From 953fbbc730bee402cdaf7913498758138cf22eb7 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 13:32:10 +0200 Subject: [PATCH 001/181] #783: Object Matte effect from a point / box prompt, tracked Problem: After Effects 26.2's Object Matte (pick an object with a click, optionally a box, get a matte that follows it) had no counterpart; only stroke-driven Roto Brush existed. Fix: an Object Matte effect (Effect > Matte, ec.matte.objectmatte) turns the Object Point (and optional box) on a Reference Frame into Roto Brush strokes and segments them with Roto Brush's engine, content-keyed cache and the model chosen in Settings > Roto Brush (MobileSAM, classical graph cut without it). Track Object carries the matte frame by frame up to 600 frames each way; otherwise every frame is segmented on its own. Feather, Contrast, Shift Edge, Invert and a Matte view. The engine keeps its Input Key current so upstream edits recompute it. objectMatte.apply creates one from agents / the CLI. Unusable prompts pass the layer through. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/lib.rs | 13 +- crates/effects/src/object_matte.rs | 468 ++++++++++++++++++++++++ crates/effects/src/roto.rs | 8 +- crates/engine/src/commands/roto_cmds.rs | 90 +++++ crates/engine/src/roto.rs | 20 +- crates/engine/src/tests_roto.rs | 75 ++++ docs/effects.md | 7 +- docs/gaps.md | 2 +- docs/target-app-parity.md | 4 +- 9 files changed, 674 insertions(+), 13 deletions(-) create mode 100644 crates/effects/src/object_matte.rs diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 7e968297a..d32cfb2e1 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -40,6 +40,7 @@ pub mod mocha_shape; mod noise; mod noise2; mod noise3; +pub mod object_matte; mod obsolete; mod ocio; pub mod ocio_config; @@ -497,6 +498,7 @@ pub fn registry() -> &'static [EffectSpec] { v.extend(camera_tracker::specs()); v.extend(face_track::specs()); v.extend(roto::specs()); + v.extend(object_matte::specs()); v.sort_by(|a, b| a.category.cmp(b.category).then(a.name.cmp(b.name))); for s in v.iter_mut() { if GPU_EFFECTS.contains(&s.id) { @@ -805,7 +807,15 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[ /// Display names of nested parameter groups by match id. fn group_name(m: &str) -> &str { - warp_stab::GROUPS.iter().chain(roto::GROUPS).chain(keylight::GROUPS).chain(PARAM_GROUPS).find(|(id, _)| *id == m).map(|(_, n)| *n).unwrap_or(m) + warp_stab::GROUPS + .iter() + .chain(roto::GROUPS) + .chain(object_matte::GROUPS) + .chain(keylight::GROUPS) + .chain(PARAM_GROUPS) + .find(|(id, _)| *id == m) + .map(|(_, n)| *n) + .unwrap_or(m) } /// The nested group at `path` (`borders/autoScale`) under `g`, created on first use. @@ -1180,6 +1190,7 @@ pub const TIME_DEPENDENT: &[&str] = &[ camera_tracker::ID, // Each frame has its own segmentation. roto::ID, + object_matte::ID, ]; /// See [`TIME_DEPENDENT`]. diff --git a/crates/effects/src/object_matte.rs b/crates/effects/src/object_matte.rs new file mode 100644 index 000000000..3b7b7c28e --- /dev/null +++ b/crates/effects/src/object_matte.rs @@ -0,0 +1,468 @@ +//! Object Matte (Effect ▸ Matte): isolate one object from a click, and follow it through the +//! shot (After Effects 26.2's Object Matte, from its public description: pick an object with a +//! point, optionally a box around it, and get a matte that tracks it). +//! +//! The prompt is the **Object Point** (layer pixels) and, with **Use Box**, a box around the +//! object. On the **Reference Frame** it is segmented by the same engine as Roto Brush: the +//! trained model chosen in Settings ▸ Roto Brush (MobileSAM, `effectcraft_segment`) when one is +//! installed, the classical graph cut otherwise (the point is a foreground stroke, the box's +//! edges, or without a box the layer's border, background strokes). With **Track Object** the +//! matte is then carried frame by frame up to **Frames Before** / **Frames After** away (optical +//! flow, re-segmented by the model prompted with the carried matte); without it every frame is +//! segmented on its own from the prompt (useful when the point is animated). +//! +//! Like Roto Brush, the segmentation is derived data in Roto Brush's content-keyed cache +//! ([`crate::roto::cached`]): the chain seed holds the engine-maintained Input Key (what the +//! frames are made of), the layer size, the frame rate, the mode and the model, and every +//! frame's key the prompt, so edits upstream or to the prompt recompute it and undo never sees a +//! stale matte. **Feather**, **Contrast** and **Shift Edge** shape the matte, **Invert** keeps +//! everything but the object and **View ▸ Matte** shows the matte in grey. + +use std::sync::Arc; + +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::Image; +use effectcraft_segment::MaskModel; +use effectcraft_track::roto::refine::MatteParams; +use effectcraft_track::roto::{RotoData, SegOpts, Stroke, StrokeKind}; +use rayon::prelude::*; + +use crate::roto::{self, Chain, INPUT_KEY}; +use crate::warp_stab::param; +use crate::{Buf, EffectCtx, EffectSpec, Params, num, p, popup, slider}; + +/// The effect's id. +pub const ID: &str = "ec.matte.objectmatte"; + +/// Display names of the instance's parameter groups. +pub const GROUPS: &[(&str, &str)] = &[("objectTracking", "Tracking"), ("objectMatte", "Matte")]; + +pub const VIEWS: [&str; 2] = ["Final Output", "Matte"]; + +/// Most frames a matte is carried from the reference frame each way. +pub const MAX_REACH: f64 = 600.0; +/// Default reach each way (frames). +pub const DEFAULT_REACH: f64 = 30.0; + +/// Radius (layer pixels) of the foreground stroke the Object Point becomes. +const POINT_RADIUS: f64 = 3.0; +/// Radius (layer pixels) of the background strokes along the box / the layer's border. +const EDGE_RADIUS: f64 = 2.0; + +pub fn specs() -> Vec { + vec![EffectSpec { + id: ID, + name: "Object Matte", + category: "Matte", + params: vec![ + p("objectPoint", "Object Point", Value::Vec2([0.5, 0.5]), ParamUi::Point), + p("useBox", "Use Box", Value::Bool(false), ParamUi::Checkbox), + p("boxTopLeft", "Box Top Left", Value::Vec2([0.25, 0.25]), ParamUi::Point), + p("boxBottomRight", "Box Bottom Right", Value::Vec2([0.75, 0.75]), ParamUi::Point), + p("objectTracking/trackObject", "Track Object", Value::Bool(true), ParamUi::Checkbox), + p("objectTracking/referenceFrame", "Reference Frame", num(0.0), ParamUi::Number), + p("objectTracking/framesBefore", "Frames Before", num(DEFAULT_REACH), slider(0.0, MAX_REACH, 0.0, 120.0, 0)), + p("objectTracking/framesAfter", "Frames After", num(DEFAULT_REACH), slider(0.0, MAX_REACH, 0.0, 120.0, 0)), + p("objectMatte/feather", "Feather", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("objectMatte/contrast", "Contrast", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0)), + p("objectMatte/shiftEdge", "Shift Edge", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 0)), + p("invert", "Invert", Value::Bool(false), ParamUi::Checkbox), + p("view", "View", Value::Enum(0), popup(&VIEWS)), + p(INPUT_KEY, "Input Key", Value::Str(String::new()), ParamUi::Hidden), + ], + render, + gpu: false, + float: true, + }] +} + +fn f(params: &Params, id: &str) -> f64 { + param(params, id).map(Value::as_f64).unwrap_or(0.0) +} +fn b(params: &Params, id: &str) -> bool { + param(params, id).map(Value::as_bool).unwrap_or(false) +} +fn v2(params: &Params, id: &str) -> Option<[f64; 2]> { + let v = param(params, id)?.as_vec2(); + v.iter().all(|c| c.is_finite()).then_some(v) +} +fn s<'a>(params: &'a Params, id: &str) -> &'a str { + match param(params, id) { + Some(Value::Str(s)) => s, + _ => "", + } +} + +fn fnv(h: &mut u64, bytes: &[u8]) { + for b in bytes { + *h ^= *b as u64; + *h = h.wrapping_mul(0x0100_0000_01b3); + } +} + +/// A frame count parameter (finite, clamped to `lo..=hi`). +fn frames(params: &Params, id: &str, lo: f64, hi: f64) -> i64 { + let v = f(params, id); + if v.is_finite() { v.round().clamp(lo, hi) as i64 } else { 0 } +} + +/// The reference frame (layer frame). +pub fn reference_frame(params: &Params) -> i64 { + frames(params, "objectTracking/referenceFrame", -1e9, 1e9) +} + +/// Whether the matte is carried from the reference frame (else every frame is segmented on its +/// own). +pub fn tracks(params: &Params) -> bool { + b(params, "objectTracking/trackObject") +} + +/// The prompt as Roto Brush strokes on layer frame `frame`: the Object Point as a foreground +/// stroke; the box's edges as background strokes; without a box, the layer's border when there +/// is no model (the graph cut needs background samples; a model does not, and a border stroke +/// would cut objects touching the frame edge). `None` when the point is unusable. +pub fn prompt_strokes(params: &Params, layer_size: [f64; 2], frame: i64, with_model: bool) -> Option> { + let [w, h] = layer_size; + if !(w.is_finite() && h.is_finite() && w >= 1.0 && h >= 1.0) { + return None; + } + let pt = v2(params, "objectPoint")?; + let inside = |q: [f64; 2]| q[0] >= 0.0 && q[1] >= 0.0 && q[0] <= w && q[1] <= h; + if !inside(pt) { + return None; + } + let mut out = vec![Stroke { kind: StrokeKind::Fg, frame, radius: POINT_RADIUS, points: vec![pt] }]; + let rect = |x0: f64, y0: f64, x1: f64, y1: f64| vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]; + let boxed = b(params, "useBox").then(|| Some((v2(params, "boxTopLeft")?, v2(params, "boxBottomRight")?))).flatten(); + match boxed { + Some((a, c)) => { + let (x0, x1) = (a[0].min(c[0]).clamp(0.0, w), a[0].max(c[0]).clamp(0.0, w)); + let (y0, y1) = (a[1].min(c[1]).clamp(0.0, h), a[1].max(c[1]).clamp(0.0, h)); + // The point must lie inside the box, clear of its edges, for the box to mean anything. + let m = POINT_RADIUS + EDGE_RADIUS; + if pt[0] - m <= x0 || pt[0] + m >= x1 || pt[1] - m <= y0 || pt[1] + m >= y1 { + return None; + } + out.push(Stroke { kind: StrokeKind::Bg, frame, radius: EDGE_RADIUS, points: rect(x0, y0, x1, y1) }); + } + None if !with_model => { + let i = EDGE_RADIUS; + if w <= 4.0 * i || h <= 4.0 * i { + return None; + } + out.push(Stroke { kind: StrokeKind::Bg, frame, radius: EDGE_RADIUS, points: rect(i, i, w - i, h - i) }); + } + None => {} + } + Some(out) +} + +/// The segmentation data the matte of layer frame `f` comes from: with Track Object, the prompt +/// on the reference frame and the span it is carried over; otherwise the prompt on `f` alone. +pub fn roto_data(params: &Params, layer_size: [f64; 2], f: i64, with_model: bool) -> Option { + if tracks(params) { + let base = reference_frame(params); + let before = frames(params, "objectTracking/framesBefore", 0.0, MAX_REACH); + let after = frames(params, "objectTracking/framesAfter", 0.0, MAX_REACH); + let strokes = prompt_strokes(params, layer_size, base, with_model)?; + Some(RotoData { base: Some(base), span: [base.saturating_sub(before), base.saturating_add(after)], strokes }) + } else { + Some(RotoData { base: Some(f), span: [f, f], strokes: prompt_strokes(params, layer_size, f, with_model)? }) + } +} + +/// Seed of the chain keys: what the frames are (the Input Key), the layer size, the frame rate, +/// the mode and the model. The prompt is in each frame's key. +pub fn seed(params: &Params, layer_size: [f64; 2], fps: f64, model: Option<&dyn MaskModel>) -> u64 { + let mut h = 0xcbf2_9ce4_8422_2325; + fnv(&mut h, ID.as_bytes()); + fnv(&mut h, s(params, INPUT_KEY).as_bytes()); + for v in [layer_size[0], layer_size[1], fps] { + fnv(&mut h, &v.to_bits().to_le_bytes()); + } + fnv(&mut h, &[tracks(params) as u8]); + fnv(&mut h, model.map_or(effectcraft_segment::CLASSICAL, |m| m.info().id).as_bytes()); + h +} + +/// Segmentation settings at `scale` (Roto Brush's defaults). +fn seg_opts(scale: f64) -> SegOpts { + SegOpts { scale, search_radius: 15.0, motion_threshold: 10.0, motion_damping: 20.0, best: false } +} + +/// Matte settings: Feather, Contrast, Shift Edge (no Refine Edge band). +pub fn matte_params(params: &Params) -> MatteParams { + let num = |id: &str| { + let v = f(params, id); + if v.is_finite() { v } else { 0.0 } + }; + MatteParams { + rb_feather: num("objectMatte/feather").clamp(0.0, 100.0), + rb_contrast: num("objectMatte/contrast").clamp(0.0, 100.0), + rb_shift: num("objectMatte/shiftEdge").clamp(-100.0, 100.0), + decontaminate: false, + ..MatteParams::default() + } +} + +/// What a frame's matte is: the object's alpha on the layer grid, or why there is none. +#[derive(Clone, Debug, PartialEq)] +pub enum FrameMatte { + /// The object's alpha (0–1) on the frame's layer grid. + Alpha(Vec), + /// The prompt is unusable (point outside the layer, outside the box…): the layer passes. + NoPrompt, + /// The frame is beyond the tracked range, or its matte could not be computed. + Outside, +} + +/// The matte of layer frame `f` on the layer grid of `img` (the effect's input on that frame at +/// `scale`), computing what is missing. `frame(g)`: the input on frame g on the same grid. +#[allow(clippy::too_many_arguments)] +pub fn frame_matte( + params: &Params, + layer_size: [f64; 2], + fps: f64, + f: i64, + scale: f64, + img: &Image, + model: Option>, + frame: &mut dyn FnMut(i64) -> Option>, + cancel: &dyn Fn() -> bool, +) -> FrameMatte { + let Some(data) = roto_data(params, layer_size, f, model.is_some()) else { return FrameMatte::NoPrompt }; + if !data.in_span(f) { + return FrameMatte::Outside; + } + let keys = data.chain_keys(seed(params, layer_size, fps, model.as_deref())); + let chain = Chain { data: Arc::new(data), keys, opts: seg_opts(scale), refine_radius: 0.0, model }; + let Some(seg) = chain.ensure(f, frame, cancel) else { return FrameMatte::Outside }; + let (w, h) = (img.width as usize, img.height as usize); + let seg = if seg.w == w && seg.h == h { (*seg).clone() } else { roto::resample(&seg, w, h) }; + FrameMatte::Alpha(roto::final_alpha(img, &seg, None, None, &matte_params(params), 0.0, scale)) +} + +fn render(ctx: &EffectCtx, buf: Buf) -> Buf { + if ctx.adjustment { + return buf; + } + let fps = ctx.fps(); + let f = roto::frame_of(ctx.time, fps); + let sc = if buf.scale > 0.0 { buf.scale } else { 1.0 }; + let img = Arc::new(roto::layer_grid(&buf, ctx.layer_size)); + let (w, h) = (img.width as usize, img.height as usize); + let host = ctx.env.host; + let effects = ctx.env.effect_index; + let layer_size = ctx.layer_size; + let me = img.clone(); + let mut frame = |g: i64| -> Option> { + if g == f { + return Some(me.clone()); + } + let other = host?.self_at(g as f64 / fps, effects)?; + let im = roto::layer_grid(&other, layer_size); + (im.width as usize == w && im.height as usize == h).then(|| Arc::new(im)) + }; + let alpha = match frame_matte(ctx.params, layer_size, fps, f, sc, &img, roto::installed_model(), &mut frame, &|| false) { + FrameMatte::Alpha(a) if a.len() == w * h => Some(a), + FrameMatte::NoPrompt => return buf, + _ => None, + }; + compose(&buf, alpha.as_deref(), w, h, b(ctx.params, "invert"), param(ctx.params, "view").map(Value::as_enum).unwrap_or(0) == 1) +} + +/// The output: the input keyed by the matte (`None`: no object, all zero), or the matte itself. +fn compose(buf: &Buf, alpha: Option<&[f32]>, w: usize, h: usize, invert: bool, view_matte: bool) -> Buf { + let (ox, oy) = (buf.offset[0].round() as i64, buf.offset[1].round() as i64); + let mut out = Buf { img: Image::new(buf.img.width, buf.img.height), offset: buf.offset, scale: buf.scale }; + out.img.rows_mut().for_each(|(by, row)| { + for (bx, px) in row.iter_mut().enumerate() { + let (x, y) = (bx as i64 - ox, by as i64 - oy); + let a = if x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h { + alpha.and_then(|a| a.get(y as usize * w + x as usize)).copied().unwrap_or(0.0) + } else { + 0.0 + }; + let a = if invert { 1.0 - a } else { a }.clamp(0.0, 1.0); + let src = buf.img.get(bx as i64, by as i64); + *px = if view_matte { + let v = a * src[3]; + [v, v, v, 1.0] + } else { + [src[0] * a, src[1] * a, src[2] * a, src[3] * a] + }; + } + }); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, EffectHost, LayerPixels}; + + /// A red disk of radius 15 moving 2 px a frame right over a textured blue background + /// (100×80, 25 fps), plus a still green square in the corner. + fn frame_at(fi: i64) -> Image { + let c = [40.0 + 2.0 * fi as f64, 40.0]; + let mut img = Image::new(100, 80); + for y in 0..80 { + for x in 0..100 { + let d = ((x as f64 + 0.5 - c[0]).hypot(y as f64 + 0.5 - c[1])) - 15.0; + let a = (0.5 - d).clamp(0.0, 1.0) as f32; + let tex = ((x * 7 + y * 13) % 9) as f32 / 60.0; + let bg = if x >= 80 && y >= 60 { [0.1, 0.8, 0.2] } else { [0.1, 0.4 + tex, 0.7] }; + let fg = [0.85 + tex, 0.2, 0.1]; + img.set(x, y, [fg[0] * a + bg[0] * (1.0 - a), fg[1] * a + bg[1] * (1.0 - a), fg[2] * a + bg[2] * (1.0 - a), 1.0]); + } + } + img + } + fn frame_of_time(t: f64) -> Image { + frame_at((t * 25.0 + 1e-6).floor() as i64) + } + + struct Seq; + impl EffectHost for Seq { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn self_at(&self, t: f64, _: usize) -> Option { + Some(Buf { img: frame_of_time(t), offset: [0.0; 2], scale: 1.0 }) + } + } + + fn params(vals: &[(&str, Value)]) -> Params { + let s = &specs()[0]; + let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), crate::default_value(p, [100.0, 80.0]))).collect() }; + for (k, v) in vals { + p.values.insert(k.to_string(), v.clone()); + } + p + } + + fn run(p: &Params, fi: i64) -> Image { + let host = Seq; + let t = fi as f64 / 25.0 + 0.001; + let ctx = EffectCtx { + params: p, + time: t, + layer_size: [100.0, 80.0], + seed: 1, + adjustment: false, + env: EffectEnv { host: Some(&host), frame_rate: 25.0, ..Default::default() }, + }; + crate::apply(&specs()[0], &ctx, Buf { img: frame_of_time(t), offset: [0.0; 2], scale: 1.0 }).img + } + + /// Pixels whose alpha disagrees with the disk centred at `cx` (1 px of slack at the edge). + fn wrong(out: &Image, cx: f64) -> usize { + let mut n = 0; + for y in 0..80u32 { + for x in 0..100u32 { + let d = (x as f64 + 0.5 - cx).hypot(y as f64 + 0.5 - 40.0); + let a = out.data[(y * 100 + x) as usize][3]; + if (d < 14.0 && a < 0.5) || (d > 16.0 && a > 0.5) { + n += 1; + } + } + } + n + } + + #[test] + fn a_click_isolates_the_object_and_tracks_it() { + let p = params(&[("objectPoint", Value::Vec2([40.0, 40.0]))]); + // The reference frame: the disk, nothing else. + let out = run(&p, 0); + assert!(wrong(&out, 40.0) < 20, "{} wrong pixels on the reference frame", wrong(&out, 40.0)); + // Six frames on (carried through frames 1–5), the disk has moved 12 px and the matte with it. + let out = run(&p, 6); + assert!(wrong(&out, 52.0) < 30, "{} wrong pixels on frame 6", wrong(&out, 52.0)); + // Beyond Frames After: no matte. + let p2 = params(&[("objectPoint", Value::Vec2([40.0, 40.0])), ("objectTracking/framesAfter", Value::Scalar(3.0))]); + assert!(run(&p2, 6).data.iter().all(|px| px[3] == 0.0)); + // Invert keeps everything but the disk; View ▸ Matte shows it in grey, opaque. + let inv = run(¶ms(&[("objectPoint", Value::Vec2([40.0, 40.0])), ("invert", Value::Bool(true))]), 0); + assert!(inv.data[40 * 100 + 40][3] < 0.5 && inv.data[5 * 100 + 95][3] > 0.5); + let m = run(¶ms(&[("objectPoint", Value::Vec2([40.0, 40.0])), ("view", Value::Enum(1))]), 0); + assert!(m.data[40 * 100 + 40][0] > 0.5 && m.data[5 * 100 + 95][0] < 0.5 && m.data.iter().all(|px| px[3] == 1.0)); + } + + #[test] + fn without_tracking_every_frame_follows_the_prompt() { + // The point animated onto the disk at frame 9: frame 9 is segmented from it alone. + let p = params(&[("objectPoint", Value::Vec2([58.0, 40.0])), ("objectTracking/trackObject", Value::Bool(false))]); + let out = run(&p, 9); + assert!(wrong(&out, 58.0) < 20, "{} wrong pixels", wrong(&out, 58.0)); + } + + #[test] + fn a_box_and_bad_prompts() { + // A box around the green square, the point in it: the square, not the disk. + let p = params(&[ + ("objectPoint", Value::Vec2([90.0, 70.0])), + ("useBox", Value::Bool(true)), + ("boxTopLeft", Value::Vec2([74.0, 54.0])), + ("boxBottomRight", Value::Vec2([100.0, 80.0])), + ]); + let out = run(&p, 0); + assert!(out.data[70 * 100 + 90][3] > 0.5 && out.data[40 * 100 + 40][3] < 0.5); + // Unusable prompts pass the layer through instead of failing: a point off the layer, a + // non-finite point, a point outside its box. + let src = frame_at(0); + for vals in [ + vec![("objectPoint", Value::Vec2([-5.0, 40.0]))], + vec![("objectPoint", Value::Vec2([f64::NAN, 40.0]))], + vec![ + ("objectPoint", Value::Vec2([40.0, 40.0])), + ("useBox", Value::Bool(true)), + ("boxTopLeft", Value::Vec2([60.0, 10.0])), + ("boxBottomRight", Value::Vec2([90.0, 30.0])), + ], + ] { + let p = params(&vals); + assert_eq!(run(&p, 0).data, src.data, "{vals:?}"); + } + // A huge reference frame or reach doesn't overflow. + let p = params(&[("objectTracking/referenceFrame", Value::Scalar(1e300)), ("objectTracking/framesBefore", Value::Scalar(f64::INFINITY))]); + assert!(run(&p, 0).data.iter().all(|px| px[3] == 0.0)); + } + + /// A stand-in model (the weights are an optional download): a disk around the first + /// foreground point, radius 15, so the test sees the model's answer in the matte. + #[derive(Default)] + struct Stub { + calls: std::sync::atomic::AtomicUsize, + } + impl MaskModel for Stub { + fn info(&self) -> &'static effectcraft_segment::ModelInfo { + &effectcraft_segment::MOBILE_SAM + } + fn segment(&self, _: &[[f32; 3]], w: usize, h: usize, prompt: &effectcraft_segment::Prompt) -> effectcraft_segment::Result> { + self.calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let Some(([cx, cy], _)) = prompt.points.iter().find(|(_, fg)| *fg) else { return Err("no point".into()) }; + Ok((0..w * h).map(|i| (((i % w) as f32 + 0.5 - cx).hypot((i / w) as f32 + 0.5 - cy) < 15.0) as u8 as f32).collect()) + } + } + + #[test] + fn segments_with_the_installed_model_without_a_border_stroke() { + let p = params(&[("objectPoint", Value::Vec2([40.0, 40.0]))]); + // With a model, no border stroke: an object touching the frame edge stays whole. + assert_eq!(prompt_strokes(&p, [100.0, 80.0], 0, true).map(|s| s.len()), Some(1)); + assert_eq!(prompt_strokes(&p, [100.0, 80.0], 0, false).map(|s| s.len()), Some(2)); + // The model's answer is part of the cache key, and drives the matte. + let stub = Arc::new(Stub::default()); + assert_ne!(seed(&p, [100.0, 80.0], 25.0, Some(stub.as_ref())), seed(&p, [100.0, 80.0], 25.0, None)); + let img = frame_at(0); + let mut frame = |g: i64| Some(Arc::new(frame_at(g))); + let FrameMatte::Alpha(a) = frame_matte(&p, [100.0, 80.0], 25.0, 0, 1.0, &img, Some(stub.clone()), &mut frame, &|| false) else { panic!("no matte") }; + assert!(a[40 * 100 + 40] > 0.5 && a[5 * 100 + 5] < 0.5); + assert_eq!(stub.calls.load(std::sync::atomic::Ordering::Relaxed), 1); + } +} diff --git a/crates/effects/src/roto.rs b/crates/effects/src/roto.rs index d514e80a1..cd2d240e4 100644 --- a/crates/effects/src/roto.rs +++ b/crates/effects/src/roto.rs @@ -195,6 +195,12 @@ pub fn model_for(params: &Params) -> Option> { if e(params, "version") == 0 { return None; } + installed_model() +} + +/// The trained model chosen in Settings ▸ Roto Brush, if one is installed (Roto Brush 2.0 / 3.0 +/// and Object Matte segment with it). +pub fn installed_model() -> Option> { model_slot().read().ok().and_then(|m| m.clone()) } @@ -392,7 +398,7 @@ pub fn downscale(s: &FrameSeg, scale: f64) -> FrameSeg { resample(s, w, h) } -fn resample(s: &FrameSeg, w: usize, h: usize) -> FrameSeg { +pub(crate) fn resample(s: &FrameSeg, w: usize, h: usize) -> FrameSeg { if s.w == w && s.h == h { return s.clone(); } diff --git a/crates/engine/src/commands/roto_cmds.rs b/crates/engine/src/commands/roto_cmds.rs index 95326b423..ff459d0bb 100644 --- a/crates/engine/src/commands/roto_cmds.rs +++ b/crates/engine/src/commands/roto_cmds.rs @@ -1,6 +1,9 @@ //! Roto Brush & Refine Edge commands: strokes (the Roto Brush and Refine Edge tools in the Layer //! panel), propagation, the segmentation span, Freeze / Unfreeze, tool options and status. //! +//! `objectMatte.apply` adds an Object Matte effect (Effect ▸ Matte) from a point / box prompt on +//! a frame; it segments with the same engine and model as Roto Brush. +//! //! `roto.stroke` records a stroke into the layer's Roto Brush & Refine Edge effect (applying the //! effect on the first stroke, as After Effects does); the first foreground stroke sets the base //! frame and a span of 20 frames each side. Strokes on a frozen effect are refused (Unfreeze @@ -51,6 +54,15 @@ pub fn specs() -> Vec { always, options ), + cmd!( + "objectMatte.apply", + "Object Matte", + [], + None, + "{layer?, point: [x, y] (layer pixels: the object), box?: [x0, y0, x1, y1] (layer pixels, around the object), frame? (reference layer frame; default current), track?: follow the object from that frame (default true), before?, after? (frames to follow it; default 30, up to 600)}", + has_comp, + object_matte_apply + ), query!( "roto.status", "Roto Brush Status", @@ -422,3 +434,81 @@ fn status(s: &mut Session, p: &Value) -> Result { out["computed"] = json!(computed.iter().filter(|(_, c)| **c).map(|(f, _)| *f).collect::>()); Ok(out) } + +/// Two finite numbers. +fn xy_p(p: &Value, k: &str, n: usize, cmd: &str) -> Result>> { + let Some(v) = p.get(k).filter(|v| !v.is_null()) else { return Ok(None) }; + let a = v.as_array().filter(|a| a.len() == n).ok_or_else(|| bad(cmd, format!("`{k}` is {n} numbers (layer pixels)")))?; + let out: Vec = a.iter().filter_map(Value::as_f64).filter(|x| x.is_finite()).collect(); + if out.len() != n { + return Err(bad(cmd, format!("`{k}` is {n} numbers (layer pixels)"))); + } + Ok(Some(out)) +} + +fn object_matte_apply(s: &mut Session, p: &Value) -> Result { + use effectcraft_effects::object_matte as om; + let cmd = "objectMatte.apply"; + let (cid, lid) = layer_p(s, p, cmd)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let layer = comp.layer(lid).ok_or_else(|| bad(cmd, "no layer"))?; + if !layer.has_video() || layer.switches.adjustment || layer.effects().is_none() { + return Err(bad(cmd, "Object Matte works on footage, solid and composition layers")); + } + let size = rt::layer_size(&s.project, comp, layer); + let point = xy_p(p, "point", 2, cmd)?.ok_or_else(|| bad(cmd, "missing `point` [x, y] (layer pixels)"))?; + let inside = |x: f64, y: f64| (0.0..=size[0]).contains(&x) && (0.0..=size[1]).contains(&y); + if !inside(point[0], point[1]) { + return Err(bad(cmd, format!("`point` is outside the layer ({}×{})", size[0], size[1]))); + } + let bx = xy_p(p, "box", 4, cmd)?; + if let Some(b) = &bx { + let (x0, x1, y0, y1) = (b[0].min(b[2]), b[0].max(b[2]), b[1].min(b[3]), b[1].max(b[3])); + if !(point[0] > x0 && point[0] < x1 && point[1] > y0 && point[1] < y1) { + return Err(bad(cmd, "`point` must be inside `box`")); + } + } + let frame = frame_p(s, p, cid, layer); + let limits = rt::frame_limits(comp, layer); + if frame < limits[0] || frame > limits[1] { + return Err(bad(cmd, format!("frame {frame} is outside the layer ({}–{})", limits[0], limits[1]))); + } + let track = b_p(p, "track").unwrap_or(true); + let reach = |k: &str| f_p(p, k).filter(|v| v.is_finite()).unwrap_or(om::DEFAULT_REACH).round().clamp(0.0, om::MAX_REACH); + let (before, after) = (reach("before"), reach("after")); + let spec = effectcraft_effects::find(om::ID).ok_or_else(|| EngineError::Other("Object Matte effect missing".into()))?; + let uid = s.edit("Object Matte", None, |proj, _| { + let mut next = proj.next_id; + let l = layer_mut(proj, cid, lid)?; + let fxg = l.props.sub_mut("effects").ok_or_else(|| bad(cmd, "this layer has no effects"))?; + let n = fxg.groups().filter(|g| rt::is_object_matte(g)).count(); + let name = if n == 0 { spec.name.to_string() } else { format!("{} {}", spec.name, n + 1) }; + let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), &name, size); + let mut set = |path: &str, v: KV| { + let (grp, leaf) = path.rsplit_once('/').map_or((None, path), |(a, b)| (Some(a), b)); + let target = match grp { + Some(sub) => g.sub_mut(sub), + None => Some(&mut g), + }; + if let Some(pr) = target.and_then(|t| t.get_mut(leaf)) { + pr.value = v; + } + }; + set("objectPoint", KV::Vec2([point[0], point[1]])); + if let Some(b) = &bx { + set("useBox", KV::Bool(true)); + set("boxTopLeft", KV::Vec2([b[0].min(b[2]), b[1].min(b[3])])); + set("boxBottomRight", KV::Vec2([b[0].max(b[2]), b[1].max(b[3])])); + } + set("objectTracking/trackObject", KV::Bool(track)); + set("objectTracking/referenceFrame", KV::Scalar(frame as f64)); + set("objectTracking/framesBefore", KV::Scalar(before)); + set("objectTracking/framesAfter", KV::Scalar(after)); + let u = g.uid; + fxg.children.push(g.into()); + proj.next_id = proj.next_id.max(next); + Ok(u) + })?; + s.state.selected_layers = vec![lid]; + Ok(json!({"layer": lid.0, "effect": uid, "frame": frame, "track": track, "span": [frame - before as i64, frame + after as i64]})) +} diff --git a/crates/engine/src/roto.rs b/crates/engine/src/roto.rs index ca92a74bc..b8c2c649c 100644 --- a/crates/engine/src/roto.rs +++ b/crates/engine/src/roto.rs @@ -147,6 +147,12 @@ pub(crate) fn is_roto(g: &PropGroup) -> bool { matches!(&g.kind, GroupKind::Effect { effect } if effect == fx::ID) } +/// An Object Matte instance (its Input Key is kept current like Roto Brush's; it segments on +/// demand while rendering). +pub(crate) fn is_object_matte(g: &PropGroup) -> bool { + matches!(&g.kind, GroupKind::Effect { effect } if effect == effectcraft_effects::object_matte::ID) +} + fn str_of(g: &PropGroup, m: &str) -> String { match g.get(m).map(|p| &p.value) { Some(Value::Str(s)) => s.clone(), @@ -188,8 +194,9 @@ pub fn input_key(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> String { format!("{:016x}", fnv(&s)) } -/// After an edit: refresh Input Keys; frozen mattes made from other frames are dropped. -/// Returns the instances whose segmentation changed (to propagate again). +/// After an edit: refresh Input Keys (Roto Brush and Object Matte); frozen mattes made from +/// other frames are dropped. Returns the Roto Brush instances whose segmentation changed (to +/// propagate again). pub(crate) fn sync(before: &Project, after: &mut Project) -> Vec<(ItemId, LayerId, Uid)> { let mut todo = vec![]; let mut set = vec![]; @@ -197,13 +204,16 @@ pub(crate) fn sync(before: &Project, after: &mut Project) -> Vec<(ItemId, LayerI let ItemKind::Comp(c) = &it.kind else { continue }; for l in &c.layers { let Some(fxg) = l.effects() else { continue }; - for g in fxg.groups().filter(|g| is_roto(g)) { + for g in fxg.groups().filter(|g| is_roto(g) || is_object_matte(g)) { let key = input_key(after, *cid, l, g.uid); + let roto = is_roto(g); let old = before.comp(*cid).and_then(|bc| bc.layer(l.id)).and_then(|ol| ol.props.find_group(g.uid)); if str_of(g, INPUT_KEY) != key { set.push((*cid, l.id, g.uid, key)); - todo.push((*cid, l.id, g.uid)); - } else if old.is_none_or(|o| str_of(o, fx::STROKES) != str_of(g, fx::STROKES)) { + if roto { + todo.push((*cid, l.id, g.uid)); + } + } else if roto && old.is_none_or(|o| str_of(o, fx::STROKES) != str_of(g, fx::STROKES)) { todo.push((*cid, l.id, g.uid)); } } diff --git a/crates/engine/src/tests_roto.rs b/crates/engine/src/tests_roto.rs index c1cbd2c8d..cdf585400 100644 --- a/crates/engine/src/tests_roto.rs +++ b/crates/engine/src/tests_roto.rs @@ -351,3 +351,78 @@ fn offloaded_propagation_streams_segmentations_back() { s.execute("roto.propagate", json!({"layer": clip.0, "wait": true})).unwrap(); assert!(s.offloaded.is_empty()); } + +/// Object Matte: one click on the disk on frame 0 isolates it there and on the frames it is +/// followed to; the Input Key follows the layer's frames; bad prompts are refused. +#[test] +fn object_matte_from_a_click() { + hold_roto_cache_test_lock(); + let (mut s, clip, solid) = setup(|f| draw(center(f))); + let cid = s.active_comp_id().unwrap(); + s.edit("remove solid", None, |p, _| { + p.comp_mut(cid).unwrap().layers.retain(|l| l.id != solid); + for it in p.items.values_mut() { + if let effectcraft_project::ItemKind::Footage(f) = &mut it.kind { + f.path = "object-matte.mov".into(); + } + } + Ok(()) + }) + .unwrap(); + s.execute("time.set", json!({"time": 0})).unwrap(); + let c = center(0); + let r = s.execute_checked("objectMatte.apply", json!({"layer": clip.0, "point": c, "after": 6})).unwrap(); + assert_eq!(r["frame"], 0); + assert_eq!(r["span"], json!([-30, 6])); + let uid = r["effect"].as_u64().unwrap(); + let key = |s: &Session| { + let g = s.active_comp().unwrap().layer(clip).unwrap().props.find_group(uid).unwrap().clone(); + crate::roto::params_static(&g).get(effectcraft_effects::roto::INPUT_KEY).cloned() + }; + let k0 = key(&s); + assert!(matches!(&k0, Some(effectcraft_keyframe::Value::Str(k)) if !k.is_empty()), "{k0:?}"); + let wrong = |s: &Session, f: u32| { + let c = center(f); + alpha_at(s, f) + .iter() + .enumerate() + .filter(|(i, v)| { + let d = (((*i as u32 % W) as f64 + 0.5 - c[0]).hypot((*i as u32 / W) as f64 + 0.5 - c[1])) - R; + (d < -3.0 && **v < 0.5) || (d > 3.0 && **v > 0.5) + }) + .count() + }; + let w0 = wrong(&s, 0); + assert!(w0 < 40, "{w0} wrong pixels on the reference frame"); + let w4 = wrong(&s, 4); + assert!(w4 < 60, "{w4} wrong pixels on frame 4"); + // Past Frames After: transparent. + assert!(alpha_at(&s, 8).iter().all(|v| *v == 0.0)); + // Other frames under the effect: a new Input Key (the mattes are recomputed). + s.edit("swap clip", None, |p, _| { + for it in p.items.values_mut() { + if let effectcraft_project::ItemKind::Footage(f) = &mut it.kind { + f.path = "object-matte-2.mov".into(); + } + } + Ok(()) + }) + .unwrap(); + assert_ne!(key(&s), k0); + // Bad prompts are refused. + for bad in [ + json!({"layer": clip.0}), + json!({"layer": clip.0, "point": [-10, 5]}), + json!({"layer": clip.0, "point": [10, "x"]}), + json!({"layer": clip.0, "point": c, "box": [0, 0, 10, 10]}), + json!({"layer": clip.0, "point": c, "frame": 999}), + ] { + assert!(s.execute_checked("objectMatte.apply", bad.clone()).is_err(), "{bad}"); + } + // A box and a static prompt are accepted; the second instance is numbered. + let r = s + .execute_checked("objectMatte.apply", json!({"layer": clip.0, "point": c, "box": [c[0] + 50.0, c[1] + 50.0, c[0] - 50.0, c[1] - 50.0], "track": false})) + .unwrap(); + let g = s.active_comp().unwrap().layer(clip).unwrap().props.find_group(r["effect"].as_u64().unwrap()).unwrap().clone(); + assert_eq!(g.name, "Object Matte 2"); +} diff --git a/docs/effects.md b/docs/effects.md index e498cf1ae..84f16ef86 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,11 +3,11 @@ -EffectCraft ships 306 effects with 3014 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. +EffectCraft ships 307 effects with 3028 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. - **GPU**: 280 effects also run on the GPU compositor with identical results. -- **32**: 306 effects process 32-bit float (HDR, overbright) pixels without clamping. -- **Status**: 306 implemented in full, 0 partial (what is missing is listed). +- **32**: 307 effects process 32-bit float (HDR, overbright) pixels without clamping. +- **Status**: 307 implemented in full, 0 partial (what is missing is listed). Effects are addressed by id (`ec..`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`). @@ -255,6 +255,7 @@ Effects are addressed by id (`ec..`) or display name in commands |---|---|---:|:-:|:-:|---| | 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` | 24 | | 32 | Implemented | diff --git a/docs/gaps.md b/docs/gaps.md index acb22a630..577f7bd6f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -70,7 +70,7 @@ are grouped by area; the issue tracker has the rest. | 11 | **Text rendering and layout** | #453 CJK text in comps, #316 paragraph alignment inverted, #483 squeezed at 4:3, #417 soft at Fit, #583 font search, #550 toolbar font options | Text is in almost every motion graphic | 10–20 h | G2 · [ui-parity.md](ui-parity.md) | | 12 | **3D interaction and depth** | No 3D transform gizmos (#392, #347); Z position (#489); camera aperture (#260); reflections (#261); bevel options (#315); 3D issue reopened (#307) | Advanced 3D is After Effects' growth area since 2023 | 20–35 h | G2 · [ui-parity.md](ui-parity.md) | | 13 | **3D model formats and materials** | After Effects 26 imports FBX, USD/USDZ, STL besides glTF and OBJ (`usd_plugins`), and Substance 3D materials with mesh displacement (26.0–26.2); we read glTF and OBJ | Product shots and 3D scenes come as FBX/USDZ | 25–45 h | — · [file-format-parity.md](file-format-parity.md) | -| 14 | **Object Matte** (26.2, disk cache in 26.5) | Not present; MobileSAM is already in `effectcraft-segment` and could back it | The new one-click roto; Roto Brush covers part of it | 15–30 h | G7 · [target-app-parity.md](target-app-parity.md) | +| 14 | **Object Matte** (26.2, disk cache in 26.5) | First slice (#783): the Object Matte effect (Effect ▸ Matte) and `objectMatte.apply` turn a point (and optional box) on a reference frame into a matte and follow the object up to 600 frames each way, or segment every frame on its own; Roto Brush's engine, cache and model (MobileSAM from Settings ▸ Roto Brush, classical graph cut without it). Missing: the click / hover tool in the viewer, a background propagation job, the disk cache, comparison with After Effects | The new one-click roto | 10–20 h | G7 · [target-app-parity.md](target-app-parity.md) | | 15 | **Hardware video decode and encode** | No VideoToolbox, NVENC, Quick Sync or VA-API; #498 asks for it; FilmCraft has VideoToolbox / NVENC / VA-API paths to borrow | 4K playback and export speed | 25–45 h | G5/G6 · [hardware-parity.md](hardware-parity.md) | | 16 | **Localization depth** | Menus in 6 languages; panels in 4; effect names and ~1,650 effect parameter names untranslated; French, German, Korean, Italian, Russian (After Effects ships them) absent; #599 Hangul boxes on macOS | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | | 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists TGA, DPX, HDR, AVI, MXF; `media::is_importable` has none of them; #431 any image imports as a sequence; #324 MP3 goes silent | Confusing failures on common VFX formats (DPX, TGA) | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 12d37245b..f0ed90b6e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -122,7 +122,7 @@ compositing and effects dominate; audio is light). Hours are to full parity for | Text and typography | 7% | 94% | 50% | 20–35 | CJK text in comps renders wrongly (#453); paragraph alignment inverted (#316); squeezed text at 4:3 (#483); font search (#583); soft text at Fit (#417); variable-font filter (26.3) | | Effects and animation presets | 14% | 85% | 40% | 70–130 | All 306 effects exist, but rendered output is unmeasured against After Effects; 8 built-in presets against After Effects' 621; open effect bugs (#580 crash, #514, #526, #463, #369, #473); effect colour labels (26.5); see [effects-parity.md](effects-parity.md) | | 3D (Classic and Advanced) | 6% | 82% | 35% | 35–65 | No FBX/USD/STL models, no Substance materials, mesh displacement unverified; no 3D transform gizmos (#392, #347); Z position (#489); reflections (#261); camera aperture (#260); bevel options (#315) | -| Tracking and AI-assisted tools | 5% | 80% | 35% | 35–70 | No Object Matte (26.2); Roto Brush (MobileSAM) and face tracking (MediaPipe) unmeasured against After Effects; camera-tracker view (#505) | +| Tracking and AI-assisted tools | 5% | 80% | 35% | 35–70 | Object Matte (26.2) is a first slice: an effect and command from a point / box prompt, no viewer tool or disk cache; Roto Brush (MobileSAM) and face tracking (MediaPipe) unmeasured against After Effects; camera-tracker view (#505) | | Preview and cache | 7% | 92% | 40% | 25–50 | Playback stutters on cached frames (#595, #304); slow scrubbing (#329); late audio (#554, #504, #335); no benchmark against After Effects | | Render queue and export | 7% | 85% | 55% | 25–45 | No hardware encoding; ProRes 4444 colour tag (#340); rendering over an existing file (#325); default macOS output location (#446); uncompressed single-file output (#372); HEVC/AV1 without B-frames | | Audio | 2% | 85% | 45% | 6–12 | Audio sync and crackle (#335); audio to keyframes depth | @@ -144,7 +144,7 @@ Feature depth (≈ 50%) feeds the overall *ready* number with the other dimensio | Stability | 45% | 40–80 | Open crash reports #580, #591, #501, #426, #341, #306, #234, #511; no CI runs the tests on pull requests | [gaps.md](gaps.md#g3-stability-and-a-green-main) | | Platforms | 65% | 30–60 | Releases for macOS, Windows x64/x86/ARM64, Linux, FreeBSD and the web (more than After Effects), but Linux and Windows users hit basic bugs | [gaps.md](gaps.md#g2-real-user-reliability-on-every-platform) | | Ecosystem and plug-ins | 20% | 40–80 | After Effects scripts and expressions mostly carry over; no After Effects SDK plug-ins, `.ffx` presets or `.mogrt`; our WebAssembly plug-in API has no third-party plug-ins yet | [plugins.md](plugins.md) | -| AI features | 35% | 40–80 (overlaps Tracking) | Roto Brush (MobileSAM) and face tracking (MediaPipe) as optional downloads; no Object Matte; the After Effects bundle ships Whisper, BERT and Marian tokenizers and a shot-cut model we have no equivalent of | [gaps.md](gaps.md) | +| AI features | 35% | 40–80 (overlaps Tracking) | Roto Brush (MobileSAM) and face tracking (MediaPipe) as optional downloads; Object Matte (point / box prompt, same model) without its viewer tool or disk cache; the After Effects bundle ships Whisper, BERT and Marian tokenizers and a shot-cut model we have no equivalent of | [gaps.md](gaps.md) | | Agent control (beyond After Effects) | ahead | — | Every command is drivable over MCP, the control channel and the CLI | [agents.md](agents.md) | **Totals.** Summing the rows double-counts: AI overlaps Tracking, performance overlaps Preview, From c21a93e45eed34cd0a6bbab3ece518b2e43b281a Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 13:33:17 +0200 Subject: [PATCH 002/181] #783: app.capabilities counts Object Matte (307 registered effects) Problem: the hand-maintained effect count in app.capabilities still said 306 after Object Matte was registered. Fix: bump it to 307. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/query.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/commands/query.rs b/crates/engine/src/commands/query.rs index 56139b6c0..2872d9a53 100644 --- a/crates/engine/src/commands/query.rs +++ b/crates/engine/src/commands/query.rs @@ -191,7 +191,7 @@ fn capabilities(s: &mut Session, _: &Value) -> Result { } /// The headline of `docs/target-app-parity.md` (kept in sync by hand at each re-measure). -const PARITY: &str = "≈91% feature breadth and ≈45% ready for real work (estimated parity with After Effects 2026 26.5, stage alpha); all After Effects effects present (306 registered)"; +const PARITY: &str = "≈91% feature breadth and ≈45% ready for real work (estimated parity with After Effects 2026 26.5, stage alpha); all After Effects effects present (307 registered)"; fn state(s: &mut Session, _: &Value) -> Result { Ok(serde_json::to_value(&s.state).unwrap_or_default()) From 4fc7b5235196732a66aa8efa4a3a3b02c48658a0 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 13:38:10 +0200 Subject: [PATCH 003/181] #783: Object Matte passes its box to the model as a box prompt Problem: with a trained model installed, Object Matte's optional box reached MobileSAM only as background points along the box's edges (an approximation), although the model's prompt encoder takes a box prompt directly. Fix: a new Roto Brush stroke kind, Box (two opposite corners), that stroke_prompt turns into the model prompt's bbox (scaled to the matte grid, corners in any order, degenerate or non-finite boxes ignored). The graph cut never rasterizes it. Object Matte emits it instead of the edge strokes when a model segments; the classical fallback keeps the edge strokes. A prior matte's box no longer replaces a box the user drew on that frame. Tests: effects object_matte::the_box_reaches_the_model_as_a_box_prompt (a stand-in model sees the box, at full and half resolution, and no edge points); track roto::tests::box_stroke_is_the_model_box_prompt. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/object_matte.rs | 56 ++++++++++++++++++++++++++---- crates/track/src/roto/mod.rs | 28 +++++++++++---- crates/track/src/roto/tests.rs | 22 ++++++++++++ 3 files changed, 94 insertions(+), 12 deletions(-) diff --git a/crates/effects/src/object_matte.rs b/crates/effects/src/object_matte.rs index 3b7b7c28e..9ee33a90a 100644 --- a/crates/effects/src/object_matte.rs +++ b/crates/effects/src/object_matte.rs @@ -5,8 +5,9 @@ //! The prompt is the **Object Point** (layer pixels) and, with **Use Box**, a box around the //! object. On the **Reference Frame** it is segmented by the same engine as Roto Brush: the //! trained model chosen in Settings ▸ Roto Brush (MobileSAM, `effectcraft_segment`) when one is -//! installed, the classical graph cut otherwise (the point is a foreground stroke, the box's -//! edges, or without a box the layer's border, background strokes). With **Track Object** the +//! installed, the classical graph cut otherwise. The model gets the point as a foreground point +//! and the box as its box prompt; the graph cut gets the point as a foreground stroke and the +//! box's edges (without a box, the layer's border) as background strokes. With **Track Object** the //! matte is then carried frame by frame up to **Frames Before** / **Frames After** away (optical //! flow, re-segmented by the model prompted with the carried matte); without it every frame is //! segmented on its own from the prompt (useful when the point is animated). @@ -119,9 +120,10 @@ pub fn tracks(params: &Params) -> bool { } /// The prompt as Roto Brush strokes on layer frame `frame`: the Object Point as a foreground -/// stroke; the box's edges as background strokes; without a box, the layer's border when there -/// is no model (the graph cut needs background samples; a model does not, and a border stroke -/// would cut objects touching the frame edge). `None` when the point is unusable. +/// stroke; the box as a box stroke (the model's box prompt) with a model, its edges as +/// background strokes for the graph cut without one; without a box, the layer's border when +/// there is no model (the graph cut needs background samples; a model does not, and a border +/// stroke would cut objects touching the frame edge). `None` when the point is unusable. pub fn prompt_strokes(params: &Params, layer_size: [f64; 2], frame: i64, with_model: bool) -> Option> { let [w, h] = layer_size; if !(w.is_finite() && h.is_finite() && w >= 1.0 && h >= 1.0) { @@ -144,7 +146,12 @@ pub fn prompt_strokes(params: &Params, layer_size: [f64; 2], frame: i64, with_mo if pt[0] - m <= x0 || pt[0] + m >= x1 || pt[1] - m <= y0 || pt[1] + m >= y1 { return None; } - out.push(Stroke { kind: StrokeKind::Bg, frame, radius: EDGE_RADIUS, points: rect(x0, y0, x1, y1) }); + out.push(if with_model { + // The model takes the box itself as its box prompt. + Stroke { kind: StrokeKind::Box, frame, radius: 0.0, points: vec![[x0, y0], [x1, y1]] } + } else { + Stroke { kind: StrokeKind::Bg, frame, radius: EDGE_RADIUS, points: rect(x0, y0, x1, y1) } + }); } None if !with_model => { let i = EDGE_RADIUS; @@ -438,6 +445,8 @@ mod tests { #[derive(Default)] struct Stub { calls: std::sync::atomic::AtomicUsize, + /// The box prompt of the last call. + bbox: std::sync::Mutex>, } impl MaskModel for Stub { fn info(&self) -> &'static effectcraft_segment::ModelInfo { @@ -445,6 +454,7 @@ mod tests { } fn segment(&self, _: &[[f32; 3]], w: usize, h: usize, prompt: &effectcraft_segment::Prompt) -> effectcraft_segment::Result> { self.calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + *self.bbox.lock().unwrap() = prompt.bbox; let Some(([cx, cy], _)) = prompt.points.iter().find(|(_, fg)| *fg) else { return Err("no point".into()) }; Ok((0..w * h).map(|i| (((i % w) as f32 + 0.5 - cx).hypot((i / w) as f32 + 0.5 - cy) < 15.0) as u8 as f32).collect()) } @@ -464,5 +474,39 @@ mod tests { let FrameMatte::Alpha(a) = frame_matte(&p, [100.0, 80.0], 25.0, 0, 1.0, &img, Some(stub.clone()), &mut frame, &|| false) else { panic!("no matte") }; assert!(a[40 * 100 + 40] > 0.5 && a[5 * 100 + 5] < 0.5); assert_eq!(stub.calls.load(std::sync::atomic::Ordering::Relaxed), 1); + assert_eq!(*stub.bbox.lock().unwrap(), None); + } + + #[test] + fn the_box_reaches_the_model_as_a_box_prompt() { + let p = params(&[ + ("objectPoint", Value::Vec2([90.0, 70.0])), + ("useBox", Value::Bool(true)), + ("boxTopLeft", Value::Vec2([100.0, 80.0])), + ("boxBottomRight", Value::Vec2([74.0, 54.0])), + ]); + // With a model: the point and a box stroke (corners ordered); without: the box's edges + // as a background stroke, as before. + let with = prompt_strokes(&p, [100.0, 80.0], 0, true).unwrap(); + assert_eq!(with.iter().map(|s| s.kind).collect::>(), [StrokeKind::Fg, StrokeKind::Box]); + assert_eq!(with[1].points, [[74.0, 54.0], [100.0, 80.0]]); + let without = prompt_strokes(&p, [100.0, 80.0], 0, false).unwrap(); + assert_eq!(without.iter().map(|s| s.kind).collect::>(), [StrokeKind::Fg, StrokeKind::Bg]); + // The model is called with the box itself (matte pixels at scale 1) and only one + // foreground point: no background points along the edges. + let stub = Arc::new(Stub::default()); + let img = frame_at(0); + let mut frame = |g: i64| Some(Arc::new(frame_at(g))); + let FrameMatte::Alpha(a) = frame_matte(&p, [100.0, 80.0], 25.0, 0, 1.0, &img, Some(stub.clone()), &mut frame, &|| false) else { panic!("no matte") }; + assert_eq!(*stub.bbox.lock().unwrap(), Some([74.0, 54.0, 100.0, 80.0])); + assert!(a[70 * 100 + 90] > 0.5 && a[40 * 100 + 40] < 0.5); + // At half resolution the box is scaled with the frame (another Input Key, so the + // full-resolution result above is not reused). + let mut p = p; + p.values.insert(INPUT_KEY.into(), Value::Str("half".into())); + let half = effectcraft_raster::resample(&img, 50, 40); + let mut frame = |g: i64| Some(Arc::new(effectcraft_raster::resample(&frame_at(g), 50, 40))); + let _ = frame_matte(&p, [100.0, 80.0], 25.0, 0, 0.5, &half, Some(stub.clone()), &mut frame, &|| false); + assert_eq!(*stub.bbox.lock().unwrap(), Some([37.0, 27.0, 50.0, 40.0])); } } diff --git a/crates/track/src/roto/mod.rs b/crates/track/src/roto/mod.rs index 7138a93a3..4efb43359 100644 --- a/crates/track/src/roto/mod.rs +++ b/crates/track/src/roto/mod.rs @@ -59,6 +59,9 @@ pub enum StrokeKind { Refine, /// Refine Edge tool with Alt: remove from the band. RefineErase, + /// A box around the object (Object Matte): two opposite corners in `points`. Trained models + /// get it as their box prompt; the graph cut ignores it. + Box, } impl StrokeKind { @@ -830,8 +833,9 @@ fn points_along(s: &Stroke, scale: f64, max: usize) -> Vec<[f32; 2]> { out } -/// A model's prompt from the strokes: points along the foreground and background strokes. -fn stroke_prompt(strokes: &[&Stroke], scale: f64) -> Prompt { +/// A model's prompt from the strokes: points along the foreground and background strokes, and +/// the box of a [`StrokeKind::Box`] stroke (matte pixels). +pub fn stroke_prompt(strokes: &[&Stroke], scale: f64) -> Prompt { let mut points = vec![]; for (kind, fg) in [(StrokeKind::Fg, true), (StrokeKind::Bg, false)] { let ss: Vec<&&Stroke> = strokes.iter().filter(|s| s.kind == kind).collect(); @@ -840,11 +844,20 @@ fn stroke_prompt(strokes: &[&Stroke], scale: f64) -> Prompt { points.extend(points_along(s, scale, per).into_iter().map(|p| (p, fg))); } } - Prompt { points, ..Default::default() } + let bbox = strokes.iter().filter(|s| s.kind == StrokeKind::Box).find_map(|s| box_of(s, scale)); + Prompt { points, bbox, ..Default::default() } } -/// Add a prior matte to a prompt: its box (a little larger), up to four points spread through -/// its core, and the matte itself. +/// The box `[x0, y0, x1, y1]` (matte pixels) of a box stroke: its first two points as opposite +/// corners; `None` when they are missing, not finite or the box is empty. +fn box_of(s: &Stroke, scale: f64) -> Option<[f32; 4]> { + let (a, c) = (s.points.first()?, s.points.get(1)?); + let v = [a[0].min(c[0]), a[1].min(c[1]), a[0].max(c[0]), a[1].max(c[1])].map(|v| (v * scale) as f32); + (v.iter().all(|x| x.is_finite()) && v[2] > v[0] && v[3] > v[1]).then_some(v) +} + +/// Add a prior matte to a prompt: its box (a little larger; unless the prompt has one), up to +/// four points spread through its core, and the matte itself. fn add_prior_prompt(prompt: &mut Prompt, soft: &[f32], w: usize, h: usize) -> bool { let bin: Vec = soft.iter().map(|v| (*v >= 0.5) as u8).collect(); let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize); @@ -858,7 +871,10 @@ fn add_prior_prompt(prompt: &mut Prompt, soft: &[f32], w: usize, h: usize) -> bo return false; } let (mx, my) = (((x1 - x0) as f32 * 0.05).max(4.0), ((y1 - y0) as f32 * 0.05).max(4.0)); - prompt.bbox = Some([(x0 as f32 - mx).max(0.0), (y0 as f32 - my).max(0.0), (x1 as f32 + 1.0 + mx).min(w as f32), (y1 as f32 + 1.0 + my).min(h as f32)]); + // A box the user drew on this frame wins over the prior's. + if prompt.bbox.is_none() { + prompt.bbox = Some([(x0 as f32 - mx).max(0.0), (y0 as f32 - my).max(0.0), (x1 as f32 + 1.0 + mx).min(w as f32), (y1 as f32 + 1.0 + my).min(h as f32)]); + } // Farthest-point picks among the core (deep inside), starting from the deepest pixel. let sd = matting::signed_distance(&bin, w, h); let deep = sd.iter().copied().fold(0.0f32, f32::max); diff --git a/crates/track/src/roto/tests.rs b/crates/track/src/roto/tests.rs index f92e67d5e..65d1743f9 100644 --- a/crates/track/src/roto/tests.rs +++ b/crates/track/src/roto/tests.rs @@ -428,3 +428,25 @@ fn mobilesam_segments_and_propagates_the_disk() { eprintln!("MobileSAM: base IoU {jb:.4}, worst propagated {worst:.4} (classic {cworst:.4}), {:.0} ms/frame", t.elapsed().as_secs_f64() * 1000.0 / 20.0); assert!(jb > 0.95 && worst > 0.93 && worst >= cworst - 0.01, "base {jb}, worst {worst} vs classic {cworst}"); } + +/// A box stroke becomes the model's box prompt (scaled, corners in any order); the graph cut +/// ignores it; a prior's box does not replace it. +#[test] +fn box_stroke_is_the_model_box_prompt() { + let fg = Stroke { kind: StrokeKind::Fg, frame: 0, radius: 2.0, points: vec![[50.0, 40.0]] }; + let bx = Stroke { kind: StrokeKind::Box, frame: 0, radius: 0.0, points: vec![[80.0, 60.0], [20.0, 10.0]] }; + let p = stroke_prompt(&[&fg, &bx], 0.5); + assert_eq!(p.bbox, Some([10.0, 5.0, 40.0, 30.0])); + assert_eq!(p.points, vec![([25.0, 20.0], true)]); + // Degenerate or non-finite boxes are no prompt. + for pts in [vec![[20.0, 10.0]], vec![[20.0, 10.0], [20.0, 50.0]], vec![[f64::NAN, 10.0], [80.0, 60.0]]] { + let b = Stroke { points: pts, ..bx.clone() }; + assert_eq!(stroke_prompt(&[&fg, &b], 1.0).bbox, None); + } + assert!(!StrokeKind::Box.is_segmentation()); + assert!(rasterize(&[&bx], &[StrokeKind::Fg, StrokeKind::Bg], 1.0, 100, 80).iter().all(|v| !v)); + let mut q = stroke_prompt(&[&fg, &bx], 1.0); + let soft: Vec = (0..100 * 80).map(|i| ((i % 100) < 10) as u8 as f32).collect(); + assert!(add_prior_prompt(&mut q, &soft, 100, 80)); + assert_eq!(q.bbox, Some([20.0, 10.0, 80.0, 60.0])); +} From 0c695cff7740241a38621faa334a33c164f2e197 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 13:50:01 +0200 Subject: [PATCH 004/181] #783: Object Matte tool in the Composition viewer Problem: Object Matte could only be applied through the objectMatte.apply command; After Effects 26.2 picks the object directly in the viewer (click it, or drag a box around it). Fix: an Object Matte tool (with Roto Brush & Refine Edge in the Alt+W slot, icon drawn in code). In the Composition viewer a click sets a point prompt and a drag a box prompt (its centre the point) on the footage, solid or comp layer under the pointer, converted to layer pixels through the layer's transform. The layer's Object Matte (the selected one, else its last) is re-prompted; Shift adds another. Everything goes through objectMatte.apply, which gained `effect` to re-prompt an instance (point, box or no box, reference frame; the reach kept unless given), so each prompt is one undo step; the effect is then selected, so its point controls show in the viewer. Hover draws a crosshair and the outline of the layer that would get the matte (a live matte preview would need a segmentation per pointer move). Automation ids: viewer.objectMatte.crosshair / .target / .box, tools.ObjectMatte. Tests: ui-egui ui_object_matte (hover, click, drag box re-prompts the same effect, Shift-click adds one, undo steps); engine tests_roto::object_matte_reprompt_updates_the_instance. Checked with the headless snapshot example (hover crosshair, box drag isolating a disk). Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/object_matte.rs | 8 +- crates/engine/src/commands/roto_cmds.rs | 72 +++++++-- crates/engine/src/tests_roto.rs | 39 +++++ crates/ui-egui/src/icons.rs | 10 ++ crates/ui-egui/src/panels/mod.rs | 1 + .../ui-egui/src/panels/object_matte_tool.rs | 128 ++++++++++++++++ crates/ui-egui/src/panels/viewer.rs | 18 ++- crates/ui-egui/src/state.rs | 11 +- crates/ui-egui/tests/ui_object_matte.rs | 142 ++++++++++++++++++ 9 files changed, 409 insertions(+), 20 deletions(-) create mode 100644 crates/ui-egui/src/panels/object_matte_tool.rs create mode 100644 crates/ui-egui/tests/ui_object_matte.rs diff --git a/crates/effects/src/object_matte.rs b/crates/effects/src/object_matte.rs index 9ee33a90a..a7db562a9 100644 --- a/crates/effects/src/object_matte.rs +++ b/crates/effects/src/object_matte.rs @@ -113,6 +113,11 @@ pub fn reference_frame(params: &Params) -> i64 { frames(params, "objectTracking/referenceFrame", -1e9, 1e9) } +/// Frames Before / After (finite, clamped to `0..=MAX_REACH`). +pub fn reach(params: &Params) -> [i64; 2] { + [frames(params, "objectTracking/framesBefore", 0.0, MAX_REACH), frames(params, "objectTracking/framesAfter", 0.0, MAX_REACH)] +} + /// Whether the matte is carried from the reference frame (else every frame is segmented on its /// own). pub fn tracks(params: &Params) -> bool { @@ -170,8 +175,7 @@ pub fn prompt_strokes(params: &Params, layer_size: [f64; 2], frame: i64, with_mo pub fn roto_data(params: &Params, layer_size: [f64; 2], f: i64, with_model: bool) -> Option { if tracks(params) { let base = reference_frame(params); - let before = frames(params, "objectTracking/framesBefore", 0.0, MAX_REACH); - let after = frames(params, "objectTracking/framesAfter", 0.0, MAX_REACH); + let [before, after] = reach(params); let strokes = prompt_strokes(params, layer_size, base, with_model)?; Some(RotoData { base: Some(base), span: [base.saturating_sub(before), base.saturating_add(after)], strokes }) } else { diff --git a/crates/engine/src/commands/roto_cmds.rs b/crates/engine/src/commands/roto_cmds.rs index ff459d0bb..f4f33d53a 100644 --- a/crates/engine/src/commands/roto_cmds.rs +++ b/crates/engine/src/commands/roto_cmds.rs @@ -59,7 +59,7 @@ pub fn specs() -> Vec { "Object Matte", [], None, - "{layer?, point: [x, y] (layer pixels: the object), box?: [x0, y0, x1, y1] (layer pixels, around the object), frame? (reference layer frame; default current), track?: follow the object from that frame (default true), before?, after? (frames to follow it; default 30, up to 600)}", + "{layer?, effect? (uid: re-prompt this Object Matte instead of adding one), point: [x, y] (layer pixels: the object), box?: [x0, y0, x1, y1] (layer pixels, around the object), frame? (reference layer frame; default current), track?: follow the object from that frame (default true), before?, after? (frames to follow it; default 30, up to 600)}", has_comp, object_matte_apply ), @@ -455,6 +455,18 @@ fn object_matte_apply(s: &mut Session, p: &Value) -> Result { if !layer.has_video() || layer.switches.adjustment || layer.effects().is_none() { return Err(bad(cmd, "Object Matte works on footage, solid and composition layers")); } + // Re-prompt an existing instance (the Object Matte tool on a layer that has one). + let existing = match p.get("effect").filter(|v| !v.is_null()) { + Some(v) => { + let uid = v.as_u64().ok_or_else(|| bad(cmd, "`effect` is an Object Matte effect's uid"))?; + layer + .effects() + .and_then(|f| f.groups().find(|g| g.uid == uid && rt::is_object_matte(g))) + .ok_or_else(|| bad(cmd, format!("no Object Matte effect {uid} on this layer")))?; + Some(uid) + } + None => None, + }; let size = rt::layer_size(&s.project, comp, layer); let point = xy_p(p, "point", 2, cmd)?.ok_or_else(|| bad(cmd, "missing `point` [x, y] (layer pixels)"))?; let inside = |x: f64, y: f64| (0.0..=size[0]).contains(&x) && (0.0..=size[1]).contains(&y); @@ -473,42 +485,74 @@ fn object_matte_apply(s: &mut Session, p: &Value) -> Result { if frame < limits[0] || frame > limits[1] { return Err(bad(cmd, format!("frame {frame} is outside the layer ({}–{})", limits[0], limits[1]))); } - let track = b_p(p, "track").unwrap_or(true); - let reach = |k: &str| f_p(p, k).filter(|v| v.is_finite()).unwrap_or(om::DEFAULT_REACH).round().clamp(0.0, om::MAX_REACH); + // A new instance gets the defaults; a re-prompted one keeps what is not given. + let track = b_p(p, "track").or(existing.is_none().then_some(true)); + let reach = |k: &str| { + let v = f_p(p, k).filter(|v| v.is_finite()); + let v = if existing.is_none() { Some(v.unwrap_or(om::DEFAULT_REACH)) } else { v }; + v.map(|v| v.round().clamp(0.0, om::MAX_REACH)) + }; let (before, after) = (reach("before"), reach("after")); let spec = effectcraft_effects::find(om::ID).ok_or_else(|| EngineError::Other("Object Matte effect missing".into()))?; - let uid = s.edit("Object Matte", None, |proj, _| { + let label = if existing.is_some() { "Object Matte Prompt" } else { "Object Matte" }; + let uid = s.edit(label, None, |proj, _| { let mut next = proj.next_id; let l = layer_mut(proj, cid, lid)?; let fxg = l.props.sub_mut("effects").ok_or_else(|| bad(cmd, "this layer has no effects"))?; - let n = fxg.groups().filter(|g| rt::is_object_matte(g)).count(); - let name = if n == 0 { spec.name.to_string() } else { format!("{} {}", spec.name, n + 1) }; - let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), &name, size); + let mut fresh = None; + let g = match existing { + Some(u) => fxg.find_group_mut(u).ok_or_else(|| bad(cmd, "the Object Matte effect is gone"))?, + None => { + let n = fxg.groups().filter(|g| rt::is_object_matte(g)).count(); + let name = if n == 0 { spec.name.to_string() } else { format!("{} {}", spec.name, n + 1) }; + fresh.insert(effectcraft_effects::instantiate(spec, &mut Ids(&mut next), &name, size)) + } + }; let mut set = |path: &str, v: KV| { let (grp, leaf) = path.rsplit_once('/').map_or((None, path), |(a, b)| (Some(a), b)); let target = match grp { Some(sub) => g.sub_mut(sub), - None => Some(&mut g), + None => Some(&mut *g), }; + // The prompt is static: a re-prompt replaces any keyframes. if let Some(pr) = target.and_then(|t| t.get_mut(leaf)) { pr.value = v; + pr.keys.clear(); } }; set("objectPoint", KV::Vec2([point[0], point[1]])); + set("useBox", KV::Bool(bx.is_some())); if let Some(b) = &bx { - set("useBox", KV::Bool(true)); set("boxTopLeft", KV::Vec2([b[0].min(b[2]), b[1].min(b[3])])); set("boxBottomRight", KV::Vec2([b[0].max(b[2]), b[1].max(b[3])])); } - set("objectTracking/trackObject", KV::Bool(track)); + if let Some(t) = track { + set("objectTracking/trackObject", KV::Bool(t)); + } set("objectTracking/referenceFrame", KV::Scalar(frame as f64)); - set("objectTracking/framesBefore", KV::Scalar(before)); - set("objectTracking/framesAfter", KV::Scalar(after)); + if let Some(v) = before { + set("objectTracking/framesBefore", KV::Scalar(v)); + } + if let Some(v) = after { + set("objectTracking/framesAfter", KV::Scalar(v)); + } let u = g.uid; - fxg.children.push(g.into()); + if let Some(g) = fresh { + fxg.children.push(g.into()); + } proj.next_id = proj.next_id.max(next); Ok(u) })?; s.state.selected_layers = vec![lid]; - Ok(json!({"layer": lid.0, "effect": uid, "frame": frame, "track": track, "span": [frame - before as i64, frame + after as i64]})) + let (track, before, after) = object_matte_reach(s, cid, lid, uid); + Ok(json!({"layer": lid.0, "effect": uid, "frame": frame, "track": track, "updated": existing.is_some(), "span": [frame - before, frame + after]})) +} + +/// Track Object and Frames Before / After of an Object Matte instance (static values). +fn object_matte_reach(s: &Session, cid: ItemId, lid: LayerId, uid: Uid) -> (bool, i64, i64) { + use effectcraft_effects::object_matte as om; + let Ok((_, g)) = group(s, cid, lid, uid) else { return (true, 0, 0) }; + let ps = params_static(g); + let [before, after] = om::reach(&ps); + (om::tracks(&ps), before, after) } diff --git a/crates/engine/src/tests_roto.rs b/crates/engine/src/tests_roto.rs index cdf585400..19eee1103 100644 --- a/crates/engine/src/tests_roto.rs +++ b/crates/engine/src/tests_roto.rs @@ -426,3 +426,42 @@ fn object_matte_from_a_click() { let g = s.active_comp().unwrap().layer(clip).unwrap().props.find_group(r["effect"].as_u64().unwrap()).unwrap().clone(); assert_eq!(g.name, "Object Matte 2"); } + +/// `objectMatte.apply` with `effect` re-prompts that instance in one undo step: the new point and +/// box, the box dropped when none is given, the reach kept unless given. +#[test] +fn object_matte_reprompt_updates_the_instance() { + let (mut s, clip, solid) = setup(|f| draw(center(f))); + let cid = s.active_comp_id().unwrap(); + s.edit("remove solid", None, |p, _| { + p.comp_mut(cid).unwrap().layers.retain(|l| l.id != solid); + Ok(()) + }) + .unwrap(); + let c = center(0); + let r = s + .execute_checked( + "objectMatte.apply", + json!({"layer": clip.0, "point": c, "box": [c[0] - 50.0, c[1] - 50.0, c[0] + 50.0, c[1] + 50.0], "after": 6, "frame": 0}), + ) + .unwrap(); + let uid = r["effect"].as_u64().unwrap(); + let count = |s: &Session| s.active_comp().unwrap().layer(clip).unwrap().effects().unwrap().groups().count(); + let ps = |s: &Session| crate::roto::params_static(s.active_comp().unwrap().layer(clip).unwrap().props.find_group(uid).unwrap()); + let undo0 = s.history.undo.len(); + let r = s.execute_checked("objectMatte.apply", json!({"layer": clip.0, "effect": uid, "point": [c[0] + 4.0, c[1]], "frame": 2})).unwrap(); + assert_eq!((r["effect"].as_u64(), r["updated"].as_bool()), (Some(uid), Some(true))); + assert_eq!(r["span"], json!([-28, 8])); + assert_eq!(count(&s), 1); + assert_eq!(s.history.undo.len(), undo0 + 1); + let p = ps(&s); + assert_eq!(p.get("objectPoint"), Some(&effectcraft_keyframe::Value::Vec2([c[0] + 4.0, c[1]]))); + assert_eq!(p.get("useBox"), Some(&effectcraft_keyframe::Value::Bool(false))); + assert_eq!(effectcraft_effects::object_matte::reference_frame(&p), 2); + assert_eq!(effectcraft_effects::object_matte::reach(&p), [30, 6]); + // Undo restores the first prompt. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(ps(&s).get("useBox"), Some(&effectcraft_keyframe::Value::Bool(true))); + // An unknown or non-Object Matte effect is refused. + assert!(s.execute_checked("objectMatte.apply", json!({"layer": clip.0, "effect": 999_999, "point": c})).is_err()); +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 52066f85a..91dd0f852 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -34,6 +34,8 @@ pub enum Icon { Eraser, RotoBrush, RefineEdge, + /// Object Matte: a selection box's corners around a solid object, with a click point. + ObjectMatte, /// Puppet Position Pin: a pushpin. The other pin tools add a badge at the lower right. Puppet, PuppetAdvanced, @@ -314,6 +316,14 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.line(&[(8.0, 14.0), (9.5, 12.5)]); pen.line(&[(11.0, 14.0), (12.5, 12.5)]); } + ObjectMatte => { + // Corner brackets of a box around a filled object, and the click on it. + for (x, y, dx, dy) in [(2.0, 2.0, 1.0, 1.0), (14.0, 2.0, -1.0, 1.0), (14.0, 14.0, -1.0, -1.0), (2.0, 14.0, 1.0, -1.0)] { + pen.line(&[(x, y + 3.0 * dy), (x, y), (x + 3.0 * dx, y)]); + } + pen.dot(8.0, 8.0, 2.2); + pen.circle(8.0, 8.0, 4.4); + } Puppet => pen.pushpin(8.0), PuppetAdvanced => { // Rotation ring with its square scale handle. diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 2f0ca804c..da2e05d0f 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -28,6 +28,7 @@ pub mod learn; pub mod markers_ui; pub mod media_panels; pub mod misc; +pub mod object_matte_tool; pub mod paint_panels; pub mod panel_kit; pub mod path_vr_panels; diff --git a/crates/ui-egui/src/panels/object_matte_tool.rs b/crates/ui-egui/src/panels/object_matte_tool.rs new file mode 100644 index 000000000..70c9df8ea --- /dev/null +++ b/crates/ui-egui/src/panels/object_matte_tool.rs @@ -0,0 +1,128 @@ +//! The Object Matte tool in the Composition viewer (After Effects 26.2's Object Matte, from its +//! public description): click an object to isolate it, or drag a box around it. The layer under +//! the pointer gets an Object Matte effect (`objectMatte.apply`), or its Object Matte is +//! re-prompted (the selected one, else its last); Shift adds another. Every prompt is one undo +//! step, and the effect is selected so its point controls show in the viewer. +//! +//! While hovering, a crosshair and the outline of the layer that would get the matte preview the +//! click: segmenting on every pointer move would be far too slow for a hover preview (a model +//! pass or a graph cut per move), so the matte itself appears once the prompt is set. + +use effectcraft_engine::geom::vec2 as gv2; +use effectcraft_engine::project::Layer; +use effectcraft_engine::render::EvalCtx; +use egui::{Color32, Pos2, Rect, Stroke, StrokeKind}; +use serde_json::json; + +use super::viewer::{ViewerMap, fx2c, layers_at}; +use crate::EffectcraftApp; + +/// Drags shorter than this (screen points) are clicks: a point prompt, no box. +pub const MIN_BOX: f32 = 4.0; + +/// Whether Object Matte can work on `layer` (footage, solid or composition layers with pixels). +fn eligible(layer: &Layer) -> bool { + layer.has_video() && !layer.switches.adjustment && layer.effects().is_some() +} + +/// The layer a prompt at comp point `cpt` goes to: a selected one under it, else the topmost. +pub fn target<'a>(app: &EffectcraftApp, ectx: &EvalCtx<'a>, cpt: [f64; 2]) -> Option<&'a Layer> { + let hits: Vec<&Layer> = layers_at(ectx, cpt).filter(|l| eligible(l)).collect(); + let selected = &app.session.state.selected_layers; + hits.iter().find(|l| selected.contains(&l.id)).or(hits.first()).copied() +} + +/// The layer's effect-space rectangle (its pixels) on screen. +fn outline(ectx: &EvalCtx, map: &ViewerMap, layer: &Layer) -> [Pos2; 4] { + let [w, h] = ectx.effect_bounds(layer).0; + let m = fx2c(ectx, layer); + [[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]].map(|p| { + let c = m.apply(gv2(p[0], p[1])); + map.to_screen([c.x, c.y]) + }) +} + +/// Hover preview at `hp` (screen): the outline of the layer that would get the matte and a +/// crosshair where the object point would go. +pub fn hover(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap, ectx: &EvalCtx, hp: Pos2) { + let accent = app.tokens.accent; + let cpt = map.to_comp(hp); + if let Some(layer) = target(app, ectx, cpt) { + let q = outline(ectx, map, layer); + let mut pts = q.to_vec(); + pts.push(q[0]); + painter.add(egui::Shape::line(pts, Stroke::new(1.5, accent))); + let r = Rect::from_points(&q); + app.auto.add("viewer.objectMatte.target", r, &layer.name); + } + let (gap, arm) = (4.0, 11.0); + for (dx, dy) in [(1.0, 0.0), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0)] { + let d = egui::vec2(dx, dy); + for (w, c) in [(3.0, Color32::from_black_alpha(160)), (1.5, Color32::WHITE)] { + painter.line_segment([hp + d * gap, hp + d * arm], Stroke::new(w, c)); + } + } + painter.circle_stroke(hp, 2.0, Stroke::new(1.5, accent)); + app.auto.add("viewer.objectMatte.crosshair", Rect::from_center_size(hp, egui::vec2(2.0 * arm, 2.0 * arm)), "Object Matte point"); +} + +/// The box being dragged (screen `a`–`b`). +pub fn draw_box(app: &mut EffectcraftApp, painter: &egui::Painter, a: Pos2, b: Pos2) { + let r = Rect::from_two_pos(a, b); + let accent = app.tokens.accent; + painter.rect_stroke(r, 0.0, Stroke::new(2.5, Color32::from_black_alpha(140)), StrokeKind::Middle); + painter.rect_stroke(r, 0.0, Stroke::new(1.5, accent), StrokeKind::Middle); + painter.circle_filled(r.center(), 2.5, accent); + app.auto.add("viewer.objectMatte.box", r, "Object Matte box"); +} + +/// Set the prompt from a click at `a` (screen), or a box dragged from `a` to `b`: on the +/// target layer's Object Matte (re-prompted), or a new one with `add` or when it has none. +pub fn apply(app: &mut EffectcraftApp, ectx: &EvalCtx, map: &ViewerMap, a: Pos2, b: Option, add: bool) { + let boxed = b.filter(|b| (b.x - a.x).abs() >= MIN_BOX && (b.y - a.y).abs() >= MIN_BOX); + let center = boxed.map_or(a, |b| Rect::from_two_pos(a, b).center()); + let Some(layer) = target(app, ectx, map.to_comp(center)) else { + app.ui.status = "Object Matte: click an object on a footage, solid or composition layer".into(); + return; + }; + let Some(inv) = fx2c(ectx, layer).inverse() else { return }; + let to_layer = |p: Pos2| { + let c = map.to_comp(p); + let l = inv.apply(gv2(c[0], c[1])); + [l.x, l.y] + }; + let point = to_layer(center); + let mut q = json!({"layer": layer.id.0, "point": point}); + if let Some(b) = boxed { + // The box in layer pixels: the bounds of its corners (the layer may be turned). + let r = Rect::from_two_pos(a, b); + let c = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()].map(to_layer); + let (x0, x1) = c.iter().map(|p| p[0]).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(v), hi.max(v))); + let (y0, y1) = c.iter().map(|p| p[1]).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(v), hi.max(v))); + q["box"] = json!([x0, y0, x1, y1]); + } + if !add && let Some(uid) = existing(app, layer) { + q["effect"] = json!(uid); + } + let lid = layer.id; + match app.session.execute("objectMatte.apply", q) { + Ok(r) => { + if let Some(uid) = r["effect"].as_u64() { + let _ = app.session.execute("prop.select", json!({"layer": lid.0, "prop": uid})); + } + } + Err(e) => app.ui.status = e.to_string(), + } +} + +/// The Object Matte a prompt on `layer` re-prompts: the selected one, else the layer's last. +fn existing(app: &EffectcraftApp, layer: &Layer) -> Option { + let fx = layer.effects()?; + let om: Vec = fx.groups().filter(|g| is_object_matte(g)).map(|g| g.uid).collect(); + let sel = app.session.state.selected_props.iter().rev().find(|(l, u)| *l == layer.id && om.contains(u)).map(|(_, u)| *u); + sel.or(om.last().copied()) +} + +fn is_object_matte(g: &effectcraft_engine::project::PropGroup) -> bool { + matches!(&g.kind, effectcraft_engine::project::GroupKind::Effect { effect } if effect == effectcraft_engine::effects::object_matte::ID) +} diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 5fe7697c9..4ea3b8c33 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -164,6 +164,10 @@ enum Gesture { PinMarquee { start: Pos2, }, + /// Object Matte tool: a box around the object (screen point where the drag began). + ObjectMatteBox { + start: Pos2, + }, /// Puppet tool Record mode (⌘/Ctrl-drag a pin): the drag is sampled in real time (seconds /// since it began, effect space) and becomes keyframes on release (`puppet.recordPin`); the /// current time runs along while recording. @@ -1164,6 +1168,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } t if t.is_shape() => egui::CursorIcon::Crosshair, _ if app.ui.viewer.roi_draw => egui::CursorIcon::Crosshair, + Tool::ObjectMatte => { + // The tool draws its own crosshair (and the layer it would matte) while no box + // is being dragged. + if ui.data(|d| d.get_temp::(egui::Id::new("viewer-gesture"))).is_none() { + super::object_matte_tool::hover(app, &painter, &map, &ectx, hp); + } + egui::CursorIcon::None + } t if t.puppet_kind().is_some() => match super::puppet_tool::hit(&pin_hits, hp) { Some((_, super::puppet_tool::PinPart::Ring)) => egui::CursorIcon::Alias, Some((_, super::puppet_tool::PinPart::Scale)) => egui::CursorIcon::ResizeNwSe, @@ -1293,6 +1305,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // From where the button went down: by the time the pointer has moved far enough to // count as a drag it has already left the press point, and the shape is drawn there. t if t.is_shape() => Some(Gesture::Create { tool: t, start: map.to_comp(press) }), + Tool::ObjectMatte => Some(Gesture::ObjectMatteBox { start: press }), t if t.puppet_kind().is_some() && super::puppet_tool::hit(&pin_hits, press).is_none() => { // Off the pins: Alt or a press outside the art drags a marquee that selects pins. (mods.alt || puppet_layer(app, cpt).is_none()).then_some(Gesture::PinMarquee { start: press }) @@ -1609,6 +1622,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { q["merge"] = json!(merge); let _ = app.session.execute("track.setPoint", q); } + Gesture::ObjectMatteBox { start } => super::object_matte_tool::draw_box(app, &painter, start, pos), Gesture::Marquee { start } | Gesture::PinMarquee { start } => { let r = Rect::from_two_pos(start, pos); painter.rect_filled(r, 0.0, t.accent.gamma_multiply(0.12)); @@ -1649,6 +1663,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } Gesture::PuppetPin { layer, pin, .. } => super::puppet_tool::reveal_pin(app, layer, pin), + Gesture::ObjectMatteBox { start } => super::object_matte_tool::apply(app, &ectx, &map, start, Some(pos), mods.shift), Gesture::Create { tool, start } => { let end = map.to_comp(pos); create_shape(app, tool, start, end, mods); @@ -1752,6 +1767,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Clicking away from the free-transform box ends it. ov::end_free_transform(&ctx); } + Tool::ObjectMatte => super::object_matte_tool::apply(app, &ectx, &map, pos, None, mods.shift), t if t.puppet_kind().is_some() => { if let Some((h, _)) = super::puppet_tool::hit(&pin_hits, pos) { // Click selects the pin; Shift-click adds or removes it. @@ -2075,7 +2091,7 @@ pub(crate) fn selectable_layers(comp: &Comp, time: Tick) -> impl Iterator(ectx: &'b EvalCtx<'a>, cpt: [f64; 2]) -> impl Iterator + 'b { +pub(crate) fn layers_at<'a, 'b>(ectx: &'b EvalCtx<'a>, cpt: [f64; 2]) -> impl Iterator + 'b { selectable_layers(ectx.comp, ectx.time).filter(move |l| layer_quad(ectx, l).is_some_and(|(_, q, _)| point_in_quad(cpt, &q))) } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 0cce2c113..7c49c786f 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -35,6 +35,8 @@ pub enum Tool { Eraser, RotoBrush, RefineEdge, + /// Object Matte: click an object (or drag a box around it) in the Composition viewer. + ObjectMatte, Puppet, PuppetStarch, PuppetBend, @@ -59,10 +61,10 @@ impl Tool { &[Tool::Brush], &[Tool::Clone], &[Tool::Eraser], - &[Tool::RotoBrush, Tool::RefineEdge], + &[Tool::RotoBrush, Tool::RefineEdge, Tool::ObjectMatte], &[Tool::Puppet, Tool::PuppetStarch, Tool::PuppetBend, Tool::PuppetAdvanced, Tool::PuppetOverlap], ]; - pub const ALL: [Tool; 30] = [ + pub const ALL: [Tool; 31] = [ Tool::Selection, Tool::Hand, Tool::Zoom, @@ -88,6 +90,7 @@ impl Tool { Tool::Eraser, Tool::RotoBrush, Tool::RefineEdge, + Tool::ObjectMatte, Tool::Puppet, Tool::PuppetStarch, Tool::PuppetBend, @@ -121,6 +124,7 @@ impl Tool { Tool::Eraser => "Eraser Tool", Tool::RotoBrush => "Roto Brush Tool", Tool::RefineEdge => "Refine Edge Tool", + Tool::ObjectMatte => "Object Matte Tool", Tool::Puppet => "Puppet Position Pin Tool", Tool::PuppetStarch => "Puppet Starch Pin Tool", Tool::PuppetBend => "Puppet Bend Pin Tool", @@ -142,7 +146,7 @@ impl Tool { Tool::Pen | Tool::PenAdd | Tool::PenDelete | Tool::PenConvert | Tool::MaskFeather => Some("G"), Tool::Type | Tool::TypeVertical => Some("Cmd+T"), Tool::Brush | Tool::Clone | Tool::Eraser => Some("Cmd+B"), - Tool::RotoBrush | Tool::RefineEdge => Some("Alt+W"), + Tool::RotoBrush | Tool::RefineEdge | Tool::ObjectMatte => Some("Alt+W"), Tool::Puppet | Tool::PuppetStarch | Tool::PuppetBend | Tool::PuppetAdvanced | Tool::PuppetOverlap => Some("Cmd+P"), } } @@ -173,6 +177,7 @@ impl Tool { Tool::Eraser => Icon::Eraser, Tool::RotoBrush => Icon::RotoBrush, Tool::RefineEdge => Icon::RefineEdge, + Tool::ObjectMatte => Icon::ObjectMatte, Tool::Puppet => Icon::Puppet, Tool::PuppetStarch => Icon::PuppetStarch, Tool::PuppetBend => Icon::PuppetBend, diff --git a/crates/ui-egui/tests/ui_object_matte.rs b/crates/ui-egui/tests/ui_object_matte.rs new file mode 100644 index 000000000..483d271ea --- /dev/null +++ b/crates/ui-egui/tests/ui_object_matte.rs @@ -0,0 +1,142 @@ +//! Headless checks for the Object Matte tool in the Composition viewer: the hover preview, a +//! click (point prompt), a drag (box prompt) re-prompting the same effect, Shift adding another, +//! one undo step each (egui_kittest, UI logic only). + +use effectcraft_engine::Session; +use effectcraft_engine::effects::flatten_params; +use effectcraft_engine::effects::object_matte as om; +use effectcraft_engine::keyframe::Value; +use effectcraft_engine::project::{GroupKind, LayerId, PropGroup}; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::state::Tool; +use egui::{Event, Modifiers, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +fn harness() -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Matte", "width": 640, "height": 360, "duration": 4})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080", "width": 640, "height": 360})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Box", "color": "#e04020", "width": 80, "height": 80})).unwrap(); + s.execute("edit.deselectAll", json!({})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s)); + h.run_steps(3); + h +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +/// Comp pixel → screen point. +fn screen(h: &Harness<'_, EffectcraftApp>, p: [f32; 2]) -> Pos2 { + let c = rect(h, "viewer.comp"); + let z = c.width() / 640.0; + c.min + vec2(p[0] * z, p[1] * z) +} + +fn press(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, pressed: bool, modifiers: Modifiers) { + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers }); + h.step(); +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, modifiers: Modifiers) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + press(h, p, true, modifiers); + press(h, p, false, modifiers); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); +} + +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) -> Option { + h.input_mut().events.push(Event::PointerMoved(from)); + press(h, from, true, Modifiers::NONE); + let mut drawn = None; + for i in 1..=8 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0))); + h.step(); + drawn = h.state().auto.find("viewer.objectMatte.box").map(|e| Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))); + } + press(h, to, false, Modifiers::NONE); + h.run_steps(2); + drawn +} + +fn box_layer(h: &Harness<'_, EffectcraftApp>) -> LayerId { + h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id +} + +/// The Box layer's Object Matte effects. +fn mattes(h: &Harness<'_, EffectcraftApp>) -> Vec { + let comp = h.state().session.active_comp().unwrap(); + let l = comp.layer(box_layer(h)).unwrap(); + l.effects().unwrap().groups().filter(|g| matches!(&g.kind, GroupKind::Effect { effect } if effect == om::ID)).cloned().collect() +} + +fn v2(g: &PropGroup, id: &str) -> [f64; 2] { + flatten_params(g, &mut |p| p.value.clone()).get(id).map(Value::as_vec2).unwrap() +} + +fn near(a: [f64; 2], b: [f64; 2]) -> bool { + (a[0] - b[0]).abs() < 1.5 && (a[1] - b[1]).abs() < 1.5 +} + +#[test] +fn object_matte_tool_prompts_with_a_click_and_a_box() { + let mut h = harness(); + assert_eq!(Tool::from_name("objectMatte"), Some(Tool::ObjectMatte)); + assert!(Tool::SLOTS.iter().any(|s| s.contains(&Tool::RotoBrush) && s.contains(&Tool::ObjectMatte))); + h.state_mut().ui.tool = Tool::ObjectMatte; + h.run_steps(2); + // Hover: a crosshair at the pointer and the outline of the layer that would get the matte. + let center = screen(&h, [320.0, 180.0]); + h.input_mut().events.push(Event::PointerMoved(center)); + h.run_steps(2); + assert!(rect(&h, "viewer.objectMatte.crosshair").contains(center)); + let target = h.state().auto.find("viewer.objectMatte.target").expect("no target outline"); + assert_eq!(target.label, "Box"); + assert!(mattes(&h).is_empty(), "hovering must not apply anything"); + let undo0 = h.state().session.history.undo.len(); + + // Click: a point prompt in layer pixels on the layer under the pointer, selected. + click(&mut h, center, Modifiers::NONE); + let m = mattes(&h); + assert_eq!(m.len(), 1); + assert!(near(v2(&m[0], "objectPoint"), [40.0, 40.0]), "{:?}", v2(&m[0], "objectPoint")); + let uid = m[0].uid; + let st = &h.state().session.state; + assert!(st.selected_props.contains(&(box_layer(&h), uid)), "the new effect is selected"); + assert_eq!(h.state().session.history.undo.len(), undo0 + 1); + + // Drag: a box (drawn while dragging) re-prompts the same effect; its centre is the point. + // (It starts clear of the selected effect's point controls, which take drags of their own.) + let (a, b) = (screen(&h, [286.0, 150.0]), screen(&h, [356.0, 214.0])); + let drawn = drag(&mut h, a, b).expect("no box drawn while dragging"); + assert!(drawn.width() > 20.0); + let m = mattes(&h); + assert_eq!(m.len(), 1); + assert_eq!(m[0].uid, uid); + let ps = flatten_params(&m[0], &mut |p| p.value.clone()); + assert_eq!(ps.get("useBox"), Some(&Value::Bool(true))); + assert!(near(v2(&m[0], "boxTopLeft"), [6.0, 10.0]), "{:?}", v2(&m[0], "boxTopLeft")); + assert!(near(v2(&m[0], "boxBottomRight"), [76.0, 74.0]), "{:?}", v2(&m[0], "boxBottomRight")); + assert!(near(v2(&m[0], "objectPoint"), [41.0, 42.0]), "{:?}", v2(&m[0], "objectPoint")); + assert_eq!(h.state().session.history.undo.len(), undo0 + 2); + + // Shift-click adds a second Object Matte. + let p = screen(&h, [310.0, 170.0]); + click(&mut h, p, Modifiers::SHIFT); + let m = mattes(&h); + assert_eq!(m.len(), 2); + assert_eq!(m[1].name, "Object Matte 2"); + assert!(near(v2(&m[1], "objectPoint"), [30.0, 30.0])); + + // Undo takes the prompts back one at a time. + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(mattes(&h).len(), 1); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(flatten_params(&mattes(&h)[0], &mut |p| p.value.clone()).get("useBox"), Some(&Value::Bool(false))); +} From 560767dfe79e2cea59da0814d950db37c4b8e420 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 14:02:50 +0200 Subject: [PATCH 005/181] #783: Object Matte tracks in the background and freezes Problem: Object Matte computed its frames only on demand while rendering, so playing a shot stalled on every untracked frame, there was no progress or way to stop, and nothing kept the mattes once computed. Fix: Object Matte runs through Roto Brush's job machinery (one job slot, cancellable with roto.cancel, a worker thread on the desktop, a Web Worker in the browser, never the UI thread). objectMatte.propagate computes the frames around the reference frame (clipped to the layer) outward, filling the same content-keyed segmentation cache the renderer reads; the engine's sync now queues an instance for background tracking when its frames or prompt change. objectMatte.freeze stores the final mattes in a new hidden Frozen parameter (Roto Brush's FrozenCache format) under a key of the input, prompt, span, model and matte settings; renders use them without segmenting while the key matches; a frozen prompt is refused until objectMatte.unfreeze. Freeze is one undo step and is dropped if the prompt changed meanwhile. objectMatte.status reports progress, frozen state and computed frames. Effect Controls shows Freeze / Unfreeze and Track / Stop with a status line (progress banner). The frontend now polls Roto Brush / Object Matte jobs on their own: before, a finished job (and so a Freeze result) was only picked up while a Warp Stabilizer job was active. Job progress is set before the worker starts. Tests: engine tests_roto::object_matte_background_job_cancel_and_freeze (queued by the edit, returns at once with progress, cancel keeps a partial result, completion computes all 25 frames, frozen renders recompute nothing, unfreeze recomputes, undo restores); effects object_matte job_frames and frozen-matte tests (stored pattern used, key mismatch and damaged data ignored); ui-egui ui_object_matte Effect Controls Freeze end to end. docs/agents.md: Object Matte section; docs/effects.md regenerated. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/object_matte.rs | 193 ++++++++++++++++++- crates/engine/src/commands/roto_cmds.rs | 104 +++++++++- crates/engine/src/offload.rs | 3 +- crates/engine/src/roto.rs | 177 +++++++++++++---- crates/engine/src/tests_roto.rs | 81 ++++++++ crates/ui-egui/src/i18n/ui.rs | 4 + crates/ui-egui/src/lib.rs | 6 + crates/ui-egui/src/panels/effect_controls.rs | 41 +++- crates/ui-egui/tests/ui_object_matte.rs | 43 +++++ docs/agents.md | 18 ++ docs/effects.md | 2 +- 11 files changed, 618 insertions(+), 54 deletions(-) diff --git a/crates/effects/src/object_matte.rs b/crates/effects/src/object_matte.rs index a7db562a9..32a196af2 100644 --- a/crates/effects/src/object_matte.rs +++ b/crates/effects/src/object_matte.rs @@ -16,8 +16,12 @@ //! ([`crate::roto::cached`]): the chain seed holds the engine-maintained Input Key (what the //! frames are made of), the layer size, the frame rate, the mode and the model, and every //! frame's key the prompt, so edits upstream or to the prompt recompute it and undo never sees a -//! stale matte. **Feather**, **Contrast** and **Shift Edge** shape the matte, **Invert** keeps -//! everything but the object and **View ▸ Matte** shows the matte in grey. +//! stale matte. The engine fills that cache in a background job (`objectMatte.propagate`, +//! outward from the reference frame over [`job_frames`]) and **Freeze** (`objectMatte.freeze`) +//! stores the final mattes in the hidden **Frozen** parameter ([`FrozenCache`], as Roto Brush +//! does): they are used, without segmenting, while [`frozen_key`] still matches. **Feather**, +//! **Contrast** and **Shift Edge** shape the matte, **Invert** keeps everything but the object +//! and **View ▸ Matte** shows the matte in grey. use std::sync::Arc; @@ -26,10 +30,10 @@ use effectcraft_project::ParamUi; use effectcraft_raster::Image; use effectcraft_segment::MaskModel; use effectcraft_track::roto::refine::MatteParams; -use effectcraft_track::roto::{RotoData, SegOpts, Stroke, StrokeKind}; +use effectcraft_track::roto::{FrameSeg, RotoData, SegOpts, Stroke, StrokeKind, rle}; use rayon::prelude::*; -use crate::roto::{self, Chain, INPUT_KEY}; +use crate::roto::{self, Chain, FROZEN, FrozenCache, INPUT_KEY}; use crate::warp_stab::param; use crate::{Buf, EffectCtx, EffectSpec, Params, num, p, popup, slider}; @@ -71,6 +75,7 @@ pub fn specs() -> Vec { p("invert", "Invert", Value::Bool(false), ParamUi::Checkbox), p("view", "View", Value::Enum(0), popup(&VIEWS)), p(INPUT_KEY, "Input Key", Value::Str(String::new()), ParamUi::Hidden), + p(FROZEN, "Frozen", Value::Str(String::new()), ParamUi::Hidden), ], render, gpu: false, @@ -197,6 +202,127 @@ pub fn seed(params: &Params, layer_size: [f64; 2], fps: f64, model: Option<&dyn h } +/// The layer frames a background job computes, outward from the reference frame: the tracked +/// span (Frames Before / After around it), clipped to `limits` (the layer's frames). Without +/// Track Object, the same frames, each segmented on its own. +pub fn job_frames(params: &Params, limits: [i64; 2]) -> Vec { + let base = reference_frame(params); + let [before, after] = reach(params); + let (lo, hi) = (base.saturating_sub(before).max(limits[0]), base.saturating_add(after).min(limits[1])); + if base < limits[0] || base > limits[1] { + return vec![]; + } + let mut v = vec![base]; + v.extend(base.saturating_add(1)..=hi); + v.extend((lo..base).rev()); + v +} + +/// The segmentation chain the matte of layer frame `f` comes from (`None`: no usable prompt). +pub fn chain(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, scale: f64, model: Option>) -> Option { + let data = roto_data(params, layer_size, f, model.is_some())?; + let keys = data.chain_keys(seed(params, layer_size, fps, model.as_deref())); + Some(Chain { data: Arc::new(data), keys, opts: seg_opts(scale), refine_radius: 0.0, model }) +} + +/// Frame `f`'s segmentation at full resolution, computing what is missing (the background +/// job's step). `None` outside the tracked span, without a prompt, or when cancelled. +pub fn ensure( + params: &Params, + layer_size: [f64; 2], + fps: f64, + f: i64, + model: Option>, + frame: &mut dyn FnMut(i64) -> Option>, + cancel: &dyn Fn() -> bool, +) -> Option> { + let ch = chain(params, layer_size, fps, f, 1.0, model)?; + if !ch.data.in_span(f) { + return None; + } + ch.ensure(f, frame, cancel) +} + +/// The chain key of each of `frames` (what a computed segmentation is cached under). +pub fn frame_keys(params: &Params, layer_size: [f64; 2], fps: f64, frames: &[i64], model: Option>) -> Vec<(i64, u64)> { + if tracks(params) { + let Some(ch) = chain(params, layer_size, fps, 0, 1.0, model) else { return vec![] }; + return frames.iter().filter_map(|f| Some((*f, *ch.keys.get(f)?))).collect(); + } + frames.iter().filter_map(|f| Some((*f, *chain(params, layer_size, fps, *f, 1.0, model.clone())?.keys.get(f)?))).collect() +} + +/// What frozen mattes depend on: what the frames are (the seed: Input Key, size, rate, mode, +/// model), the prompt, the tracked span and the matte settings. +pub fn frozen_key(params: &Params, layer_size: [f64; 2], fps: f64, model: Option<&dyn MaskModel>) -> String { + let mut h = seed(params, layer_size, fps, model); + let base = reference_frame(params); + if let Some(st) = prompt_strokes(params, layer_size, base, model.is_some()) { + fnv(&mut h, serde_json::to_string(&st).unwrap_or_default().as_bytes()); + } + for v in [base, reach(params)[0], reach(params)[1]] { + fnv(&mut h, &v.to_le_bytes()); + } + fnv(&mut h, format!("{:?}", matte_params(params)).as_bytes()); + format!("{h:016x}") +} + +/// Whether the instance is frozen (has frozen mattes). +pub fn is_frozen(params: &Params) -> bool { + !s(params, FROZEN).is_empty() +} + +/// Frame `f`'s frozen matte on a `w`×`h` grid, when the instance is frozen, the mattes still +/// match ([`frozen_key`]) and `f` is one of them. +pub fn frozen_alpha(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, w: usize, h: usize, model: Option<&dyn MaskModel>) -> Option> { + let j = s(params, FROZEN); + if j.is_empty() { + return None; + } + let fc = frozen_cache(j)?; + if fc.key != frozen_key(params, layer_size, fps, model) { + return None; + } + let fr = fc.frames.get(&f)?; + let a = rle::decode(&fr.a)?; + let [fw, fh] = fc.size; + if fw == 0 || fh == 0 || a.len() != fw.checked_mul(fh)? { + return None; + } + let af: Vec = a.iter().map(|v| *v as f32 / 255.0).collect(); + if fw == w && fh == h { + return Some(af); + } + let mut out = vec![0.0f32; w * h]; + for y in 0..h { + for x in 0..w { + let (sx, sy) = ((x as f64 + 0.5) * fw as f64 / w as f64, (y as f64 + 0.5) * fh as f64 / h as f64); + out[y * w + x] = effectcraft_track::roto::matting::sample(&af, fw, fh, sx, sy); + } + } + Some(out) +} + +/// The parsed Frozen parameter (parsed once per content). +fn frozen_cache(j: &str) -> Option> { + use std::collections::HashMap; + use std::sync::{Mutex, OnceLock}; + static C: OnceLock>>> = OnceLock::new(); + let mut h = 0xcbf2_9ce4_8422_2325; + fnv(&mut h, j.as_bytes()); + let c = C.get_or_init(Default::default); + if let Some(d) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&h) { + return Some(d.clone()); + } + let d = Arc::new(FrozenCache::from_json(j)?); + let mut m = c.lock().unwrap_or_else(|e| e.into_inner()); + if m.len() > 8 { + m.clear(); + } + m.insert(h, d.clone()); + Some(d) +} + /// Segmentation settings at `scale` (Roto Brush's defaults). fn seg_opts(scale: f64) -> SegOpts { SegOpts { scale, search_radius: 15.0, motion_threshold: 10.0, motion_damping: 20.0, best: false } @@ -242,12 +368,10 @@ pub fn frame_matte( frame: &mut dyn FnMut(i64) -> Option>, cancel: &dyn Fn() -> bool, ) -> FrameMatte { - let Some(data) = roto_data(params, layer_size, f, model.is_some()) else { return FrameMatte::NoPrompt }; - if !data.in_span(f) { + let Some(chain) = chain(params, layer_size, fps, f, scale, model) else { return FrameMatte::NoPrompt }; + if !chain.data.in_span(f) { return FrameMatte::Outside; } - let keys = data.chain_keys(seed(params, layer_size, fps, model.as_deref())); - let chain = Chain { data: Arc::new(data), keys, opts: seg_opts(scale), refine_radius: 0.0, model }; let Some(seg) = chain.ensure(f, frame, cancel) else { return FrameMatte::Outside }; let (w, h) = (img.width as usize, img.height as usize); let seg = if seg.w == w && seg.h == h { (*seg).clone() } else { roto::resample(&seg, w, h) }; @@ -275,7 +399,12 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf { let im = roto::layer_grid(&other, layer_size); (im.width as usize == w && im.height as usize == h).then(|| Arc::new(im)) }; - let alpha = match frame_matte(ctx.params, layer_size, fps, f, sc, &img, roto::installed_model(), &mut frame, &|| false) { + let model = roto::installed_model(); + // Frozen mattes are used as they are (no segmentation). + if let Some(a) = frozen_alpha(ctx.params, layer_size, fps, f, w, h, model.as_deref()) { + return compose(&buf, Some(&a), w, h, b(ctx.params, "invert"), param(ctx.params, "view").map(Value::as_enum).unwrap_or(0) == 1); + } + let alpha = match frame_matte(ctx.params, layer_size, fps, f, sc, &img, model, &mut frame, &|| false) { FrameMatte::Alpha(a) if a.len() == w * h => Some(a), FrameMatte::NoPrompt => return buf, _ => None, @@ -513,4 +642,50 @@ mod tests { let _ = frame_matte(&p, [100.0, 80.0], 25.0, 0, 0.5, &half, Some(stub.clone()), &mut frame, &|| false); assert_eq!(*stub.bbox.lock().unwrap(), Some([37.0, 27.0, 50.0, 40.0])); } + + #[test] + fn job_frames_go_outward_from_the_reference_frame_inside_the_layer() { + let p = params(&[ + ("objectTracking/referenceFrame", Value::Scalar(5.0)), + ("objectTracking/framesBefore", Value::Scalar(2.0)), + ("objectTracking/framesAfter", Value::Scalar(3.0)), + ]); + assert_eq!(job_frames(&p, [0, 100]), [5, 6, 7, 8, 4, 3]); + assert_eq!(job_frames(&p, [4, 6]), [5, 6, 4]); + assert!(job_frames(&p, [10, 20]).is_empty()); + let huge = params(&[("objectTracking/referenceFrame", Value::Scalar(1e300)), ("objectTracking/framesAfter", Value::Scalar(f64::INFINITY))]); + assert!(job_frames(&huge, [0, 100]).is_empty()); + } + + /// Frozen mattes are used as stored while their key matches (no segmentation: a stored + /// pattern the segmentation could never produce comes out), and ignored once it doesn't. + #[test] + fn frozen_mattes_are_used_while_their_key_matches() { + let mut p = params(&[("objectPoint", Value::Vec2([40.0, 40.0])), ("objectTracking/framesAfter", Value::Scalar(3.0))]); + p.values.insert(INPUT_KEY.into(), Value::Str("frozen-test".into())); + // Left half opaque on frame 2. + let alpha: Vec = (0..100 * 80).map(|i| ((i % 100) < 50) as u8 as f32).collect(); + let mut fc = FrozenCache { key: frozen_key(&p, [100.0, 80.0], 25.0, None), size: [100, 80], ..Default::default() }; + fc.frames.insert(2, roto::freeze_frame(&alpha, &[])); + p.values.insert(FROZEN.into(), Value::Str(fc.to_json())); + assert!(is_frozen(&p)); + let out = run(&p, 2); + assert!(out.data[40 * 100 + 10][3] > 0.99 && out.data[40 * 100 + 90][3] < 0.01, "the frozen matte is used"); + // At half resolution too. + assert_eq!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 50, 40, None).map(|a| (a[20 * 50 + 5], a[20 * 50 + 45])), Some((1.0, 0.0))); + // A frame without a frozen matte is computed (the disk). + assert!(wrong(&run(&p, 1), 42.0) < 30); + // Another prompt: the frozen mattes no longer apply. + p.values.insert("objectPoint".into(), Value::Vec2([41.0, 40.0])); + assert_eq!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 100, 80, None), None); + assert!(wrong(&run(&p, 2), 44.0) < 30); + // Damaged frozen data is ignored, not a crash. + p.values.insert(FROZEN.into(), Value::Str("{\"key\": 3, \"frames\": [".into())); + assert_eq!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 100, 80, None), None); + let mut fc2 = fc.clone(); + fc2.size = [usize::MAX, 2]; + p.values.insert("objectPoint".into(), Value::Vec2([40.0, 40.0])); + p.values.insert(FROZEN.into(), Value::Str(fc2.to_json())); + assert_eq!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 100, 80, None), None); + } } diff --git a/crates/engine/src/commands/roto_cmds.rs b/crates/engine/src/commands/roto_cmds.rs index f4f33d53a..ee9cd3a28 100644 --- a/crates/engine/src/commands/roto_cmds.rs +++ b/crates/engine/src/commands/roto_cmds.rs @@ -63,6 +63,23 @@ pub fn specs() -> Vec { has_comp, object_matte_apply ), + cmd!( + "objectMatte.propagate", + "Track Object Matte", + [], + None, + "{layer?, effect?, direction?: forward|backward|both, wait?: block until done} (a background job like Roto Brush propagation; roto.cancel stops it)", + has_comp, + object_matte_propagate + ), + cmd!("objectMatte.freeze", "Freeze Object Matte", [], None, "{layer?, effect?, wait?}", has_comp, object_matte_freeze), + cmd!("objectMatte.unfreeze", "Unfreeze Object Matte", [], None, "{layer?, effect?}", has_comp, object_matte_unfreeze), + query!( + "objectMatte.status", + "Object Matte Status", + "{layer?, effect?}: the job's progress, whether the instance is frozen, its frames and which are computed", + object_matte_status + ), query!( "roto.status", "Roto Brush Status", @@ -79,6 +96,16 @@ fn is_running(s: &Session) -> std::result::Result<(), String> { /// The Roto Brush instance targeted by `p`: `layer` / `effect` (uid, name or 1-based index), /// else a selected one, else the first on the selected (or any) layer. pub(crate) fn roto_p(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> { + instance_lookup(s, p, cmd, is_roto)?.ok_or_else(|| bad(cmd, "no Roto Brush & Refine Edge effect found (paint a stroke with the Roto Brush tool first)")) +} + +/// The Object Matte instance targeted by `p` (as [`roto_p`]). +fn object_matte_p(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> { + instance_lookup(s, p, cmd, rt::is_object_matte)?.ok_or_else(|| bad(cmd, "no Object Matte effect found (click an object with the Object Matte tool first)")) +} + +/// The instance of a kind (`is`) targeted by `p`; `None` when there is none. +fn instance_lookup(s: &Session, p: &Value, cmd: &str, is: fn(&PropGroup) -> bool) -> Result> { let cid = super::comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let candidates: Vec = match p.get("layer") { @@ -102,17 +129,17 @@ pub(crate) fn roto_p(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, Layer .state .selected_props .iter() - .find_map(|(l, u)| (*l == lid).then(|| fxg.groups().find(|g| g.uid == *u && is_roto(g))).flatten()) - .or_else(|| fxg.groups().find(|g| is_roto(g))), + .find_map(|(l, u)| (*l == lid).then(|| fxg.groups().find(|g| g.uid == *u && is(g))).flatten()) + .or_else(|| fxg.groups().find(|g| is(g))), }; - if let Some(g) = g.filter(|g| is_roto(g)) { - return Ok((cid, lid, g.uid)); + if let Some(g) = g.filter(|g| is(g)) { + return Ok(Some((cid, lid, g.uid))); } if p.get("layer").is_some() { break; } } - Err(bad(cmd, "no Roto Brush & Refine Edge effect found (paint a stroke with the Roto Brush tool first)")) + Ok(None) } fn group(s: &Session, cid: ItemId, lid: LayerId, uid: Uid) -> Result<(&Layer, &PropGroup)> { @@ -459,10 +486,13 @@ fn object_matte_apply(s: &mut Session, p: &Value) -> Result { let existing = match p.get("effect").filter(|v| !v.is_null()) { Some(v) => { let uid = v.as_u64().ok_or_else(|| bad(cmd, "`effect` is an Object Matte effect's uid"))?; - layer + let g = layer .effects() .and_then(|f| f.groups().find(|g| g.uid == uid && rt::is_object_matte(g))) .ok_or_else(|| bad(cmd, format!("no Object Matte effect {uid} on this layer")))?; + if frozen_of(g) { + return Err(bad(cmd, "the Object Matte is frozen: Unfreeze it to change the prompt")); + } Some(uid) } None => None, @@ -556,3 +586,65 @@ fn object_matte_reach(s: &Session, cid: ItemId, lid: LayerId, uid: Uid) -> (bool let [before, after] = om::reach(&ps); (om::tracks(&ps), before, after) } + +fn object_matte_propagate(s: &mut Session, p: &Value) -> Result { + let cmd = "objectMatte.propagate"; + let (cid, lid, uid) = object_matte_p(s, p, cmd)?; + let dir = match str_p(p, "direction") { + Some(d) => Direction::from_name(d).ok_or_else(|| bad(cmd, format!("unknown direction `{d}` (forward | backward | both)")))?, + None => Direction::Both, + }; + let wait = b_p(p, "wait").unwrap_or(false); + let n = s.start_roto(cid, lid, uid, RotoTask::Propagate, dir, wait).map_err(|e| bad(cmd, e))?; + let mut out = json!({"layer": lid.0, "effect": uid, "frames": n, "running": s.is_roto_running(), "progress": s.roto_progress()}); + if wait { + out["status"] = object_matte_status(s, &json!({"layer": lid.0, "effect": uid, "comp": cid.0}))?; + } + Ok(out) +} + +fn object_matte_freeze(s: &mut Session, p: &Value) -> Result { + let cmd = "objectMatte.freeze"; + let (cid, lid, uid) = object_matte_p(s, p, cmd)?; + let wait = b_p(p, "wait").unwrap_or(false); + let n = s.start_roto(cid, lid, uid, RotoTask::Freeze, Direction::Both, wait).map_err(|e| bad(cmd, e))?; + let (_, g) = group(s, cid, lid, uid)?; + Ok(json!({"layer": lid.0, "effect": uid, "frames": n, "running": s.is_roto_running(), "frozen": frozen_of(g), "progress": s.roto_progress()})) +} + +fn object_matte_unfreeze(s: &mut Session, p: &Value) -> Result { + let cmd = "objectMatte.unfreeze"; + let (cid, lid, uid) = object_matte_p(s, p, cmd)?; + let (_, g) = group(s, cid, lid, uid)?; + if frozen_of(g) { + s.edit("Unfreeze", None, |proj, _| { + let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| EngineError::Other("the effect is gone".into()))?; + if let Some(pr) = g.get_mut(FROZEN) { + pr.value = KV::Str(String::new()); + } + Ok(()) + })?; + } + Ok(json!({"layer": lid.0, "effect": uid, "frozen": false})) +} + +fn object_matte_status(s: &mut Session, p: &Value) -> Result { + s.poll_roto(false); + let running = s.is_roto_running(); + let mut out = json!({"running": running, "progress": s.roto_progress(), "banner": s.roto_progress().map(|p| p.banner())}); + let Ok((cid, lid, uid)) = object_matte_p(s, p, "objectMatte.status") else { return Ok(out) }; + let (_, g) = group(s, cid, lid, uid)?; + let frozen = frozen_of(g); + let (track, before, after) = object_matte_reach(s, cid, lid, uid); + let computed = s.roto_computed(cid, lid, uid); + out["layer"] = json!(lid.0); + out["effect"] = json!(uid); + out["frozen"] = json!(frozen); + out["track"] = json!(track); + out["reach"] = json!([before, after]); + out["mine"] = json!(running && s.roto_target().is_some_and(|(c, l, u, _)| (c, l, u) == (cid, lid, uid))); + out["pending"] = json!(s.roto_pending.contains(&(cid, lid, uid))); + out["frames"] = json!(computed.keys().collect::>()); + out["computed"] = json!(computed.iter().filter(|(_, c)| **c).map(|(f, _)| *f).collect::>()); + Ok(out) +} diff --git a/crates/engine/src/offload.rs b/crates/engine/src/offload.rs index 23fa2b38b..561ebe6c4 100644 --- a/crates/engine/src/offload.rs +++ b/crates/engine/src/offload.rs @@ -561,7 +561,8 @@ async fn fill(s: &mut Session, plan: crate::commands::content_fill::FillPlan, po /// Roto Brush propagation in a worker: the segmentations stream back (at most every 250 ms) /// as they are computed, so stopping the job keeps what was propagated. async fn propagate(s: &mut Session, comp: ItemId, layer: LayerId, effect: Uid, dir: crate::roto::Direction, post: Post) -> Result<(), String> { - let keys: Vec = s.roto_chain(comp, layer, effect).map(|c| c.keys.values().copied().collect()).ok_or("no Roto Brush strokes")?; + let keys: Vec = + s.roto_keys(comp, layer, effect).map(|k| k.into_iter().map(|(_, k)| k).collect()).ok_or("no Roto Brush strokes or Object Matte prompt")?; let sent = Rc::new(RefCell::new(std::collections::HashSet::new())); let (p, k2, s2) = (post.clone(), keys.clone(), sent.clone()); let mut last = None::; diff --git a/crates/engine/src/roto.rs b/crates/engine/src/roto.rs index b8c2c649c..49f53d39b 100644 --- a/crates/engine/src/roto.rs +++ b/crates/engine/src/roto.rs @@ -10,12 +10,18 @@ //! edit, keeps each instance's hidden Input Key equal to a signature of what its frames are made //! of (the layer's source and timing, its masks and the effects above it) and drops frozen //! mattes made from other frames. +//! +//! Object Matte instances run through the same jobs (`objectMatte.propagate` / `.freeze`): the +//! frames are [`om::job_frames`] around the reference frame, each segmented through +//! [`om::ensure`]; Freeze stores [`om::frame_matte`]'s mattes under [`om::frozen_key`]. An edit +//! to an instance's frames or prompt queues it for background tracking like a Roto Brush. use crate::offload::JobKind; use std::collections::{BTreeMap, HashMap}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; +use effectcraft_effects::object_matte as om; use effectcraft_effects::roto::{self as fx, FROZEN, FrozenCache, INPUT_KEY}; use effectcraft_effects::{Params, flatten_params}; use effectcraft_keyframe::Value; @@ -104,14 +110,18 @@ pub struct RotoProgress { pub finished: bool, pub cancelled: bool, pub error: Option, + /// The job works on an Object Matte (else a Roto Brush & Refine Edge). + pub object_matte: bool, } impl RotoProgress { pub fn banner(&self) -> String { let pc = (100 * self.done).checked_div(self.total).unwrap_or(0); - match self.task { - Some(RotoTask::Freeze) => format!("Freezing Roto Brush & Refine Edge: {pc}%"), - _ => format!("Propagating Roto Brush: {pc}%"), + match (self.task, self.object_matte) { + (Some(RotoTask::Freeze), false) => format!("Freezing Roto Brush & Refine Edge: {pc}%"), + (Some(RotoTask::Freeze), true) => format!("Freezing Object Matte: {pc}%"), + (_, false) => format!("Propagating Roto Brush: {pc}%"), + (_, true) => format!("Tracking Object Matte: {pc}%"), } } } @@ -130,8 +140,11 @@ pub struct RotoJob { pub layer: LayerId, pub effect: Uid, pub task: RotoTask, - /// The strokes the job was started with (a Freeze result is dropped when they changed). + /// What the job was started from: the strokes (Roto Brush) or the prompt (Object Matte); a + /// Freeze result is dropped when it changed. strokes: String, + /// The job works on an Object Matte. + pub object_matte: bool, } impl RotoJob { @@ -153,6 +166,20 @@ pub(crate) fn is_object_matte(g: &PropGroup) -> bool { matches!(&g.kind, GroupKind::Effect { effect } if effect == effectcraft_effects::object_matte::ID) } +/// What an Object Matte's segmentation is made from besides its frames: the prompt and tracking +/// parameters (static values), for queueing it after edits and guarding a Freeze. +pub(crate) fn om_prompt(g: &PropGroup) -> String { + let ps = params_static(g); + let mut keys: Vec<&String> = ps.values.keys().filter(|k| ["objectPoint", "useBox", "box", "objectTracking/"].iter().any(|p| k.starts_with(p))).collect(); + keys.sort(); + keys.iter().map(|k| format!("{k}={:?};", ps.values.get(*k))).collect() +} + +/// The job-relevant guard of an instance: its strokes (Roto Brush) or prompt (Object Matte). +fn guard_of(g: &PropGroup) -> String { + if is_object_matte(g) { om_prompt(g) } else { str_of(g, fx::STROKES) } +} + fn str_of(g: &PropGroup, m: &str) -> String { match g.get(m).map(|p| &p.value) { Some(Value::Str(s)) => s.clone(), @@ -195,8 +222,8 @@ pub fn input_key(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> String { } /// After an edit: refresh Input Keys (Roto Brush and Object Matte); frozen mattes made from -/// other frames are dropped. Returns the Roto Brush instances whose segmentation changed (to -/// propagate again). +/// other frames are dropped. Returns the instances whose segmentation changed (to propagate +/// again): new frames, strokes (Roto Brush) or prompt (Object Matte). pub(crate) fn sync(before: &Project, after: &mut Project) -> Vec<(ItemId, LayerId, Uid)> { let mut todo = vec![]; let mut set = vec![]; @@ -206,14 +233,11 @@ pub(crate) fn sync(before: &Project, after: &mut Project) -> Vec<(ItemId, LayerI let Some(fxg) = l.effects() else { continue }; for g in fxg.groups().filter(|g| is_roto(g) || is_object_matte(g)) { let key = input_key(after, *cid, l, g.uid); - let roto = is_roto(g); let old = before.comp(*cid).and_then(|bc| bc.layer(l.id)).and_then(|ol| ol.props.find_group(g.uid)); if str_of(g, INPUT_KEY) != key { set.push((*cid, l.id, g.uid, key)); - if roto { - todo.push((*cid, l.id, g.uid)); - } - } else if roto && old.is_none_or(|o| str_of(o, fx::STROKES) != str_of(g, fx::STROKES)) { + todo.push((*cid, l.id, g.uid)); + } else if old.is_none_or(|o| guard_of(o) != guard_of(g)) { todo.push((*cid, l.id, g.uid)); } } @@ -229,7 +253,11 @@ pub(crate) fn sync(before: &Project, after: &mut Project) -> Vec<(ItemId, LayerI } } todo.retain(|(c, l, u)| { - after.comp(*c).and_then(|c| c.layer(*l)).and_then(|l| l.props.find_group(*u)).is_some_and(|g| !fx::data(¶ms_static(g)).is_empty()) + after + .comp(*c) + .and_then(|c| c.layer(*l)) + .and_then(|l| l.props.find_group(*u)) + .is_some_and(|g| is_object_matte(g) || !fx::data(¶ms_static(g)).is_empty()) }); todo } @@ -284,6 +312,7 @@ struct Work { fps: f64, frames: Vec, task: RotoTask, + object_matte: bool, } /// Renders the effect's input frames for a worker, keeping the last few. With a browser job @@ -400,10 +429,13 @@ async fn run_work(w: Work, shared: &RotoShared) { let mut s = lock(&shared.state); s.task = Some(w.task); s.total = w.frames.len() as u64; + s.object_matte = w.object_matte; } let cancel = || shared.cancel.load(Ordering::Relaxed); let chain = fx::Chain::new(&w.params, w.size, w.fps, 1.0); - let mut frozen = FrozenCache { key: fx::frozen_key(&w.params, w.size, w.fps), ..Default::default() }; + let model = fx::installed_model(); + let key = if w.object_matte { om::frozen_key(&w.params, w.size, w.fps, model.as_deref()) } else { fx::frozen_key(&w.params, w.size, w.fps) }; + let mut frozen = FrozenCache { key, ..Default::default() }; for (k, f) in w.frames.iter().enumerate() { if cancel() { let mut s = lock(&shared.state); @@ -414,9 +446,21 @@ async fn run_work(w: Work, shared: &RotoShared) { // The frame and its neighbours (what a step reads), on the GPU. src.prefetch(&[*f, f - 1, f + 1]).await; let mut get = |g: i64| src.get(g); - let ok = match w.task { - RotoTask::Propagate => chain.ensure(*f, &mut get, &cancel).is_some(), - RotoTask::Freeze => match fx::frame_matte(&w.params, w.size, w.fps, *f, &mut get, &cancel) { + let ok = match (w.task, w.object_matte) { + (RotoTask::Propagate, true) => om::ensure(&w.params, w.size, w.fps, *f, model.clone(), &mut get, &cancel).is_some(), + (RotoTask::Freeze, true) => { + let img = get(*f); + match img.map(|img| (om::frame_matte(&w.params, w.size, w.fps, *f, 1.0, &img, model.clone(), &mut get, &cancel), img)) { + Some((om::FrameMatte::Alpha(a), img)) => { + frozen.size = [img.width as usize, img.height as usize]; + frozen.frames.insert(*f, fx::freeze_frame(&a, &[])); + true + } + _ => false, + } + } + (RotoTask::Propagate, false) => chain.ensure(*f, &mut get, &cancel).is_some(), + (RotoTask::Freeze, false) => match fx::frame_matte(&w.params, w.size, w.fps, *f, &mut get, &cancel) { Some((a, b)) => { if let Some(img) = src.get(*f) { frozen.size = [img.width as usize, img.height as usize]; @@ -471,12 +515,15 @@ impl Session { self.roto_job.as_ref().map(|j| j.progress()).or_else(|| { self.offloaded_roto().map(|(j, task)| { let p = &j.progress; + let object_matte = + self.project.comp(j.comp).and_then(|c| c.layer(j.layer)).and_then(|l| l.props.find_group(j.uid)).is_some_and(is_object_matte); RotoProgress { task: Some(task), done: p.done, total: p.total, elapsed: p.elapsed, fps: crate::offload::rate(p.done, p.elapsed), + object_matte, ..Default::default() } }) @@ -488,7 +535,8 @@ impl Session { self.roto_job.as_ref().map(|j| (j.comp, j.layer, j.effect, j.task)).or_else(|| self.offloaded_roto().map(|(j, task)| (j.comp, j.layer, j.uid, task))) } - /// The segmentation chain a propagation of `effect` computes (its frames' cache keys). + /// The segmentation chain a propagation of Roto Brush `effect` computes (its frames' cache + /// keys). pub fn roto_chain(&self, comp: ItemId, layer: LayerId, effect: Uid) -> Option { let c = self.project.comp(comp)?; let l = c.layer(layer)?; @@ -499,6 +547,27 @@ impl Session { Some(fx::Chain::new(¶ms, layer_size(&self.project, c, l), c.frame_rate.as_f64(), 1.0)) } + /// The cache keys of the segmentations a propagation of `effect` (Roto Brush or Object + /// Matte) computes, by layer frame. + pub fn roto_keys(&self, comp: ItemId, layer: LayerId, effect: Uid) -> Option> { + if let Some(ch) = self.roto_chain(comp, layer, effect) { + return Some(ch.keys.into_iter().collect()); + } + let c = self.project.comp(comp)?; + let l = c.layer(layer)?; + let g = l.effects()?.groups().find(|g| g.uid == effect && is_object_matte(g))?; + let params = self.om_params(comp, l, g)?; + let frames = om::job_frames(¶ms, frame_limits(c, l)); + Some(om::frame_keys(¶ms, layer_size(&self.project, c, l), c.frame_rate.as_f64(), &frames, fx::installed_model())) + } + + /// An Object Matte's parameters at its reference frame (what its jobs segment with). + fn om_params(&self, comp: ItemId, l: &Layer, g: &PropGroup) -> Option { + let c = self.project.comp(comp)?; + let t = comp_time_of(c, l, om::reference_frame(¶ms_static(g))); + Some(params_at(&self.project, comp, l, g, t, self.expr.as_deref())) + } + pub fn stop_roto(&mut self) -> bool { if self.cancel_offloaded(JobKind::RotoFreeze) || self.cancel_offloaded(JobKind::RotoPropagate) { return true; @@ -532,17 +601,36 @@ impl Session { let l = c.layer(layer).ok_or("no layer")?; let fxg = l.effects().ok_or("the layer has no effects")?; let index = fxg.groups().position(|g| g.uid == effect).ok_or("no such effect")?; - let g = fxg.groups().nth(index).filter(|g| is_roto(g)).ok_or("not a Roto Brush & Refine Edge effect")?; + let g = fxg.groups().nth(index).filter(|g| is_roto(g) || is_object_matte(g)).ok_or("not a Roto Brush & Refine Edge or Object Matte effect")?; + let object_matte = is_object_matte(g); let size = layer_size(&self.project, c, l); let fps = c.frame_rate.as_f64(); - let pstat = params_static(g); - let d = fx::data(&pstat); - if d.is_empty() { - return Err("draw a foreground stroke with the Roto Brush tool first".into()); - } - let t = comp_time_of(c, l, d.base.unwrap_or(0)); - let params = params_at(&self.project, comp, l, g, t, self.expr.as_deref()); - let frames = job_frames(&d, if task == RotoTask::Freeze { Direction::Both } else { dir }); + let (params, frames) = if object_matte { + let params = self.om_params(comp, l, g).ok_or("no composition")?; + let base = om::reference_frame(¶ms); + if om::roto_data(¶ms, size, base, fx::installed_model().is_some()).is_none() { + return Err("set the object first: click it with the Object Matte tool (its point is off the layer or outside its box)".into()); + } + let all = om::job_frames(¶ms, frame_limits(c, l)); + if all.is_empty() { + return Err(format!("the reference frame {base} is outside the layer")); + } + // Forward / backward from the reference frame, as for a Roto Brush. + let frames = all + .into_iter() + .filter(|f| task == RotoTask::Freeze || dir == Direction::Both || *f == base || (*f > base) == (dir == Direction::Forward)) + .collect(); + (params, frames) + } else { + let pstat = params_static(g); + let d = fx::data(&pstat); + if d.is_empty() { + return Err("draw a foreground stroke with the Roto Brush tool first".into()); + } + let t = comp_time_of(c, l, d.base.unwrap_or(0)); + let params = params_at(&self.project, comp, l, g, t, self.expr.as_deref()); + (params, job_frames(&d, if task == RotoTask::Freeze { Direction::Both } else { dir })) + }; let n = frames.len(); let work = Work { project: self.project.clone(), @@ -557,8 +645,9 @@ impl Session { fps, frames, task, + object_matte, }; - let strokes = str_of(g, fx::STROKES); + let strokes = guard_of(g); if !wait && self.offloads() { drop(work); self.offload_analysis(match task { @@ -569,6 +658,13 @@ impl Session { } let wait = wait || cfg!(target_arch = "wasm32"); let shared = Arc::new(RotoShared::default()); + { + // Known before the worker starts (the progress shows at once). + let mut st = lock(&shared.state); + st.task = Some(task); + st.total = n as u64; + st.object_matte = object_matte; + } let thread = if wait { crate::offload::run_or_queue({ let sh = shared.clone(); @@ -584,7 +680,7 @@ impl Session { .map_err(|e| e.to_string())?, ) }; - self.roto_job = Some(RotoJob { shared, thread, comp, layer, effect, task, strokes }); + self.roto_job = Some(RotoJob { shared, thread, comp, layer, effect, task, strokes, object_matte }); self.events.push(Event::ProjectChanged { revision: self.revision }); self.poll_roto_job(); Ok(n) @@ -601,40 +697,43 @@ impl Session { let _ = t.join(); } let st = job.progress(); + let name = if job.object_matte { "Object Matte" } else { "Roto Brush" }; if let Some(e) = &st.error { - self.events.push(Event::Toast { message: format!("Roto Brush: {e}"), error: true }); + self.events.push(Event::Toast { message: format!("{name}: {e}"), error: true }); self.bump(); return true; } if st.cancelled { - self.events.push(Event::Toast { message: "Roto Brush: stopped".into(), error: false }); + self.events.push(Event::Toast { message: format!("{name}: stopped"), error: false }); self.bump(); return true; } match job.task { RotoTask::Propagate => { - self.events.push(Event::Toast { message: format!("Roto Brush: propagated {} frame(s) in {:.1} s", st.done, st.elapsed), error: false }); + let verb = if job.object_matte { "tracked" } else { "propagated" }; + self.events.push(Event::Toast { message: format!("{name}: {verb} {} frame(s) in {:.1} s", st.done, st.elapsed), error: false }); // Rendered frames depend on the cache: redraw. self.bump(); } RotoTask::Freeze => { let Some(fc) = lock(&job.shared.frozen).take() else { return true }; let (cid, lid, uid, strokes) = (job.comp, job.layer, job.effect, job.strokes.clone()); + let what = if job.object_matte { "prompt" } else { "strokes" }; let r = self.edit("Freeze", None, |p, _| { let g = p .comp_mut(cid) .and_then(|c| c.layer_mut(lid)) .and_then(|l| l.props.find_group_mut(uid)) - .ok_or_else(|| crate::EngineError::Other("the Roto Brush effect was removed".into()))?; - if str_of(g, fx::STROKES) != strokes { - return Err(crate::EngineError::Other("the strokes changed while freezing".into())); + .ok_or_else(|| crate::EngineError::Other(format!("the {name} effect was removed")))?; + if guard_of(g) != strokes { + return Err(crate::EngineError::Other(format!("the {what} changed while freezing"))); } set_str(g, FROZEN, fc.to_json()); Ok(()) }); let msg = match r { - Ok(()) => format!("Roto Brush: froze {} frame(s) in {:.1} s", fc.frames.len(), st.elapsed), - Err(e) => format!("Roto Brush: {e}"), + Ok(()) => format!("{name}: froze {} frame(s) in {:.1} s", fc.frames.len(), st.elapsed), + Err(e) => format!("{name}: {e}"), }; self.events.push(Event::Toast { message: msg, error: false }); } @@ -664,6 +763,12 @@ impl Session { let Some(c) = self.project.comp(comp) else { return out }; let Some(l) = c.layer(layer) else { return out }; let Some(g) = l.props.find_group(effect) else { return out }; + if is_object_matte(g) { + for (f, k) in self.roto_keys(comp, layer, effect).unwrap_or_default() { + out.insert(f, fx::cached(k, 1.0).is_some()); + } + return out; + } let ps = params_static(g); let ch = fx::Chain::new(&ps, layer_size(&self.project, c, l), c.frame_rate.as_f64(), 1.0); for f in ch.keys.keys() { diff --git a/crates/engine/src/tests_roto.rs b/crates/engine/src/tests_roto.rs index 19eee1103..edcab5c1c 100644 --- a/crates/engine/src/tests_roto.rs +++ b/crates/engine/src/tests_roto.rs @@ -465,3 +465,84 @@ fn object_matte_reprompt_updates_the_instance() { // An unknown or non-Object Matte effect is refused. assert!(s.execute_checked("objectMatte.apply", json!({"layer": clip.0, "effect": 999_999, "point": c})).is_err()); } + +/// Object Matte runs in the background like Roto Brush propagation: queued by the edit, a job +/// that returns at once and reports progress, stops when cancelled (keeping what it computed), +/// computes the tracked frames when left to run, and Freeze stores mattes that renders then use +/// without segmenting again. +#[test] +fn object_matte_background_job_cancel_and_freeze() { + hold_roto_cache_test_lock(); + let (mut s, clip, solid) = setup(|f| draw(center(f))); + let cid = s.active_comp_id().unwrap(); + s.edit("remove solid", None, |p, _| { + p.comp_mut(cid).unwrap().layers.retain(|l| l.id != solid); + for it in p.items.values_mut() { + if let effectcraft_project::ItemKind::Footage(f) = &mut it.kind { + f.path = "object-matte-job.mov".into(); + } + } + Ok(()) + }) + .unwrap(); + let c = center(0); + let r = s.execute_checked("objectMatte.apply", json!({"layer": clip.0, "point": c, "before": 0, "after": 24, "frame": 0})).unwrap(); + let uid = r["effect"].as_u64().unwrap(); + assert!(s.roto_pending.contains(&(cid, clip, uid)), "the new prompt is queued for tracking"); + let wait_done = |s: &mut Session| { + let t = std::time::Instant::now(); + while s.is_roto_running() { + assert!(t.elapsed().as_secs() < 120, "the job never finished"); + std::thread::sleep(std::time::Duration::from_millis(5)); + s.poll_roto(false); + } + s.poll_roto(false); + }; + let toast = |s: &Session, m: &str| s.events.iter().any(|e| matches!(e, crate::Event::Toast { message, .. } if message.starts_with(m))); + let status = |s: &mut Session| s.execute("objectMatte.status", json!({"layer": clip.0, "effect": uid})).unwrap(); + + // Cancelled at once: it stops early, and says so. + let r = s.execute_checked("objectMatte.propagate", json!({"layer": clip.0, "effect": uid})).unwrap(); + assert_eq!(r["frames"], 25); + assert!(!s.roto_pending.contains(&(cid, clip, uid))); + let _ = s.execute("roto.cancel", json!({})); + wait_done(&mut s); + assert!(toast(&s, "Object Matte: stopped"), "{:?}", s.events); + let st = status(&mut s); + assert_eq!(st["frames"].as_array().unwrap().len(), 25, "{st}"); + let early = st["computed"].as_array().unwrap().len(); + assert!(early < 25, "{st}"); + + // Left to run: the call returns while the job works (progress visible), then every tracked + // frame is computed. + let r = s.execute_checked("objectMatte.propagate", json!({"layer": clip.0, "effect": uid})).unwrap(); + assert_eq!(r["running"], true, "{r}"); + let pr = s.roto_progress().unwrap(); + assert!(pr.object_matte && pr.banner().starts_with("Tracking Object Matte"), "{pr:?}"); + // One job at a time. + assert!(s.execute_checked("objectMatte.propagate", json!({"layer": clip.0, "effect": uid})).is_err()); + wait_done(&mut s); + assert!(toast(&s, "Object Matte: tracked"), "{:?}", s.events); + assert_eq!(status(&mut s)["computed"].as_array().unwrap().len(), 25); + let before = alpha_at(&s, 4); + + // Freeze, then drop the segmentations: renders use the frozen mattes and compute nothing. + let r = s.execute_checked("objectMatte.freeze", json!({"layer": clip.0, "effect": uid, "wait": true})).unwrap(); + assert_eq!(r["frozen"], true, "{r}"); + let keys: Vec = s.roto_keys(cid, clip, uid).unwrap().into_iter().map(|(_, k)| k).collect(); + assert_eq!(keys.len(), 25); + effectcraft_effects::roto::forget(keys.iter().copied()); + let frozen = alpha_at(&s, 4); + let diff = before.iter().zip(&frozen).filter(|(a, b)| (**a - **b).abs() > 0.02).count(); + assert!(diff < 30, "{diff} pixels differ from the computed matte"); + assert!(status(&mut s)["computed"].as_array().unwrap().is_empty(), "a frozen frame was segmented again"); + assert_eq!(status(&mut s)["frozen"], true); + // A frozen prompt is refused; Unfreeze computes again. + assert!(s.execute_checked("objectMatte.apply", json!({"layer": clip.0, "effect": uid, "point": c})).is_err()); + s.execute_checked("objectMatte.unfreeze", json!({"layer": clip.0, "effect": uid})).unwrap(); + let _ = alpha_at(&s, 4); + assert!(status(&mut s)["computed"].as_array().unwrap().iter().any(|f| f == 4)); + // Undo brings the frozen mattes back. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(status(&mut s)["frozen"], true); +} diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index f7c779053..117d4b708 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -508,6 +508,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("⌘/Ctrl-drag a pin to record its motion", "⌘/Ctrl+ドラッグでピンの動きを記録"), ("▣ {}", "▣ {}"), ("⚠ {} is used by:", "⚠ {}は次のコマンドで使用中:"), + ("Track", "トラック"), ]; const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ @@ -1033,6 +1034,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("After Effects project", "After Effects 项目"), ("Lottie animation", "Lottie 动画"), ("Project", "项目"), + ("Track", "跟踪"), ]; const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ @@ -1553,6 +1555,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("After Effects project", "After Effects 專案"), ("Lottie animation", "Lottie 動畫"), ("Project", "專案"), + ("Track", "追蹤"), ]; /// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs. @@ -1988,6 +1991,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("⌘/Ctrl-drag a pin to record its motion", "Перетягніть опорну точку з ⌘/Ctrl, щоб записати її рух"), ("▣ {}", "▣ {}"), ("⚠ {} is used by:", "⚠ {} використовується в:"), + ("Track", "Відстежити"), ]; #[cfg(test)] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a0e05e007..a6b5501cd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1580,6 +1580,12 @@ impl EffectcraftApp { self.session.poll_roto(true); ctx.request_repaint_after(std::time::Duration::from_millis(100)); } + // Roto Brush propagation and Object Matte tracking / Freeze: finish jobs (a Freeze writes + // its mattes) and start queued ones in the background. + if self.session.roto_job.is_some() || !self.session.roto_pending.is_empty() { + self.session.poll_roto(true); + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } // 3D Camera Tracker: likewise (tracking, then solving). if self.session.camera_job.is_some() || !self.session.camera_pending.is_empty() { self.session.poll_camera(true); diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 501adf898..a63b5a832 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -787,6 +787,44 @@ fn roto_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, l p.text(pos2(r.min.x + 24.0, r.min.y + 38.0), Align2::LEFT_CENTER, status, Tokens::ui(11.5), t.text_dim); } +/// Object Matte: Freeze / Unfreeze and Track / Stop buttons (the background job that computes +/// the matte over the tracked frames) and its status. +fn object_matte_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, layer: &Layer, g: &PropGroup, r: Rect, actions: &mut Actions) { + use effectcraft_engine::effects::object_matte as om; + let t = app.tokens; + let params = effectcraft_engine::effects::flatten_params(g, &mut |pr| pr.value.clone()); + let frozen = om::is_frozen(¶ms); + let mine = app.session.roto_target().is_some_and(|(_, l, u, _)| l == layer.id && u == g.uid); + let running = mine && app.session.is_roto_running(); + let busy = app.session.is_roto_running(); + let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0)); + let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(84.0, 22.0)); + let label = if frozen { "Unfreeze" } else { "Freeze" }; + if widgets::text_button(ui, b1, crate::i18n::tr(label), frozen, &t, egui::Id::new(("om-freeze-ec", g.uid))).clicked() && !busy { + let id = if frozen { "objectMatte.unfreeze" } else { "objectMatte.freeze" }; + actions.push((id.into(), json!({"layer": layer.id.0, "effect": g.uid}))); + } + app.auto.add(&format!("effectControls.objectMatte.{}.freeze", g.uid), b1, label); + let (l2, id2) = if running { ("Stop", "roto.cancel") } else { ("Track", "objectMatte.propagate") }; + if widgets::text_button(ui, b2, crate::i18n::tr(l2), false, &t, egui::Id::new(("om-track-ec", g.uid))).clicked() && !frozen && (running || !busy) { + actions.push((id2.into(), json!({"layer": layer.id.0, "effect": g.uid}))); + } + app.auto.add(&format!("effectControls.objectMatte.{}.track", g.uid), b2, l2); + let base = om::reference_frame(¶ms); + let status = match app.session.roto_progress() { + Some(pr) if running => pr.banner(), + _ if frozen => format!("Frozen from reference frame {base}"), + _ if app.session.roto_pending.iter().any(|(_, l, u)| *l == layer.id && *u == g.uid) => "Waiting to track".to_string(), + _ => format!("Reference frame {base}: Track computes the matte ahead of playback"), + }; + p.text(pos2(r.min.x + 24.0, r.min.y + 38.0), Align2::LEFT_CENTER, &status, Tokens::ui(11.5), t.text_dim); + app.auto.add( + &format!("effectControls.objectMatte.{}.status", g.uid), + Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 30.0), vec2(r.width() - 24.0, 16.0)), + &status, + ); +} + // --------------------------------------------------------------------------------------------- // Curves / Levels editors @@ -797,7 +835,7 @@ fn editor_height(effect: &str, g: &PropGroup, width: f32) -> f32 { "ec.color.curves" => 34.0 + curves_size(width) + 26.0, effectcraft_engine::effects::warp_stab::ID => 50.0, effectcraft_engine::effects::camera_tracker::ID => super::camera_tracker_ui::EDITOR_HEIGHT, - effectcraft_engine::effects::roto::ID => 50.0, + effectcraft_engine::effects::roto::ID | effectcraft_engine::effects::object_matte::ID => 50.0, "ec.color.levels" | "ec.color.levelsic" => 34.0 + 80.0 + 58.0, "ec.color.autolevels" | "ec.color.autocontrast" | "ec.color.autocolor" => 24.0 + 80.0 + 12.0, _ => 0.0, @@ -1207,6 +1245,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { effectcraft_engine::effects::warp_stab::ID => warp_editor(app, ui, &bp, &layer, g, er, &mut actions), effectcraft_engine::effects::camera_tracker::ID => super::camera_tracker_ui::editor(app, ui, &bp, &layer, g, er, &mut actions), effectcraft_engine::effects::roto::ID => roto_editor(app, ui, &bp, &layer, g, er, &mut actions), + effectcraft_engine::effects::object_matte::ID => object_matte_editor(app, ui, &bp, &layer, g, er, &mut actions), "ec.color.levels" | "ec.color.levelsic" => levels_editor(app, ui, &bp, &layer, g, effect, &ectx, er, &mut actions), super::fx_editors::GLOW | super::fx_editors::RESHAPE | super::fx_editors::EXTRACTOR => { super::fx_editors::header_editor(app, ui, &bp, &layer, g, effect, &ectx, er, &mut actions) diff --git a/crates/ui-egui/tests/ui_object_matte.rs b/crates/ui-egui/tests/ui_object_matte.rs index 483d271ea..5f30ba03b 100644 --- a/crates/ui-egui/tests/ui_object_matte.rs +++ b/crates/ui-egui/tests/ui_object_matte.rs @@ -8,6 +8,7 @@ use effectcraft_engine::effects::object_matte as om; use effectcraft_engine::keyframe::Value; use effectcraft_engine::project::{GroupKind, LayerId, PropGroup}; use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; use effectcraft_ui_egui::state::Tool; use egui::{Event, Modifiers, Pos2, Rect, pos2, vec2}; use egui_kittest::Harness; @@ -140,3 +141,45 @@ fn object_matte_tool_prompts_with_a_click_and_a_box() { h.state_mut().session.execute("edit.undo", json!({})).unwrap(); assert_eq!(flatten_params(&mattes(&h)[0], &mut |p| p.value.clone()).get("useBox"), Some(&Value::Bool(false))); } + +/// Effect Controls: the Object Matte's Track and Freeze buttons run the background job and its +/// status line shows where it is; the UI keeps drawing frames while it runs and finishes the +/// job itself (a Freeze writes its mattes without any other job running). +#[test] +fn object_matte_effect_controls_track_and_freeze() { + let mut h = harness(); + h.state_mut().show_panel(PanelKind::EffectControls); + h.state_mut().ui.tool = Tool::ObjectMatte; + h.run_steps(2); + let center = screen(&h, [320.0, 180.0]); + click(&mut h, center, Modifiers::NONE); + let uid = mattes(&h)[0].uid; + h.run_steps(2); + let status = |h: &Harness<'_, EffectcraftApp>| h.state().auto.find(&format!("effectControls.objectMatte.{uid}.status")).map(|e| e.label.clone()); + assert!(status(&h).is_some(), "no Object Matte status line"); + // Freeze: a background job (the frames keep coming), then the effect is frozen. + let b = rect(&h, &format!("effectControls.objectMatte.{uid}.freeze")); + // Let any automatic tracking finish first (one job at a time). + let frozen = |h: &Harness<'_, EffectcraftApp>| om::is_frozen(&flatten_params(&mattes(h)[0], &mut |p| p.value.clone())); + let t = std::time::Instant::now(); + while h.state().session.is_roto_running() { + assert!(t.elapsed().as_secs() < 120); + h.step(); + } + click(&mut h, b.center(), Modifiers::NONE); + let t = std::time::Instant::now(); + while !frozen(&h) { + assert!( + t.elapsed().as_secs() < 20, + "never frozen: {:?} / {} / running {} / {:?}", + status(&h), + h.state().ui.status, + h.state().session.is_roto_running(), + h.state().session.roto_progress() + ); + h.step(); + } + h.run_steps(2); + assert!(status(&h).is_some_and(|s| s.starts_with("Frozen")), "{:?}", status(&h)); + assert_eq!(h.state().auto.find(&format!("effectControls.objectMatte.{uid}.freeze")).map(|e| e.label.as_str()), Some("Unfreeze")); +} diff --git a/docs/agents.md b/docs/agents.md index 6a3b9e85e..d94d46bdc 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -471,6 +471,24 @@ effectcraft-cli run clip.ecproj roto.stroke '{"layer":"#1","points":[[300,200],[ roto.propagate '{"layer":"#1","wait":true}' roto.freeze '{"layer":"#1","wait":true}' --save ``` +### Object Matte + +The Object Matte tool (third in the Alt+W slot) works in the Composition viewer: click an object, +or drag a box around it; Shift adds another Object Matte instead of re-prompting the layer's. +Agents use `objectMatte.apply {layer, point: [x, y], box?: [x0, y0, x1, y1], frame?, track?, +before?, after?, effect?}` (layer pixels, layer frames; `effect` re-prompts that instance), one +undo step each. With a trained model installed (Settings ▸ Roto Brush) the box is the model's box +prompt. `objectMatte.propagate {layer, effect?, direction?, wait?}` tracks the matte over the +frames around the reference frame in the background (`roto.cancel` stops it; edits to the prompt +or the layer's frames queue it again), `objectMatte.freeze {wait?}` / `objectMatte.unfreeze` +store or drop the final mattes, and `objectMatte.status {layer, effect?}` reports the job's +progress, the frozen state, the tracked frames and which are computed. + +```sh +effectcraft-cli run clip.ecproj objectMatte.apply '{"layer":"#1","point":[320,200],"box":[250,120,400,300]}' \ + objectMatte.propagate '{"layer":"#1","wait":true}' objectMatte.freeze '{"layer":"#1","wait":true}' --save +``` + ### Text: styles and editing Source Text holds character style runs and per-paragraph settings. `layer.setText` changes the diff --git a/docs/effects.md b/docs/effects.md index 84f16ef86..8a02b4892 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,7 +3,7 @@ -EffectCraft ships 307 effects with 3028 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. +EffectCraft ships 307 effects with 3029 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. - **GPU**: 280 effects also run on the GPU compositor with identical results. - **32**: 307 effects process 32-bit float (HDR, overbright) pixels without clamping. From 3466538d0eb3288cf46f644958cd2cc295fc8a10 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 14:06:33 +0200 Subject: [PATCH 006/181] #783: Object Matte mattes persist in the disk cache Problem: computed Object Matte segmentations lived only in the in-memory cache, so every restart or memory eviction segmented the shot again (After Effects 26.5 keeps Object Matte results in its disk cache). Fix: the persistent disk cache gets a third entry kind, Matte (folder `mattes`, LZ4-compressed FrameSeg bytes, same checksum, atomic writes, LRU eviction under the one size limit, purged with the rest). Entries are keyed by the segmentation's chain key and scale; chain keys already hash the Input Key (the layer's source, timing, masks and effects above), the prompt, the mode and the model, so any change simply misses. The effects crate gets a SegStore trait behind its memory cache: Chain::ensure reads from it on a memory miss and writes what it computes. The engine installs the session's DiskCache as the store (held weakly, so switching the disk cache off drops it). Roto Brush segmentations go through the same chain and are persisted too. Frozen mattes already persist in the project (the Frozen parameter). Tests: engine tests_roto::object_matte_mattes_persist_in_the_disk_cache (a second session on the same folder and project gets all tracked frames back without being able to read a single input frame, renders identically, and a moved prompt finds nothing); render disk_cache segmentations_round_trip_and_reopen (round trip, reopen, damaged file is a miss, size limit evicts). Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/roto.rs | 50 ++++++++++++++--- crates/engine/src/session_settings.rs | 4 ++ crates/engine/src/tests_roto.rs | 66 +++++++++++++++++++++++ crates/render/src/disk_cache.rs | 78 +++++++++++++++++++++++++-- docs/agents.md | 4 +- 5 files changed, 191 insertions(+), 11 deletions(-) diff --git a/crates/effects/src/roto.rs b/crates/effects/src/roto.rs index cd2d240e4..a6c80e742 100644 --- a/crates/effects/src/roto.rs +++ b/crates/effects/src/roto.rs @@ -7,7 +7,8 @@ //! out to the frame), so it lives in a process-wide content-keyed cache ([`cached`]) that the //! engine's propagation job fills at full resolution and rendering fills on demand (reading //! neighbouring frames with [`crate::EffectHost::self_at`]). Undo, redo and reopening a project -//! therefore never see stale mattes. **Freeze** stores the final mattes in the hidden **Frozen** +//! therefore never see stale mattes. Behind the memory cache an optional [`SegStore`] (the +//! persistent disk cache, set by the engine) keeps computed segmentations across restarts. **Freeze** stores the final mattes in the hidden **Frozen** //! parameter ([`FrozenCache`]); they are used while their key still matches. //! //! Rendering: binary segmentation → Roto Brush Matte (Shift Edge, Reduce Chatter, Feather, @@ -23,21 +24,21 @@ //! higher working resolution with more iterations. use std::collections::{BTreeMap, HashMap, VecDeque}; -use std::sync::{Arc, Mutex, OnceLock, RwLock}; +use std::sync::{Arc, Mutex, OnceLock, RwLock, Weak}; use effectcraft_keyframe::Value; use effectcraft_project::ParamUi; use effectcraft_raster::Image; use effectcraft_segment::MaskModel; use effectcraft_track::roto::refine::{self, MatteParams}; -use effectcraft_track::roto::{self as rb, FrameSeg, RotoData, SegOpts, rle}; +use effectcraft_track::roto::{self as rb, RotoData, SegOpts, rle}; use rayon::prelude::*; use serde::{Deserialize, Serialize}; use crate::warp_stab::param; use crate::{Buf, EffectCtx, EffectSpec, Params, num, p, popup, slider}; -pub use effectcraft_track::roto::{Stroke, StrokeKind}; +pub use effectcraft_track::roto::{FrameSeg, Stroke, StrokeKind}; /// The effect's id. pub const ID: &str = "ec.matte.rotobrush"; @@ -266,6 +267,37 @@ pub fn store(key: u64, scale: f64, seg: Arc) { } } +/// A second-level store for segmentations, keyed like the memory cache (chain key, scale): the +/// persistent disk cache, so computed mattes survive the memory cache and restarts. +pub trait SegStore: Send + Sync { + fn get_seg(&self, key: u64, scale: f64) -> Option; + fn put_seg(&self, key: u64, scale: f64, seg: &FrameSeg); +} + +fn seg_store_slot() -> &'static RwLock>> { + static S: OnceLock>>> = OnceLock::new(); + S.get_or_init(|| RwLock::new(None)) +} + +/// Use `store` behind the memory cache (held weakly: once its owner drops it, e.g. the disk +/// cache is switched off, it is no longer used). `None` turns it off. +pub fn set_seg_store(store: Option>) { + if let Ok(mut s) = seg_store_slot().write() { + *s = store; + } +} + +fn seg_store() -> Option> { + seg_store_slot().read().ok()?.as_ref()?.upgrade() +} + +/// A segmentation from the second-level store, put into the memory cache. +fn from_store(key: u64, scale: f64) -> Option> { + let seg = Arc::new(seg_store()?.get_seg(key, scale)?); + store(key, scale, seg.clone()); + Some(seg) +} + /// Drop the cached segmentations of these chain keys (every scale). pub fn forget(keys: impl IntoIterator) { let keys: std::collections::HashSet = keys.into_iter().collect(); @@ -325,11 +357,11 @@ impl Chain { let sc = self.opts.scale; let lookup = |g: i64| -> Option> { let k = *self.keys.get(&g)?; - if let Some(s) = cached(k, sc) { + if let Some(s) = cached(k, sc).or_else(|| from_store(k, sc)) { return Some(s); } if sc != 1.0 { - let full = cached(k, 1.0)?; + let full = cached(k, 1.0).or_else(|| from_store(k, 1.0))?; let s = Arc::new(downscale(&full, sc)); store(k, sc, s.clone()); return Some(s); @@ -379,7 +411,11 @@ impl Chain { } }; let seg = Arc::new(seg); - store(*self.keys.get(&g)?, sc, seg.clone()); + let k = *self.keys.get(&g)?; + store(k, sc, seg.clone()); + if let Some(st) = seg_store() { + st.put_seg(k, sc, &seg); + } cur = Some(seg); prev_img = Some(img); } diff --git a/crates/engine/src/session_settings.rs b/crates/engine/src/session_settings.rs index 34f24786a..55c8e4154 100644 --- a/crates/engine/src/session_settings.rs +++ b/crates/engine/src/session_settings.rs @@ -132,6 +132,10 @@ impl Session { }, }; self.layer_cache.set_disk(Some((dc.clone(), footage_salt(&self.project)))); + // Roto Brush / Object Matte segmentations persist there too (held weakly: switching the + // disk cache off drops it). + let store: std::sync::Arc = dc.clone(); + effectcraft_effects::roto::set_seg_store(Some(std::sync::Arc::downgrade(&store))); self.disk_cache = Some(dc); } diff --git a/crates/engine/src/tests_roto.rs b/crates/engine/src/tests_roto.rs index edcab5c1c..4b82c8c3a 100644 --- a/crates/engine/src/tests_roto.rs +++ b/crates/engine/src/tests_roto.rs @@ -546,3 +546,69 @@ fn object_matte_background_job_cancel_and_freeze() { s.execute("edit.undo", json!({})).unwrap(); assert_eq!(status(&mut s)["frozen"], true); } + +/// Computed Object Matte segmentations go to the persistent disk cache: a second session on the +/// same folder and project gets them back without segmenting (its frames cannot even be read +/// here), and another prompt (other keys) finds nothing. +#[test] +fn object_matte_mattes_persist_in_the_disk_cache() { + use effectcraft_effects::object_matte as om; + use effectcraft_render::disk_cache::Kind; + hold_roto_cache_test_lock(); + let dir = std::env::temp_dir().join(format!("effectcraft-om-disk-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let enable = |s: &mut Session| { + s.execute_checked("prefs.set", json!({"values": {"disk.diskCacheFolder": dir.to_string_lossy(), "disk.diskCacheEnabled": true}})).unwrap(); + }; + let (mut s, clip, solid) = setup(|f| draw(center(f))); + enable(&mut s); + let cid = s.active_comp_id().unwrap(); + s.edit("remove solid", None, |p, _| { + p.comp_mut(cid).unwrap().layers.retain(|l| l.id != solid); + for it in p.items.values_mut() { + if let effectcraft_project::ItemKind::Footage(f) = &mut it.kind { + f.path = "object-matte-disk.mov".into(); + } + } + Ok(()) + }) + .unwrap(); + let c = center(0); + let r = s.execute_checked("objectMatte.apply", json!({"layer": clip.0, "point": c, "before": 0, "after": 5, "frame": 0})).unwrap(); + let uid = r["effect"].as_u64().unwrap(); + s.execute_checked("objectMatte.propagate", json!({"layer": clip.0, "effect": uid, "wait": true})).unwrap(); + let keys = s.roto_keys(cid, clip, uid).unwrap(); + assert_eq!(keys.len(), 6); + let dc = s.disk_cache.clone().unwrap(); + dc.flush(); + assert!(dc.keys(Kind::Matte).len() >= 6, "{:?}", dc.stats()); + let before = alpha_at(&s, 3); + let saved = s.project.clone(); + drop(dc); + drop(s); + effectcraft_effects::roto::forget(keys.iter().map(|(_, k)| *k)); + + // "Reopen": a new session on the same folder and project. + let (mut s2, _, _) = setup(|f| draw(center(f))); + enable(&mut s2); + s2.project = saved; + s2.bump(); + s2.open_comp(cid); + assert_eq!(s2.roto_keys(cid, clip, uid).unwrap(), keys, "the same input key, so the same keys"); + let comp = s2.active_comp().unwrap().clone(); + let l = comp.layer(clip).unwrap(); + let ps = crate::roto::params_static(l.props.find_group(uid).unwrap()); + let size = crate::roto::layer_size(&s2.project, &comp, l); + // No frames to segment from: whatever comes back was read from disk. + for (f, _) in &keys { + assert!(om::ensure(&ps, size, 25.0, *f, None, &mut |_| None, &|| false).is_some(), "frame {f} not reused from disk"); + } + assert!(s2.disk_cache.as_ref().unwrap().stats().hits >= 6); + let again = alpha_at(&s2, 3); + assert_eq!(again, before, "the disk-cached matte renders the same"); + // Another prompt: other keys, nothing on disk. + let mut moved = ps.clone(); + moved.values.insert("objectPoint".into(), effectcraft_keyframe::Value::Vec2([c[0] + 3.0, c[1]])); + assert!(om::ensure(&moved, size, 25.0, 0, None, &mut |_| None, &|| false).is_none()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/render/src/disk_cache.rs b/crates/render/src/disk_cache.rs index da46708e7..4140b7923 100644 --- a/crates/render/src/disk_cache.rs +++ b/crates/render/src/disk_cache.rs @@ -8,7 +8,9 @@ //! project therefore never reads stale pixels: its keys simply change. //! * Payloads are LZ4-compressed (`lz4_flex`, MIT): frames as 8-bit premultiplied RGBA, layer //! buffers as `f32` RGBA with the bytes shuffled into planes (exact, so cached and fresh -//! renders are bit-identical). Every file ends in a checksum; damaged files read as misses and +//! 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. //! * Size limit with least-recently-used eviction. The in-memory index is rebuilt from the //! folder when the cache opens (file modification times order it), so the cache survives @@ -37,6 +39,8 @@ const QUEUE_LIMIT: usize = 64; pub enum Kind { Frame, Layer, + /// A Roto Brush / Object Matte segmentation. + Matte, } impl Kind { @@ -45,12 +49,14 @@ impl Kind { match self { Kind::Frame => "frames", Kind::Layer => "layers", + Kind::Matte => "mattes", } } fn tag(self) -> u8 { match self { Kind::Frame => 1, Kind::Layer => 2, + Kind::Matte => 3, } } } @@ -319,7 +325,7 @@ impl DiskCache { /// Rebuild the index from the folder (oldest files first). pub fn rescan(&self) { let mut found: Vec<(std::time::SystemTime, Kind, u128, u64)> = vec![]; - for kind in [Kind::Frame, Kind::Layer] { + for kind in [Kind::Frame, Kind::Layer, Kind::Matte] { let Ok(shards) = std::fs::read_dir(self.dir.join(kind.dir())) else { continue }; for shard in shards.flatten() { let Ok(files) = std::fs::read_dir(shard.path()) else { continue }; @@ -403,7 +409,7 @@ impl DiskCache { if let Ok(mut g) = self.index.lock() { g.clear(); } - for kind in [Kind::Frame, Kind::Layer] { + for kind in [Kind::Frame, Kind::Layer, Kind::Matte] { let _ = std::fs::remove_dir_all(self.dir.join(kind.dir())); } } @@ -555,6 +561,22 @@ impl DiskCache { self.write_now(Kind::Layer, key, &encode_layer(buf)); } + /// A segmentation (chain key, scale) written by [`DiskCache::put_seg`]. + pub fn get_seg(&self, key: u64, scale: f64) -> Option { + let p = self.read(Kind::Matte, seg_key(key, scale))?; + let raw = lz4_flex::decompress_size_prepended(&p).ok()?; + effectcraft_effects::roto::FrameSeg::from_bytes(&raw) + } + + /// Queue a segmentation for writing. + pub fn put_seg(&self, key: u64, scale: f64, seg: &effectcraft_effects::roto::FrameSeg) { + let k = seg_key(key, scale); + if self.contains(Kind::Matte, k) { + return; + } + self.enqueue(Kind::Matte, k, lz4_flex::compress_prepend_size(&seg.to_bytes())); + } + /// 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)); @@ -627,6 +649,24 @@ pub fn read_layer_entry(data: &[u8]) -> Option { decode_layer(verify(data, Kind::Layer)?) } +/// The disk key of a segmentation: its chain key and scale. +fn seg_key(key: u64, scale: f64) -> u128 { + let mut h = Hash128::default(); + h.write(b"segmentation-v1"); + h.write_u64(key); + h.write_u64(scale.to_bits()); + h.finish() +} + +impl effectcraft_effects::roto::SegStore for DiskCache { + fn get_seg(&self, key: u64, scale: f64) -> Option { + DiskCache::get_seg(self, key, scale) + } + fn put_seg(&self, key: u64, scale: f64, seg: &effectcraft_effects::roto::FrameSeg) { + DiskCache::put_seg(self, key, scale, seg); + } +} + impl crate::cache::LayerStore for DiskCache { fn get_layer(&self, key: u128) -> Option> { DiskCache::get_layer(self, key).map(Arc::new) @@ -889,6 +929,38 @@ mod tests { assert_eq!(parse_entry_name(&format!("{key:032x}.tmp.1")), None); } + /// Segmentations round-trip by (chain key, scale), survive a reopen (rescan), are bounded by + /// the size limit like every entry, and a damaged file is a miss. + #[test] + fn segmentations_round_trip_and_reopen() { + use effectcraft_effects::roto::FrameSeg; + let dir = tmpdir("seg"); + let dc = DiskCache::open(&dir, 1 << 30).unwrap(); + let seg = FrameSeg { w: 7, h: 5, matte: (0..35).map(|i| (i % 3 == 0) as u8).collect(), refine: vec![0; 35], ..Default::default() }; + assert!(dc.get_seg(42, 1.0).is_none()); + dc.put_seg(42, 1.0, &seg); + dc.flush(); + assert_eq!(dc.get_seg(42, 1.0), Some(seg.clone())); + assert!(dc.get_seg(42, 0.5).is_none() && dc.get_seg(43, 1.0).is_none()); + assert_eq!(dc.keys(Kind::Matte).len(), 1); + drop(dc); + let dc = DiskCache::open(&dir, 1 << 30).unwrap(); + assert_eq!(dc.get_seg(42, 1.0), Some(seg.clone())); + // Damaged on disk: a miss, and the entry is forgotten. + let path = dc.path(Kind::Matte, seg_key(42, 1.0)); + let mut d = std::fs::read(&path).unwrap(); + d[18] ^= 0x55; + std::fs::write(&path, d).unwrap(); + assert!(dc.get_seg(42, 1.0).is_none()); + assert!(!dc.contains(Kind::Matte, seg_key(42, 1.0))); + // The size limit evicts segmentations too. + dc.put_seg(1, 1.0, &seg); + dc.flush(); + dc.set_max_bytes(1); + assert!(dc.keys(Kind::Matte).is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn hit_miss_and_exact_round_trip() { let dir = tmpdir("hit"); diff --git a/docs/agents.md b/docs/agents.md index d94d46bdc..8bf3cc878 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -482,7 +482,9 @@ prompt. `objectMatte.propagate {layer, effect?, direction?, wait?}` tracks the m frames around the reference frame in the background (`roto.cancel` stops it; edits to the prompt or the layer's frames queue it again), `objectMatte.freeze {wait?}` / `objectMatte.unfreeze` store or drop the final mattes, and `objectMatte.status {layer, effect?}` reports the job's -progress, the frozen state, the tracked frames and which are computed. +progress, the frozen state, the tracked frames and which are computed. With the disk cache on (Settings ▸ +Media & Disk Cache), computed Object Matte and Roto Brush segmentations are also kept there, keyed +by what they are made of, so reopening a project reuses them. ```sh effectcraft-cli run clip.ecproj objectMatte.apply '{"layer":"#1","point":[320,200],"box":[250,120,400,300]}' \ From a8de199acac3c079be916bbed2ba3ad4f64797f6 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 14:07:22 +0200 Subject: [PATCH 007/181] #783: Object Matte fidelity plan; gaps and parity notes updated Problem: Object Matte had no After Effects comparison, and none can be made on this Linux machine (no After Effects). The gap notes still listed the viewer tool, background job and disk cache as missing. Fix: docs/fidelity/object-matte.md states plainly that nothing is measured yet and pins down the comparison for when an After Effects 26.2+ reference is available: four original or openly licensed clips (the synthetic moving disk from the engine tests, the disk with a jump, two objects with a box prompt, one CC0/CC BY real clip), the prompt and settings, where reference mattes stay (gitignored), and the metrics: per-frame IoU against After Effects (headline), IoU against ground truth, boundary distance, soft-edge error, temporal stability, drift and time per frame, plus a proposed pass bar left to the maintainers. Linked from docs/fidelity/README.md. docs/gaps.md (#14) and docs/target-app-parity.md now list what landed and leave the comparison as the one remaining item. Co-Authored-By: Claude Opus 5.5 --- docs/fidelity/README.md | 3 ++ docs/fidelity/object-matte.md | 73 +++++++++++++++++++++++++++++++++++ docs/gaps.md | 4 +- docs/target-app-parity.md | 4 +- 4 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 docs/fidelity/object-matte.md diff --git a/docs/fidelity/README.md b/docs/fidelity/README.md index 025997d47..5afafee41 100644 --- a/docs/fidelity/README.md +++ b/docs/fidelity/README.md @@ -3,6 +3,9 @@ The [AE 2026 replacement acceptance gates](ae-2026-acceptance.md) define the broader target across workflows, rendered output, work-project compatibility, performance and platforms. +[Object Matte](object-matte.md) has a comparison plan (clips, prompt, per-frame IoU and the other +metrics) but no measurement yet. + The first G1 comparison measures eight original scalar Rotation cases against a live After Effects 2026 session through AEsync 2.0.4: Easy Ease, overlapping influences, asymmetric ease, overshoot, descending values, equal endpoints, 100%/100% influence and 0.1%/100% influence. Each case has diff --git a/docs/fidelity/object-matte.md b/docs/fidelity/object-matte.md new file mode 100644 index 000000000..da4e3bd3d --- /dev/null +++ b/docs/fidelity/object-matte.md @@ -0,0 +1,73 @@ +# Object Matte: comparison plan (not measured yet) + +**Status: untested.** There is no After Effects 26.2+ comparison of Object Matte yet. Nothing on +this page is a measurement. It lists what to compare once an After Effects reference session +is available, so the first run can be repeated and scored without guesswork. + +EffectCraft side, as of #783: the Object Matte effect (`ec.matte.objectmatte`), the Object Matte +tool in the Composition viewer, `objectMatte.apply` / `.propagate` / `.freeze` / +`.status`, and segmentations kept in the disk cache. The point (and an optional box) is +segmented by Roto Brush's engine: MobileSAM when it is installed (Settings ▸ Roto Brush, with +the box as the model's box prompt), otherwise the classical graph cut. The result is tracked +frame by frame with optical flow, and the model re-prompts each frame from the carried matte. +Record which engine was active, because the two are separate cases. + +## Test clips + +All original, generated or openly licensed; none of them goes into the repository. + +1. **Synthetic disk**: the moving textured disk over a textured background from + `crates/engine/src/tests_roto.rs` (`draw(center(f))`, 320×240, 25 fps, 25 frames, radius + 40 px, about 4 px a frame to the right). Export it as a PNG sequence. Its ground truth is + analytic (`gt(center(f))`). +2. **Synthetic disk with a jump**: the same disk with `jumping(f)` (it jumps at frame 8). This + checks how each tool handles losing the object. +3. **Two objects**: the disk plus a second still, differently coloured square that touches it + on some frames, prompted once with a point only and once with a box around the disk. This + checks the box prompt. +4. **Real footage**: one openly licensed clip (CC0 or CC BY) with a person or object moving + against clutter, at least 1080p and 5 s long. Record its URL, licence and SHA-256 next to the + results, and keep the clip itself out of the repository. + +## Prompt and settings + +- Reference frame 0. The point at the object's centre, in layer pixels (clip 1: `[90, 110]`). + The box, where used, is `[centre − 55, centre + 55]` on each axis. +- Track forward to the last frame (Frames After = clip length − 1, Frames Before = 0). +- Matte refinement at its defaults on both sides: Feather 0, Contrast 0, Shift Edge 0, no + Invert. Output the matte itself (View ▸ Matte in EffectCraft, the alpha channel in After + Effects) as a full-resolution PNG sequence. +- Record the After Effects build, the OS, the GPU, and the exact click position in After + Effects. It is a viewer click, so convert it to layer pixels with the comp's zoom. + +Keep the After Effects mattes in gitignored `plan/aftereffects/ref/object-matte/`. Only an +aggregate results file (like `keyframe-ease.json`) and the comparison script may be committed. + +## Metrics (per frame, then mean / worst) + +- **IoU against After Effects**: binarize both mattes at alpha 0.5, then intersection over + union. This is the headline number. `effectcraft_track::roto::iou` computes it, and + `roto.status` already reports it for Roto Brush through `compareTo`; Object Matte needs the + same for exported mattes. +- **IoU against ground truth** for the synthetic clips, for both applications. It tells + "differs from After Effects" apart from "worse than After Effects". +- **Boundary error**: the mean and 95th-percentile distance (px) between the two mattes' 0.5 + contours, which shows edge disagreement that IoU dilutes on large objects. +- **Soft-edge error**: the mean absolute alpha difference inside a 3 px band around the After + Effects contour. +- **Temporal stability**: the frame-to-frame IoU change of each matte on clip 1, where the + object moves rigidly, so jitter shows. +- **Drift**: the frame where IoU against ground truth first drops below 0.9 (clips 1 and 2). +- **Time**: seconds per frame to track the clip, warm and cold, on the same machine, with the + disk cache on and off. + +Proposed pass bar (a maintainer decision, not yet agreed): mean IoU against After Effects +≥ 0.95 and worst ≥ 0.90 on clips 1 and 3, with MobileSAM. With the classical engine the numbers +are reported but not gated. + +## Reproduce (to write) + +The script does not exist yet. It should: generate the synthetic clips; run EffectCraft +headless (`effectcraft-cli run … objectMatte.apply … objectMatte.propagate {"wait":true}`, then +render the matte); drive After Effects through ExtendScript or AEsync to apply Object Matte with +the same prompt and render its alpha; then compute the metrics above into a JSON results file. diff --git a/docs/gaps.md b/docs/gaps.md index 577f7bd6f..e9366c739 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -42,7 +42,7 @@ because 179 issues were closed with regression tests between 5 and 10 October. | Real-user experience | ≈ 60%, uneven by platform | (in G2) | 179 issues closed 5–10 October, nearly all with regression tests ([log below](#real-user-report-log-510-october)); 134 open, many from Linux and Windows. Most checking still happens on macOS | | Performance | unknown against After Effects, ≈ 35% | 50–100 | Internal numbers only (e.g. Advanced 3D 290 ms/frame at 1080p on the GPU on an M4 Pro). Users report stutter on cached frames (#595, #304), slow scrubbing (#329), late audio (#554). No 4K / hundreds-of-layers project tested | | Media formats | ≈ 75% | (in file formats) | H.264, ProRes, HEVC, AV1, VP9, image sequences and audio exist. The HEVC / AV1 encoders have no B-frames, multi-reference or SAO / CDEF, so files are larger than from mature encoders. Camera formats (BRAW, R3D, ProRes RAW, variable-frame-rate phone video) are unverified | -| AI-assisted tools | ≈ 35% | 40–80 | Roto Brush 2.0 / 3.0 with MobileSAM (M13.35) scores IoU 0.989 on the base frame of our synthetic moving-disc test and ≥ 0.980 over 20 propagated frames (classic: 0.973). Face tracking with MediaPipe Face Landmarker (M13.36) matches Google's own pipeline to 0.85 px on a test portrait. Neither is compared with After Effects; Object Matte (26.2) is missing | +| AI-assisted tools | ≈ 35% | 40–80 | Roto Brush 2.0 / 3.0 with MobileSAM (M13.35) scores IoU 0.989 on the base frame of our synthetic moving-disc test and ≥ 0.980 over 20 propagated frames (classic: 0.973). Face tracking with MediaPipe Face Landmarker (M13.36) matches Google's own pipeline to 0.85 px on a test portrait. Neither is compared with After Effects; Object Matte (26.2) has a first slice, not compared either | | Maturity | early | — | First commit 1 October 2026. ≈ 329,000 lines of almost entirely agent-written Rust, 2,704 tests, 313 GitHub issues. After Effects has around 30 years of edge cases behind it | ## Every known gap, ranked @@ -70,7 +70,7 @@ are grouped by area; the issue tracker has the rest. | 11 | **Text rendering and layout** | #453 CJK text in comps, #316 paragraph alignment inverted, #483 squeezed at 4:3, #417 soft at Fit, #583 font search, #550 toolbar font options | Text is in almost every motion graphic | 10–20 h | G2 · [ui-parity.md](ui-parity.md) | | 12 | **3D interaction and depth** | No 3D transform gizmos (#392, #347); Z position (#489); camera aperture (#260); reflections (#261); bevel options (#315); 3D issue reopened (#307) | Advanced 3D is After Effects' growth area since 2023 | 20–35 h | G2 · [ui-parity.md](ui-parity.md) | | 13 | **3D model formats and materials** | After Effects 26 imports FBX, USD/USDZ, STL besides glTF and OBJ (`usd_plugins`), and Substance 3D materials with mesh displacement (26.0–26.2); we read glTF and OBJ | Product shots and 3D scenes come as FBX/USDZ | 25–45 h | — · [file-format-parity.md](file-format-parity.md) | -| 14 | **Object Matte** (26.2, disk cache in 26.5) | First slice (#783): the Object Matte effect (Effect ▸ Matte) and `objectMatte.apply` turn a point (and optional box) on a reference frame into a matte and follow the object up to 600 frames each way, or segment every frame on its own; Roto Brush's engine, cache and model (MobileSAM from Settings ▸ Roto Brush, classical graph cut without it). Missing: the click / hover tool in the viewer, a background propagation job, the disk cache, comparison with After Effects | The new one-click roto | 10–20 h | G7 · [target-app-parity.md](target-app-parity.md) | +| 14 | **Object Matte** (26.2, disk cache in 26.5) | First slice (#783): the Object Matte effect (Effect ▸ Matte) and `objectMatte.apply` turn a point (and optional box) on a reference frame into a matte and follow the object up to 600 frames each way, or segment every frame on its own; Roto Brush's engine, cache and model (MobileSAM from Settings ▸ Roto Brush, classical graph cut without it). Since then: the Object Matte tool in the viewer (click / drag a box, hover crosshair), the box as the model's box prompt, background tracking with progress and cancel, Freeze, and segmentations kept in the disk cache (26.5). Missing: a comparison with After Effects ([plan](fidelity/object-matte.md)) | The new one-click roto | 10–20 h | G7 · [target-app-parity.md](target-app-parity.md) | | 15 | **Hardware video decode and encode** | No VideoToolbox, NVENC, Quick Sync or VA-API; #498 asks for it; FilmCraft has VideoToolbox / NVENC / VA-API paths to borrow | 4K playback and export speed | 25–45 h | G5/G6 · [hardware-parity.md](hardware-parity.md) | | 16 | **Localization depth** | Menus in 6 languages; panels in 4; effect names and ~1,650 effect parameter names untranslated; French, German, Korean, Italian, Russian (After Effects ships them) absent; #599 Hangul boxes on macOS | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | | 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists TGA, DPX, HDR, AVI, MXF; `media::is_importable` has none of them; #431 any image imports as a sequence; #324 MP3 goes silent | Confusing failures on common VFX formats (DPX, TGA) | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f0ed90b6e..c8ef95a02 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -122,7 +122,7 @@ compositing and effects dominate; audio is light). Hours are to full parity for | Text and typography | 7% | 94% | 50% | 20–35 | CJK text in comps renders wrongly (#453); paragraph alignment inverted (#316); squeezed text at 4:3 (#483); font search (#583); soft text at Fit (#417); variable-font filter (26.3) | | Effects and animation presets | 14% | 85% | 40% | 70–130 | All 306 effects exist, but rendered output is unmeasured against After Effects; 8 built-in presets against After Effects' 621; open effect bugs (#580 crash, #514, #526, #463, #369, #473); effect colour labels (26.5); see [effects-parity.md](effects-parity.md) | | 3D (Classic and Advanced) | 6% | 82% | 35% | 35–65 | No FBX/USD/STL models, no Substance materials, mesh displacement unverified; no 3D transform gizmos (#392, #347); Z position (#489); reflections (#261); camera aperture (#260); bevel options (#315) | -| Tracking and AI-assisted tools | 5% | 80% | 35% | 35–70 | Object Matte (26.2) is a first slice: an effect and command from a point / box prompt, no viewer tool or disk cache; Roto Brush (MobileSAM) and face tracking (MediaPipe) unmeasured against After Effects; camera-tracker view (#505) | +| Tracking and AI-assisted tools | 5% | 80% | 35% | 35–70 | Object Matte (26.2) has the effect, the viewer tool (click / box), background tracking, Freeze and the 26.5 disk cache, not yet compared with After Effects; Roto Brush (MobileSAM) and face tracking (MediaPipe) unmeasured against After Effects; camera-tracker view (#505) | | Preview and cache | 7% | 92% | 40% | 25–50 | Playback stutters on cached frames (#595, #304); slow scrubbing (#329); late audio (#554, #504, #335); no benchmark against After Effects | | Render queue and export | 7% | 85% | 55% | 25–45 | No hardware encoding; ProRes 4444 colour tag (#340); rendering over an existing file (#325); default macOS output location (#446); uncompressed single-file output (#372); HEVC/AV1 without B-frames | | Audio | 2% | 85% | 45% | 6–12 | Audio sync and crackle (#335); audio to keyframes depth | @@ -144,7 +144,7 @@ Feature depth (≈ 50%) feeds the overall *ready* number with the other dimensio | Stability | 45% | 40–80 | Open crash reports #580, #591, #501, #426, #341, #306, #234, #511; no CI runs the tests on pull requests | [gaps.md](gaps.md#g3-stability-and-a-green-main) | | Platforms | 65% | 30–60 | Releases for macOS, Windows x64/x86/ARM64, Linux, FreeBSD and the web (more than After Effects), but Linux and Windows users hit basic bugs | [gaps.md](gaps.md#g2-real-user-reliability-on-every-platform) | | Ecosystem and plug-ins | 20% | 40–80 | After Effects scripts and expressions mostly carry over; no After Effects SDK plug-ins, `.ffx` presets or `.mogrt`; our WebAssembly plug-in API has no third-party plug-ins yet | [plugins.md](plugins.md) | -| AI features | 35% | 40–80 (overlaps Tracking) | Roto Brush (MobileSAM) and face tracking (MediaPipe) as optional downloads; Object Matte (point / box prompt, same model) without its viewer tool or disk cache; the After Effects bundle ships Whisper, BERT and Marian tokenizers and a shot-cut model we have no equivalent of | [gaps.md](gaps.md) | +| AI features | 35% | 40–80 (overlaps Tracking) | Roto Brush (MobileSAM) and face tracking (MediaPipe) as optional downloads; Object Matte (point / box prompt, same model; viewer tool, background tracking, Freeze, disk cache) unmeasured against After Effects; the After Effects bundle ships Whisper, BERT and Marian tokenizers and a shot-cut model we have no equivalent of | [gaps.md](gaps.md) | | Agent control (beyond After Effects) | ahead | — | Every command is drivable over MCP, the control channel and the CLI | [agents.md](agents.md) | **Totals.** Summing the rows double-counts: AI overlaps Tracking, performance overlaps Preview, From eec81bbe0e424c2480e2b32060f4478e39a77dd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= <100806068+MatejKozlovsky@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:58:20 +0200 Subject: [PATCH 008/181] Render: an adjustment layer's footprint key ignores its effects, so a time-dependent effect (Noise) no longer re-keys it and the GPU re-uploads it every frame (#687) --- crates/gpu/src/tests.rs | 41 +++++++++++++++++++++++++ crates/render/src/cache.rs | 13 +++++--- crates/render/src/cache/dependencies.rs | 2 +- 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 784a393db..f7111cd45 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -857,3 +857,44 @@ fn readback_staging_stays_bounded_across_sizes() { let held = g.ctx.staging_bytes(); assert!(held <= (256 << 20) + 2 * 1024 * 16 * 376, "staging buffers hold {held} bytes"); } + +/// A static adjustment layer with a time-dependent effect (Noise) keeps its footprint key +/// across frames (the footprint is the source through the masks, not the effects), so the GPU +/// uploads nothing after the first frame; matches the CPU. +#[test] +fn adjustment_footprint_uploads_once_under_time_dependent_effects() { + let mut s = Scene::new(BitDepth::Bpc8); + let bg = s.solid([0.3, 0.5, 0.7], 96, 64); + s.push(bg); + let mut adj = s.solid([1.0, 1.0, 1.0], 80, 50); + adj.switches.adjustment = true; + s.effect(&mut adj, "ec.noise.noise", &[("amount", n(30.0))]); + s.push(adj); + // The key itself (no GPU needed): stable across time. + let comp = s.p.comp(s.cid).expect("the scene's comp"); + let adj = comp.layers.last().expect("the adjustment layer"); + let key = |k: f64| { + effectcraft_render::cache::footprint_key(&effectcraft_render::EvalCtx::new(&s.p, s.cid, comp, Tick::from_seconds_f64(k)), adj, 1.0, false, false) + }; + assert!(key(0.0).is_some()); + assert_eq!(key(0.0), key(0.5), "the footprint key changes with time"); + let Some(g) = gpu() else { return }; + let cache = effectcraft_render::cache::LayerCache::new(64 << 20); + let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() }); + r.accel = Some(g); + r.cache = Some(&cache); + let t = |k: u64| Tick::from_seconds_f64(k as f64 / 30.0); + let mut prev = None; + for k in 0..4 { + let before = g.ctx.transfer_stats().upload_bytes; + let img = g.render(&r, s.cid, t(k)).expect("the GPU renders the frame"); + let up = g.ctx.transfer_stats().upload_bytes - before; + if k >= 2 { + assert_eq!(up, 0, "frame {k} uploaded {up} bytes: the footprint was re-keyed"); + } + // Noise changes every frame: the frames differ. + assert!(prev.as_ref() != Some(&img.data), "frame {k} repeats the previous one"); + prev = Some(img.data); + check(&format!("noise adjustment frame {k}"), compare_at(&s, opts(), t(k)), 0.0); + } +} diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index 88fa5c7cc..bae12d1e3 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -423,8 +423,10 @@ fn hash_debug(h: &mut KeyHasher, v: &impl std::fmt::Debug) { /// Time-based layer content: does any part of the source/effects read the clock directly /// (rather than through property values)? -fn time_dependent(layer: &Layer) -> bool { - if layer.switches.effects +/// `effects: false` ignores the effects (a key of the source and masks only). +fn time_dependent(layer: &Layer, effects: bool) -> bool { + if effects + && layer.switches.effects && let Some(fx) = layer.effects() { for g in fx.groups() { @@ -522,7 +524,10 @@ fn has_expression(g: &PropGroup) -> bool { /// Cache key for an adjustment layer's footprint (its source through its masks, see /// `Renderer::adjustment_footprint`): the GPU compositor then uploads it once, not every frame. pub fn footprint_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool) -> Option { - Some(derive(key_any(ctx, layer, scale, draft, blur, false, None)?, 0xf007_9417)) + // The footprint is the source through the masks, without the effects: a time-dependent + // effect (Noise on a finishing adjustment layer) must not re-key it every frame (on the GPU, + // a full-frame re-upload per frame). + Some(derive(key_any(ctx, layer, scale, draft, blur, false, Some(0))?, 0xf007_9417)) } fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, footage: bool) -> Option { @@ -620,7 +625,7 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo effectcraft_effects::paint::cache_key(g, lt).0.hash(&mut h); } } - if time_dependent(layer) { + if time_dependent(layer, effects != Some(0)) { layer.layer_time(ctx.time).0.hash(&mut h); // Effects also see the layer's time mapping. layer.start_time.0.hash(&mut h); diff --git a/crates/render/src/cache/dependencies.rs b/crates/render/src/cache/dependencies.rs index 8ddee7a95..eaccd0582 100644 --- a/crates/render/src/cache/dependencies.rs +++ b/crates/render/src/cache/dependencies.rs @@ -78,7 +78,7 @@ impl Dependencies<'_> { hash_debug(self.h, layer); hash_values(self.h, ctx, layer, &layer.props); hash_debug(self.h, &ctx.world_matrix(layer)); - if super::time_dependent(layer) { + if super::time_dependent(layer, true) { layer.layer_time(ctx.time).0.hash(self.h); } match layer.source { From 9c5c1872928f3f9ec0ce6d1123bbe4e1ec78b63d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= <100806068+MatejKozlovsky@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:01:00 +0200 Subject: [PATCH 009/181] GPU: an Enc reuses the working textures it released at once instead of allocating new ones for every intermediate of a frame (#687) --- crates/gpu/src/context.rs | 69 ++++++++++++++++++++++++++++++++------- crates/gpu/src/tests.rs | 44 +++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 11 deletions(-) diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 11f28c70c..793c4d9ed 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -71,6 +71,8 @@ impl GpuImage { struct Lease { texture: wgpu::Texture, pool: Weak>, + /// The [`Enc`] that took it (see [`Pool::owned`]). + owner: u64, } impl std::fmt::Debug for Lease { @@ -84,7 +86,7 @@ impl Drop for Lease { if let Some(pool) = self.pool.upgrade() && let Ok(mut p) = pool.lock() { - p.release(self.texture.clone()); + p.release_owned(self.texture.clone(), self.owner); } } } @@ -108,8 +110,18 @@ pub(crate) struct Pool { /// Working textures reused / newly allocated (diagnostics, tests). hits: u64, misses: u64, + /// Textures released while the [`Enc`] that took them is alive, kept for that `Enc` only. + /// Its later commands run after everything it recorded or submitted before (one queue, + /// recording order), so it can overwrite them at once instead of allocating a new texture + /// for every intermediate of a frame (a 4K frame holds dozens). Other encoders never see a + /// working texture of another `Enc` before it is gone. + owned: HashMap>>, + next_owner: u64, } +/// In-`Enc` texture reuse ([`Pool::owned`]); tests turn it off to compare. +pub(crate) static TEXTURE_REUSE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); + /// Counts of uploads (CPU → GPU) and readbacks (GPU → CPU). #[derive(Default)] pub(crate) struct Transfers { @@ -178,6 +190,37 @@ impl Pool { } } + fn register(&mut self) -> u64 { + self.next_owner += 1; + if TEXTURE_REUSE.load(std::sync::atomic::Ordering::Relaxed) { + self.owned.insert(self.next_owner, HashMap::new()); + } + self.next_owner + } + + /// `Enc` `owner` is gone: its kept textures go through the shared pool. + fn unregister(&mut self, owner: u64) { + if let Some(m) = self.owned.remove(&owner) { + for t in m.into_values().flatten() { + self.release(t); + } + } + } + + fn release_owned(&mut self, t: wgpu::Texture, owner: u64) { + if let Some(m) = self.owned.get_mut(&owner) { + m.entry((t.width(), t.height())).or_default().push(t); + return; + } + self.release(t); + } + + fn take_owned(&mut self, owner: u64, w: u32, h: u32) -> Option { + let t = self.owned.get_mut(&owner)?.get_mut(&(w, h))?.pop()?; + self.hits += 1; + Some(t) + } + fn take(&mut self, w: u32, h: u32) -> Option { let t = self.free.get_mut(&(w, h)).and_then(Vec::pop); match &t { @@ -817,12 +860,12 @@ impl GpuContext { /// A pooled working texture: (image, reused). A reused texture holds old pixels; a new /// one is zeroed. - fn pooled(&self, w: u32, h: u32) -> (GpuImage, bool) { + fn pooled(&self, w: u32, h: u32, owner: u64) -> (GpuImage, bool) { let (w, h) = (w.max(1), h.max(1)); - let reused = self.pool.lock().ok().and_then(|mut p| p.take(w, h)); + let reused = self.pool.lock().ok().and_then(|mut p| p.take_owned(owner, w, h).or_else(|| p.take(w, h))); let was_reused = reused.is_some(); let texture = reused.unwrap_or_else(|| self.texture(w, h)); - let lease = Arc::new(Lease { texture: texture.clone(), pool: Arc::downgrade(&self.pool) }); + let lease = Arc::new(Lease { texture: texture.clone(), pool: Arc::downgrade(&self.pool), owner }); (GpuImage { texture, width: w, height: h, _lease: Some(lease), levels: None }, was_reused) } @@ -923,22 +966,26 @@ pub(crate) struct Enc<'g> { pending: usize, /// A dispatch named a kernel the device doesn't have (see [`Enc::take_missing`]). missing: bool, + /// This `Enc`'s id in the texture pool (textures it may reuse at once). + owner: u64, } impl Drop for Enc<'_> { fn drop(&mut self) { // Unsubmitted work is discarded, so nothing recorded will read pooled textures. - if let Some((_, id)) = self.enc.take() - && let Ok(mut p) = self.g.pool.lock() - { - p.close(id); + if let Ok(mut p) = self.g.pool.lock() { + if let Some((_, id)) = self.enc.take() { + p.close(id); + } + p.unregister(self.owner); } } } impl<'g> Enc<'g> { pub fn new(g: &'g GpuContext) -> Enc<'g> { - Enc { g, enc: None, pending: 0, missing: false } + let owner = g.pool.lock().map(|mut p| p.register()).unwrap_or(0); + Enc { g, enc: None, pending: 0, missing: false, owner } } /// Whether a dispatch since the last call named a kernel the device doesn't have (one @@ -960,7 +1007,7 @@ impl<'g> Enc<'g> { /// A new zeroed image (pooled; a reused texture is cleared by a kernel). pub fn image(&mut self, w: u32, h: u32) -> GpuImage { - let (img, reused) = self.g.pooled(w, h); + let (img, reused) = self.g.pooled(w, h, self.owner); if reused { self.fill(&img, 0.0); } @@ -970,7 +1017,7 @@ impl<'g> Enc<'g> { /// A new image for a kernel that writes every pixel of it (pooled; contents undefined). /// Under test it starts out as NaNs, so a kernel that skips pixels fails the CPU oracle. pub fn scratch(&mut self, w: u32, h: u32) -> GpuImage { - let (img, _) = self.g.pooled(w, h); + let (img, _) = self.g.pooled(w, h, self.owner); if cfg!(test) { self.fill(&img, f32::NAN); } diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index f7111cd45..fcebcd44b 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -898,3 +898,47 @@ fn adjustment_footprint_uploads_once_under_time_dependent_effects() { check(&format!("noise adjustment frame {k}"), compare_at(&s, opts(), t(k)), 0.0); } } + +/// An `Enc` reuses a working texture it released at once (`Pool::owned`); another `Enc` does +/// not get it while the first is alive. +#[test] +fn enc_reuses_its_own_released_textures() { + let Some(g) = gpu() else { return }; + let mut e = crate::context::Enc::new(&g.ctx); + let a = e.image(37, 23); + let ta = a.texture.clone(); + drop(a); + let mut other = crate::context::Enc::new(&g.ctx); + let o = other.image(37, 23); + assert!(o.texture != ta, "another Enc got a texture the first may still be using"); + let b = e.image(37, 23); + assert!(b.texture == ta, "the Enc allocated a new texture instead of reusing its own"); +} + +/// In-`Enc` texture reuse changes no pixel: a busy frame (layers, effects, adjustment layer, +/// nested comp) renders bit-identically with reuse on and off, and matches the CPU. +#[test] +fn texture_reuse_does_not_change_output() { + let Some(g) = gpu() else { return }; + let mut s = Scene::new(BitDepth::Bpc32); + for i in 0..4 { + let mut l = s.footage(90 - i * 7, 60 - i * 5); + s.effect(&mut l, "ec.blur.gaussian", &[("blurriness", n(2.0 + i as f64))]); + set(&mut l, "transform/rotation", Value::Scalar(10.0 * i as f64)); + l.blend_mode = if i % 2 == 0 { BlendMode::Screen } else { BlendMode::Multiply }; + s.push(l); + } + let mut adj = s.solid([1.0, 1.0, 1.0], 80, 50); + adj.switches.adjustment = true; + s.effect(&mut adj, "ec.color.levels", &[("gamma", n(1.2))]); + s.push(adj); + let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() }); + r.accel = Some(g); + let t = Tick::from_seconds_f64(0.3); + let on = g.render(&r, s.cid, t).expect("the GPU renders the frame"); + crate::context::TEXTURE_REUSE.store(false, std::sync::atomic::Ordering::Relaxed); + let off = g.render(&r, s.cid, t); + crate::context::TEXTURE_REUSE.store(true, std::sync::atomic::Ordering::Relaxed); + assert!(off.is_some_and(|off| off.data == on.data), "texture reuse changed the frame"); + check("texture reuse vs CPU", compare_at(&s, opts(), t), 0.0); +} From d8ff1189dcd1177f30601d506f297e92daac983f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= <100806068+MatejKozlovsky@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:05:38 +0200 Subject: [PATCH 010/181] GPU: the upload cache budget can be set per context and warns once when full, instead of evicting layer textures silently (#687) --- crates/gpu/src/context.rs | 29 ++++++++++++++++++++++++++--- crates/gpu/src/tests.rs | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 793c4d9ed..51221abc3 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -37,7 +37,7 @@ const EXT_ENTRIES: &[&str] = &["classic3d"]; /// 8/16 bpc are emulated by clamping and quantising, as on the CPU). pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float; -/// Uploaded layer buffers kept on the GPU (bytes). +/// Uploaded layer buffers kept on the GPU by default (bytes; [`GpuContext::set_upload_budget`]). const UPLOAD_BUDGET: usize = 1536 << 20; /// Working textures kept for reuse once their last image is dropped (bytes). @@ -296,6 +296,8 @@ pub struct GpuContext { dummy_tex: wgpu::TextureView, dummy_buf: wgpu::Buffer, uploads: Mutex, + upload_budget: std::sync::atomic::AtomicUsize, + upload_full_warned: std::sync::atomic::AtomicBool, pool: Arc>, /// CPU ↔ GPU traffic (diagnostics: `bench --gpu`). pub(crate) transfers: Transfers, @@ -641,6 +643,8 @@ impl GpuContext { dummy, dummy_buf, uploads: Mutex::new(Uploads::default()), + upload_budget: std::sync::atomic::AtomicUsize::new(UPLOAD_BUDGET), + upload_full_warned: Default::default(), pool: Default::default(), staging: Default::default(), transfers: Default::default(), @@ -869,6 +873,17 @@ impl GpuContext { (GpuImage { texture, width: w, height: h, _lease: Some(lease), levels: None }, was_reused) } + /// Bytes of uploaded layer textures the context keeps (default 1536 MB). An application that + /// knows the device's memory can raise it (static layers of a big frame upload once) or lower + /// it (a small GPU); the least recently used textures go first. + pub fn set_upload_budget(&self, bytes: usize) { + self.upload_budget.store(bytes.max(1 << 20), std::sync::atomic::Ordering::Relaxed); + } + + pub fn upload_budget(&self) -> usize { + self.upload_budget.load(std::sync::atomic::Ordering::Relaxed) + } + /// Uploads and readbacks so far (images; uploads of cached layers count once). pub fn transfer_stats(&self) -> TransferStats { use std::sync::atomic::Ordering::Relaxed; @@ -925,7 +940,8 @@ impl GpuContext { } u.bytes += bytes; // Drop textures of freed buffers, then the least recently used over budget. - if u.bytes > UPLOAD_BUDGET { + let budget = self.upload_budget(); + if u.bytes > budget { let dead: Vec = u.map.iter().filter(|(_, e)| e.buf.strong_count() == 0).map(|(k, _)| *k).collect(); for k in dead { if let Some(e) = u.map.remove(&k) { @@ -933,7 +949,14 @@ impl GpuContext { } } } - while u.bytes > UPLOAD_BUDGET { + if u.bytes > budget && !self.upload_full_warned.swap(true, std::sync::atomic::Ordering::Relaxed) { + log::warn!( + target: "effectcraft_gpu", + "GPU upload cache full ({} MB): evicting layer textures, which upload again when next used; a frame whose static layers exceed it re-uploads them every frame", + budget >> 20 + ); + } + while u.bytes > budget { let Some(k) = u.map.iter().min_by_key(|(_, e)| e.last_use).map(|(k, _)| *k) else { break }; if let Some(e) = u.map.remove(&k) { u.bytes -= e.bytes; diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index fcebcd44b..426899c76 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -942,3 +942,40 @@ fn texture_reuse_does_not_change_output() { assert!(off.is_some_and(|off| off.data == on.data), "texture reuse changed the frame"); check("texture reuse vs CPU", compare_at(&s, opts(), t), 0.0); } + +/// Static layer buffers (here adjustment-layer footprints) upload once while they fit the upload budget, and upload again every frame +/// when they do not (least recently used first); the frame is the same either way. +#[test] +fn upload_budget_bounds_the_layer_textures_kept() { + let Some(g) = gpu() else { return }; + let mut s = Scene::new(BitDepth::Bpc8); + for (i, c) in [[0.8, 0.2, 0.1], [0.1, 0.3, 0.9]].into_iter().enumerate() { + let mut l = s.solid(c, 200, 200); + set(&mut l, "transform/position", v3(60.0 + 30.0 * i as f64, 40.0)); + // Adjustment layers: their footprints are cached layer buffers the GPU uploads. + l.switches.adjustment = true; + s.effect(&mut l, "ec.color.levels", &[("gamma", n(1.1 + 0.2 * i as f64))]); + s.push(l); + } + let frame = |cache: &effectcraft_render::cache::LayerCache, k: u64| { + let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() }); + r.accel = Some(g); + r.cache = Some(cache); + let before = g.ctx.transfer_stats().upload_bytes; + let img = g.render(&r, s.cid, Tick::from_seconds_f64(k as f64 / 30.0)).expect("the GPU renders the frame"); + (img.data, g.ctx.transfer_stats().upload_bytes - before) + }; + let fits = effectcraft_render::cache::LayerCache::new(64 << 20); + let (a, _) = frame(&fits, 0); + let (b, up) = frame(&fits, 1); + assert_eq!(up, 0, "static layers re-uploaded within the default budget"); + // A budget smaller than the two 640 KB footprints: each upload evicts the other. + let default = g.ctx.upload_budget(); + g.ctx.set_upload_budget(1 << 20); + let tight = effectcraft_render::cache::LayerCache::new(64 << 20); + let _ = frame(&tight, 0); + let (c, up) = frame(&tight, 1); + g.ctx.set_upload_budget(default); + assert!(up > 0, "over budget, the footprints must upload again"); + assert!(a == b && b == c, "the upload budget changed the frame"); +} From bc6a145203ae3ccac504748808232765cde1c229 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= <100806068+MatejKozlovsky@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:09:22 +0200 Subject: [PATCH 011/181] GPU: a layer's effect stack runs resident on the GPU and is placed directly, instead of a readback and re-upload around every GPU effect run (#687) --- crates/gpu/src/tests.rs | 65 ++++++++++++++++++++++++++++++++++++++++ crates/gpu/src/walk.rs | 41 +++++++++++++++++++++++++ crates/render/src/lib.rs | 13 ++++++++ 3 files changed, 119 insertions(+) diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 426899c76..696402d6a 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -979,3 +979,68 @@ fn upload_budget_bounds_the_layer_textures_kept() { assert!(up > 0, "over budget, the footprints must upload again"); assert!(a == b && b == c, "the upload budget changed the frame"); } + +/// Layers with effect stacks run them resident on the GPU (`walk::draw_resident_fx`): GPU +/// effects, a CPU-only effect in the middle (read back, run, upload), opacity and a blend mode, +/// a shape layer whose effects run in effect space from its bounds' corner; 8 and 32 bpc, full +/// and half resolution; all match the CPU. +#[test] +fn effect_stacks_render_resident() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = Scene::new(depth); + let bg = s.footage(97, 61); + s.push(bg); + let mut a = s.footage(70, 44); + s.effect(&mut a, "ec.blur.gaussian", &[("blurriness", n(4.0))]); + s.effect(&mut a, "ec.generate.fractal", &[]); + s.effect(&mut a, "ec.color.levels", &[("gamma", n(1.4))]); + set(&mut a, "transform/rotation", Value::Scalar(20.0)); + set(&mut a, "transform/opacity", Value::Scalar(70.0)); + a.blend_mode = BlendMode::Overlay; + s.push(a); + let mut l = build::layer(&mut s.p, &s.comp, "Shape", LayerSource::Shape, (97, 61), None); + let mut next = s.p.next_id; + let mut ids = Ids(&mut next); + let rect = build::shape_rect(&mut ids, [50.0, 30.0], [5.0, -4.0], 0.0); + let fill = build::shape_fill(&mut ids, [0.2, 0.8, 0.3, 1.0]); + let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]); + s.p.next_id = next; + l.props.sub_mut("contents").expect("a shape layer has contents").children.push(g.into()); + s.effect(&mut l, "ec.blur.gaussian", &[("blurriness", n(3.0))]); + s.effect(&mut l, "ec.stylize.mosaic", &[]); + s.push(l); + let t = Tick::from_seconds_f64(0.2); + check(&format!("effect stacks {depth:?}"), compare_at(&s, opts(), t), 0.0); + check(&format!("effect stacks half {depth:?}"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, t), 0.0); + } +} + +/// A layer whose effects give the same result as last frame draws from the layer cache (its +/// processed buffer uploads once) instead of re-running the stack on the GPU every frame. +#[test] +fn static_effect_layers_draw_from_the_cache() { + let Some(g) = gpu() else { return }; + let mut s = Scene::new(BitDepth::Bpc8); + let mut l = s.solid([0.6, 0.4, 0.2], 80, 50); + s.effect(&mut l, "ec.blur.gaussian", &[("blurriness", n(3.0))]); + s.push(l); + let cache = effectcraft_render::cache::LayerCache::new(64 << 20); + let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() }); + r.accel = Some(g); + r.cache = Some(&cache); + let comp = s.p.comp(s.cid).expect("the scene's comp"); + let layer = comp.layers.last().expect("the layer"); + let t = |k: u64| Tick::from_seconds_f64(k as f64 / 30.0); + assert!(r.layer_is_static(&effectcraft_render::EvalCtx::new(&s.p, s.cid, comp, t(2)), layer)); + let mut frames = Vec::new(); + for k in 0..3 { + let before = g.ctx.transfer_stats().upload_bytes; + frames.push(g.render(&r, s.cid, t(k)).expect("the GPU renders the frame").data); + let up = g.ctx.transfer_stats().upload_bytes - before; + if k == 2 { + assert_eq!(up, 0, "the cached layer uploaded again"); + } + } + assert!(frames[1] == frames[2]); + check("static effect layer vs CPU", compare_at(&s, opts(), t(2)), 0.0); +} diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index 83cb85507..d0c017321 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -217,6 +217,9 @@ fn draw_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: & if let Some(done) = draw_precomp(e, r, ctx, layer, canvas, opacity) { return done; } + if let Some(done) = draw_resident_fx(e, r, ctx, layer, canvas, opacity) { + return done; + } let Some(st) = r.styled_layer(ctx, layer) else { return Some(()) }; if st.plain { return composite_layer(e, r, ctx, layer, &st.body, canvas, opacity); @@ -246,6 +249,44 @@ fn draw_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: & Some(()) } +/// A plain layer with effects, its effect stack run on the GPU: the source (+ masks, from the +/// layer cache) uploads once while unchanged, the effects stay resident and the result is +/// placed directly; the CPU path instead runs each GPU-capable effect run through an +/// upload → kernels → readback, then uploads the result again to place it. `None`: not this +/// kind of layer (draw it the usual way); `Some(None)`: the GPU failed. +fn draw_resident_fx(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut GpuImage, opacity: f32) -> Option> { + if !layer.switches.effects + || layer.effects().is_none_or(|f| f.children.is_empty()) + || styles::active(ctx, layer) + || layer.preserve_transparency + || r.track_matte(ctx, layer).is_some() + // Unchanged since the last frame: the cached result uploads once (the usual path). + || r.layer_is_static(ctx, layer) + { + return None; + } + let input = r.layer_input(ctx, layer, 0)?; + if input.img.is_empty() { + return Some(Some(())); + } + let Some(src) = e.g.upload_buf(&input) else { return Some(None) }; + let pipe = r.pipe(); + // `layer_input` is in effect space: run the stack there, then back to layer space. + let mut fx = crate::effects::GpuFx::new(e, src, input.offset, input.scale, pipe.levels); + let origin = r.run_layer_effects_on(ctx, layer, &mut fx); + let Some((mut img, mut offset, scale)) = fx.finish() else { return Some(None) }; + offset = [offset[0] - origin[0] * scale, offset[1] - origin[1] * scale]; + if img.width == 0 || img.height == 0 { + return Some(Some(())); + } + if let Some(c) = pipe.to_blend() { + img = ops::convert(e, &img, &c); + } + // The placement only reads the buffer's size, offset and scale. + let shape = Buf { img: Image { width: img.width, height: img.height, data: Vec::new() }, offset, scale }; + Some(place_src(e, r, ctx, layer, &shape, img, canvas, layer.blend_mode, opacity, pipe.levels).map(|c| *canvas = c)) +} + /// A 2D precomp layer, its nested comp rendered resident on the GPU (`walk::render` one level /// down) instead of by the CPU compositor and uploaded every frame (`Renderer::layer_source` /// renders nested comps on the CPU: depth > 0). Then the layer's effects (resident), its diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a77b7c04c..b15fc0199 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -761,6 +761,19 @@ impl<'a> Renderer<'a> { /// always continuously rasterised, and Continuously Rasterize shape layers and vector footage — /// the output scale times the layer's on-screen scale (quarter-octave steps, rounded up, so /// vector content is drawn at its final size and stays sharp when scaled up). + /// Whether `layer`'s processed buffer (source → masks → effects) is cached and the same as + /// one frame earlier: drawing it from the layer cache (an accelerator uploads it once) beats + /// re-running its effects every frame. + pub fn layer_is_static(&self, ctx: &EvalCtx, layer: &Layer) -> bool { + if self.cache.is_none() { + return false; + } + 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) + } + pub fn raster_scale(&self, ctx: &EvalCtx, layer: &Layer) -> f64 { let s = self.opts.scale; let continuous = match layer.source { From ea5e629d1aa8ca1ab9a5995ae288b1672607a8cb Mon Sep 17 00:00:00 2001 From: Diego Ospina Date: Mon, 12 Oct 2026 04:29:09 -0500 Subject: [PATCH 012/181] i18n(es): Spanish panels, dialogs and tooltips 506 rows, the Chinese catalogs' source list, read by catalog("es"); a test pins it to that list and checks a few lookups. Models: Opus 5.5, Sol 6.1, DeepSeek V4 Flash --- crates/ui-egui/src/i18n/ui.rs | 574 +++++++++++++++++++++++++++++++++- 1 file changed, 572 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 4c6374efc..1006a2dd5 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -19,7 +19,7 @@ use std::fmt::Display; use std::sync::OnceLock; thread_local! { - /// The resolved interface language ("en", "ja", "zh-hans", "zh-hant", "uk", "pt-br"); "en" until the first + /// The resolved interface language ("en", "ja", "zh-hans", "zh-hant", "uk", "pt-br", "es"); "en" until the first /// frame sets it. static CURRENT: Cell<&'static str> = const { Cell::new("en") }; } @@ -45,12 +45,14 @@ fn catalog(language: &str) -> Option<&'static Index> { static HANT: OnceLock = OnceLock::new(); static UK: OnceLock = OnceLock::new(); static PT_BR: OnceLock = OnceLock::new(); + static ES: OnceLock = OnceLock::new(); let (index, rows) = match language { "ja" => (&JA, JAPANESE), "zh-hans" => (&HANS, SIMPLIFIED_CHINESE), "zh-hant" => (&HANT, TRADITIONAL_CHINESE), "uk" => (&UK, UKRAINIAN), "pt-br" => (&PT_BR, PORTUGUESE), + "es" => (&ES, SPANISH), _ => return None, }; Some(index.get_or_init(|| rows.iter().copied().collect())) @@ -2580,6 +2582,557 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("⚠ {} is used by:", "⚠ {} é usado por:"), ]; +/// Spanish: neutral, tú, sentence case; the same source strings as the Chinese catalogs, in the same +/// order. Terms follow the Spanish menu catalog (composición, material de archivo, cola de procesamiento, +/// ajuste preestablecido, método abreviado). Written from the English source. +const SPANISH: &[(&str, &str)] = &[ + ("Appearance Settings…", "Ajustes de apariencia…"), + ("Add to favorites", "Añadir a favoritos"), + ("Remove from favorites", "Quitar de favoritos"), + (" OK ", " Aceptar "), + (" Delete ", " Eliminar "), + (" Open Auto-Save ", " Abrir guardado automático "), + ("(missing)", "(ausente)"), + ("360 View Orientation", "Orientación de la vista 360°"), + ("3:2 Pulldown: {}", "Pulldown 3:2: {}"), + ( + "A clean-room, open-source compositor for motion graphics and visual effects: native on macOS, Windows and Linux, and in the browser. Part of the ArtCraft family of creative apps.", + "Un compositor de código abierto y desarrollado en sala limpia para gráficos en movimiento y efectos visuales: nativo en macOS, Windows y Linux, y en el navegador. Forma parte de la familia de aplicaciones creativas ArtCraft.", + ), + ("About EffectCraft", "Acerca de EffectCraft"), + ("About...", "Acerca de..."), + ("Active", "Activo"), + ("Active Camera", "Cámara activa"), + ("Alpha", "Alfa"), + ("Adapt Feature On Every Frame", "Adaptar la característica en cada fotograma"), + ("Adaptive Sample Limit:", "Límite de muestras adaptativas:"), + ("Add Animator", "Añadir animador"), + ("Add Comment", "Añadir comentario"), + ("Add Formatting", "Añadir formato"), + ("Add Group", "Añadir grupo"), + ("Add Mirror", "Añadir espejo"), + ("Add Selected As", "Añadir seleccionados como"), + ("Add a comment", "Añadir un comentario"), + ("Add a shape, fill, stroke or path operation", "Añadir una forma, relleno, trazo u operación de trazado"), + ("Add to Composition", "Añadir a la composición"), + ("Add to Favorites", "Añadir a favoritos"), + ("Add:", "Añadir:"), + ("Adjust Exposure (stops)", "Ajustar exposición (pasos)"), + ( + "Adjust composition duration to the time span of the selected layers", + "Ajustar la duración de la composición al intervalo de tiempo de las capas seleccionadas", + ), + ("Advanced 3D", "3D avanzado"), + ("Align Layers to:", "Alinear capas con:"), + ("All six face cameras turn together; the scene is not changed.", "Las seis cámaras de las caras giran juntas; la escena no cambia."), + ("Anchor:", "Punto de anclaje:"), + ("Aperture", "Abertura"), + ("Appearance Mode", "Modo de apariencia"), + ("Appearance Mode: {}", "Modo de apariencia: {}"), + ("Apply Dimensions:", "Aplicar dimensiones:"), + ("Apply Motion To:", "Aplicar movimiento a:"), + ("Arrange", "Organizar"), + ("Assign", "Asignar"), + ("Auto", "Automático"), + ("Auto-saves", "Guardados automáticos"), + ("Back", "Atrás"), + ("Background Color:", "Color de fondo:"), + ("Blend Mode:", "Modo de fusión:"), + ("Blending Mode", "Modo de fusión"), + ("Blur", "Desenfoque"), + ("Blur Level", "Nivel de desenfoque"), + ("Boundary", "Límite"), + ("Browser Storage", "Almacenamiento del navegador"), + ("Browse…", "Examinar…"), + ("Brush Dynamics", "Dinámica del pincel"), + ("Caching frames…", "Almacenando fotogramas en caché…"), + ("Cancel", "Cancelar"), + ("Casts Shadows", "Proyecta sombras"), + ("Change Renderer…", "Cambiar motor de procesamiento…"), + ( + "Changing a shortcut creates a custom preset; the default stays as it is.", + "Cambiar un método abreviado crea un ajuste preestablecido personalizado; el predeterminado no cambia.", + ), + ("Channel", "Canal"), + ("Channel:", "Canal:"), + ("Chapter:", "Capítulo:"), + ("Choose...", "Elegir..."), + ("Classic 3D", "3D clásico"), + ("Clear", "Borrar"), + ("Clear list", "Borrar lista"), + ("Click a key to see its commands.", "Haz clic en una tecla para ver sus comandos."), + ( + "Click above the bar to add an opacity stop, below it to add a color stop.", + "Haz clic encima de la barra para añadir una parada de opacidad; debajo, para añadir una parada de color.", + ), + ("Click adds a point (½ height = no change)", "Al hacer clic se añade un punto (½ altura = sin cambios)"), + ("Click adds a point; drag one off to remove", "Al hacer clic se añade un punto; arrastra uno fuera para quitarlo"), + ("Click to make this viewer active", "Haz clic para activar este visor"), + ("Click to reveal the expression in the timeline", "Haz clic para mostrar la expresión en la cronología"), + ("Click to show the active composition here", "Haz clic para mostrar la composición activa aquí"), + ("Click, then click in the Composition panel", "Haz clic y, a continuación, haz clic en el panel Composición"), + ("Clone Options", "Opciones de clonación"), + ("Clone Source Overlay:", "Superposición del origen del clon:"), + ("Close", "Cerrar"), + ("Close Panel", "Cerrar panel"), + ("Close tutorial", "Cerrar tutorial"), + ("Color", "Color"), + ("Color Depth: {}", "Profundidad de color: {}"), + ("Color:", "Color:"), + ("Colour map", "Mapa de color"), + ("Columns", "Columnas"), + ("Comment:", "Comentario:"), + ("Community", "Comunidad"), + ("Composition", "Composición"), + ("Composition Mini-Flowchart", "Minidiagrama de flujo de la composición"), + ("Composition Name:", "Nombre de la composición:"), + ("Composition Settings ▸ 3D Renderer", "Configuración de la composición ▸ Motor de procesamiento 3D"), + ("Composition Settings…", "Configuración de la composición…"), + ("Composition marker bin: drag a marker to the time ruler", "Contenedor de marcadores de composición: arrastra un marcador a la regla de tiempo"), + ("Cone Angle", "Ángulo del cono"), + ("Cone Feather", "Calado del cono"), + ("Constrain Proportions", "Restringir proporciones"), + ("Continuous (Lock Outgoing to Incoming)", "Continuo (igualar la velocidad de salida a la de entrada)"), + ("Cue Point", "Punto de referencia"), + ("Current Render", "Procesamiento actual"), + ("Dark", "Oscuro"), + ("Dark Theme", "Tema oscuro"), + ("Darker", "Más oscuro"), + ("Default Output:", "Salida predeterminada:"), + ("Default: {}", "Predeterminado: {}"), + ("Defaults", "Predeterminados"), + ("Delete", "Eliminar"), + ("Delete Selected Points", "Eliminar puntos seleccionados"), + ("Delete selected project items", "Eliminar elementos del proyecto seleccionados"), + ("Delete this template", "Eliminar esta plantilla"), + ("Depth of Field", "Profundidad de campo"), + ("Discord", "Discord"), + ("Distribute Layers:", "Distribuir capas:"), + ("Don't Recover", "No recuperar"), + ("Don't Save", "No guardar"), + ("Double-click a layer to open it in the Layer panel", "Haz doble clic en una capa para abrirla en el panel Capa"), + ("Double-click footage in the Project panel to open it here", "Haz doble clic en el material de archivo del panel Proyecto para abrirlo aquí"), + ("Double-click to edit the comment", "Haz doble clic para editar el comentario"), + ("Download", "Descargar"), + ( + "Drag properties here from the timeline,\nor use Animation > Add Property to Essential Graphics.", + "Arrastra propiedades aquí desde la cronología,\no usa Animación > Añadir propiedad a Gráficos esenciales.", + ), + ("Drag to change", "Arrastra para cambiar"), + ("Drag to create a guide", "Arrastra para crear una guía"), + ("Drag to slip the source", "Arrastra para deslizar el origen"), + ("Drop Frame", "Drop frame"), + ("Duplicate", "Duplicar"), + ("Duration:", "Duración:"), + ("Edit Gradient…", "Editar degradado…"), + ("Edit Target: {}", "Destino de la edición: {}"), + ("Edit in the Timeline", "Editar en la cronología"), + ("EffectCraft didn't quit normally last time.", "EffectCraft no se cerró normalmente la última vez."), + ("Effects & Presets options", "Opciones de Efectos y ajustes preestablecidos"), + ("Effects: {}", "Efectos: {}"), + ("Empty folder", "Carpeta vacía"), + ("Enable Depth of Field", "Habilitar profundidad de campo"), + ("Enable Expression", "Habilitar expresión"), + ("Enhance", "Mejorar"), + ("Essential Properties — {}", "Propiedades esenciales — {}"), + ("Event", "Evento"), + ("Export Template…", "Exportar plantilla…"), + ("Export...", "Exportar..."), + ("Expression Language Menu", "Menú de lenguaje de expresiones"), + ("Expression pick whip: drag onto a property", "Selector de expresión: arrastra hasta una propiedad"), + ("Expression:", "Expresión:"), + ("Eyedropper: click, then click in the Composition panel", "Cuentagotas: haz clic y, a continuación, haz clic en el panel Composición"), + ("Falloff", "Atenuación"), + ("Falloff Distance", "Distancia de atenuación"), + ("Favorites", "Favoritos"), + ("Field Render: {}", "Procesamiento de campos: {}"), + ("Figures", "Cifras"), + ("Fill Target", "Destino del relleno"), + ("Finished", "Finalizado"), + ("Flowchart: {}", "Diagrama de flujo: {}"), + ("Focus Distance", "Distancia de enfoque"), + ("Follow Feature Rotation and Scale", "Seguir la rotación y la escala de la característica"), + ("Footage: {}", "Material de archivo: {}"), + ("Format: {}", "Formato: {}"), + ("Frame Aspect Ratio: {}", "Relación de aspecto del fotograma: {}"), + ("Frame Blending: {}", "Fusión de fotogramas: {}"), + ("Frame Rate:", "Velocidad de fotogramas:"), + ("Frame Render Time {}ms", "Tiempo de procesamiento del fotograma {}ms"), + ("Frame Target:", "Marco de destino:"), + ("Freeze", "Congelar"), + ("Grab bag", "Varios"), + ("Gradient Editor", "Editor de degradados"), + ("Grid", "Cuadrícula"), + ("Guide Layers: {}", "Capas de guía: {}"), + ("Guides", "Guías"), + ("Height:", "Altura:"), + ("Hide This", "Ocultar esto"), + ("Histogram", "Histograma"), + ("Hold In Place", "Mantener en el lugar"), + ("Home", "Inicio"), + ("If Confidence is Below", "Si la confianza es inferior a"), + ("Import", "Importar"), + ("Import and Add to Composition", "Importar y añadir a la composición"), + ("Import...", "Importar..."), + ("Imported media", "Medios importados"), + ("Influence:", "Influencia:"), + ("Install from File…", "Instalar desde archivo…"), + ("Intensity", "Intensidad"), + ("Inverted", "Invertida"), + ("Join the ArtCraft community on Discord", "Únete a la comunidad de ArtCraft en Discord"), + ("Key: {}", "Tecla: {}"), + ("LEARN", "APRENDER"), + ("Label", "Etiqueta"), + ("Label Colors and Names:", "Colores y nombres de etiquetas:"), + ("Label:", "Etiqueta:"), + ("Latest auto-save: {}", "Último guardado automático: {}"), + ("Layer", "Capa"), + ("Layer controls", "Controles de capa"), + ("Layer {}", "Capa {}"), + ("Layer:", "Capa:"), + ("Layer: {}", "Capa: {}"), + ("Learn", "Aprender"), + ("Leave all attributes in \"{}\"", "Dejar todos los atributos en \"{}\""), + ("Licence: {}", "Licencia: {}"), + ("Ligatures", "Ligaduras"), + ("Light", "Claro"), + ("Light Theme", "Tema claro"), + ("Light Type", "Tipo de luz"), + ("Lighter", "Más claro"), + ("Link Selected Property", "Vincular propiedad seleccionada"), + ("Location:", "Ubicación:"), + ("Lock Aspect Ratio to {}", "Bloquear relación de aspecto en {}"), + ("Lock to Zoom", "Bloquear al zoom"), + ("Locked: {} — {}", "Bloqueado: {} — {}"), + ("Log:", "Registro:"), + ("Loop (Trace Path)", "Bucle (trazar trazado)"), + ("MIT OR Apache-2.0 • Fonts: Inter, JetBrains Mono (OFL)", "MIT OR Apache-2.0 • Fuentes: Inter, JetBrains Mono (OFL)"), + ("Magnification ratio popup", "Menú de proporción de ampliación"), + ("Make Comp Size", "Ajustar al tamaño de la composición"), + ("Masks", "Máscaras"), + ("Maximize Panel", "Maximizar panel"), + ("Media Replacement", "Sustitución de medios"), + ("Mesh:", "Malla:"), + ("Diameter:", "Diámetro:"), + ("Method Used: {} Average Error: {} pixels", "Método usado: {} Error promedio: {} píxeles"), + ("Models folder: {}", "Carpeta de modelos: {}"), + ("Modifiers:", "Modificadores:"), + ("More", "Más"), + ("More ArtCraft apps", "Más aplicaciones de ArtCraft"), + ("More tutorials", "Más tutoriales"), + ("Motion Blur", "Desenfoque de movimiento"), + ("Motion Blur: {}", "Desenfoque de movimiento: {}"), + ("Motion Sketch: drag to record", "Boceto de movimiento: arrastra para grabar"), + ("Move Down", "Mover hacia abajo"), + ("Move Up", "Mover hacia arriba"), + ("Move to Project Root", "Mover a la raíz del proyecto"), + ("Name", "Nombre"), + ("Name:", "Nombre:"), + ("Navigation", "Navegación"), + ("New", "Nuevo"), + ("New Comp from Selection", "Nueva composición a partir de la selección"), + ("New Duration:", "Nueva duración:"), + ("New composition name:", "Nombre de la nueva composición:"), + ("New from Render Queue item:", "Nuevo a partir del elemento de la cola de procesamiento:"), + ("New from Template", "Nuevo a partir de plantilla"), + ("Next", "Siguiente"), + ("Nice work. Pick another tutorial on the Home screen's Learn tab.", "Buen trabajo. Elige otro tutorial en la pestaña Aprender de la pantalla de inicio."), + ( + "No VR environments yet. Create one with Composition ▸ VR ▸ Create VR Environment…", + "Aún no hay entornos de RV. Crea uno con Composición ▸ RV ▸ Crear entorno de RV…", + ), + ("No audio layer selected", "No hay ninguna capa de audio seleccionada"), + ("No background jobs running", "No hay tareas en segundo plano en ejecución"), + ("No composition markers", "No hay marcadores de composición"), + ("No contributor data was built into this copy.", "No se incluyeron datos de colaboradores en esta copia."), + ( + "No device list from this host; the system default output is used.", + "Este host no proporcionó una lista de dispositivos; se usa la salida predeterminada del sistema.", + ), + ("No layer selected", "No hay ninguna capa seleccionada"), + ("No matching effects or presets", "No hay efectos ni ajustes preestablecidos que coincidan"), + ("No matching fonts", "No hay fuentes coincidentes"), + ("Variable fonts only", "Solo fuentes variables"), + ("No model credits were built into this copy.", "No se incluyeron créditos de modelos en esta copia."), + ("No nested compositions", "No hay composiciones anidadas"), + ("No shortcut", "Sin método abreviado"), + ("No unlocked layer selected", "No hay ninguna capa desbloqueada seleccionada"), + ("No video", "Sin vídeo"), + ("None", "Ninguno"), + ("Not applied: tick it in the list to add it.", "No aplicado: márcalo en la lista para añadirlo."), + ("Not assigned", "Sin asignar"), + ("Note: Shadows are only cast from layers with Cast Shadows enabled.", "Nota: Las sombras solo se proyectan desde capas con Proyectar sombras activado."), + ("Notify", "Notificar"), + ("OK", "Aceptar"), + ("Offset:", "Desplazamiento:"), + ("On ({}) stop:", "Al parar ({}):"), + ("One-Node Camera", "Cámara de un nodo"), + ("Opacity:", "Opacidad:"), + ("Open Composition", "Abrir composición"), + ("Open Last Saved Project", "Abrir el último proyecto guardado"), + ("Open New Composition", "Abrir nueva composición"), + ("Open Output", "Abrir salida"), + ("Open a composition to build Essential Graphics", "Abre una composición para crear Gráficos esenciales"), + ("Open a composition to see its scopes", "Abre una composición para ver sus ámbitos"), + ("Open a composition to track motion.", "Abre una composición para seguir movimiento."), + ("Opus Tuning: {}", "Ajuste de Opus: {}"), + ("Original Duration:", "Duración original:"), + ("Output Cycle: {}", "Ciclo de salida: {}"), + ("Output To:", "Salida a:"), + ("Overlay", "Superposición"), + ("Parameter Name:", "Nombre del parámetro:"), + ("Parent pick whip: drag onto a layer", "Selector de capa padre: arrastra hasta una capa"), + ("Pixel Aspect Ratio:", "Relación de aspecto de píxel:"), + ("Playing on Video Preview", "Reproduciendo en la previsualización de vídeo"), + ("Post-Render Action: {}", "Acción posterior al procesamiento: {}"), + ("Preserve frame rate when nested or in render queue", "Conservar la velocidad de fotogramas cuando se anida o está en la cola de procesamiento"), + ("Preserve resolution when nested", "Conservar la resolución al anidar"), + ("Preset", "Ajuste preestablecido"), + ("Preset:", "Ajuste preestablecido:"), + ("Presets", "Ajustes preestablecidos"), + ("Previous", "Anterior"), + ("Primary:", "Principal:"), + ("Process Before Match", "Procesar antes de buscar coincidencias"), + ("Profile: {}", "Perfil: {}"), + ( + "Project color depth: click to cycle 8/16/32 bpc, Alt-click for Project Settings", + "Profundidad de color del proyecto: haz clic para alternar entre 8/16/32 bpc; Alt+clic para la configuración del proyecto", + ), + ("Project: {}", "Proyecto: {}"), + ("Projects", "Proyectos"), + ("Property pick whip: drag onto a property", "Selector de propiedad: arrastra hasta una propiedad"), + ("Protected Region", "Región protegida"), + ("Proxy Use: {}", "Uso de proxy: {}"), + ("Push All to Comp", "Enviar todo a la composición"), + ("RGB", "RGB"), + ("Radius", "Radio"), + ("Rate Control: {}", "Control de tasa: {}"), + ("Recent", "Recientes"), + ("Recording", "Grabando"), + ("Remove", "Quitar"), + ("Rename", "Cambiar nombre"), + ("Render: {} ms • UI {} fps{}", "Procesamiento: {} ms • IU {} fps{}"), + ("Renderer:", "Motor de procesamiento:"), + ("Request Persistent Storage", "Solicitar almacenamiento persistente"), + ("Reset", "Restablecer"), + ("Reset Labels", "Restablecer etiquetas"), + ("Reset Preset", "Restablecer ajuste preestablecido"), + ("Reset to Default", "Restablecer valores predeterminados"), + ("Reset to Saved Layout", "Restablecer al diseño guardado"), + ("Resolution/Down Sample Factor Popup", "Menú emergente de resolución/factor de reducción de muestreo"), + ("Restore Panel Size", "Restaurar tamaño del panel"), + ("Reverse the order", "Invertir el orden"), + ("Revert All", "Revertir todo"), + ("Roman Hanging Punctuation", "Puntuación colgante latina"), + ("Roving:", "Itinerante:"), + ("Ruler zero point (drag to move, double-click to reset)", "Punto cero de la regla (arrastra para mover, haz doble clic para restablecer)"), + ("Rulers", "Reglas"), + ("Run", "Ejecutar"), + ("Sample Format: {}", "Formato de muestra: {}"), + ("Samples Per Frame:", "Muestras por fotograma:"), + ("Save from #{}", "Guardar desde #{}"), + ("Saved {}", "Guardado {}"), + ("Script… (⌘/Ctrl+Enter runs)", "Script… (⌘/Ctrl+Enter ejecuta)"), + ("Search commands or shortcuts", "Buscar comandos o métodos abreviados"), + ("Select", "Seleccionar"), + ("Select a command to change its shortcut.", "Selecciona un comando para cambiar su método abreviado."), + ("Select a layer to see its effects", "Selecciona una capa para ver sus efectos"), + ("Select a layer to see its properties", "Selecciona una capa para ver sus propiedades"), + ("({}) Selected Objects", "({}) objetos seleccionados"), + ( + "Select a layer with a mask or shape path (or the path property), or Puppet pins.", + "Selecciona una capa con una máscara o un trazado de forma (o la propiedad de trazado), o pines de marioneta.", + ), + ("Select a text layer", "Selecciona una capa de texto"), + ("Select an item in the Project panel", "Selecciona un elemento en el panel Proyecto"), + ("Select animated properties to show them in the Graph Editor", "Selecciona propiedades animadas para mostrarlas en el editor de gráficos"), + ( + "Select the leader pin, then Shift-click the pins that should trail it (hair, cloth, tails)", + "Selecciona el pin guía y luego haz Mayús+clic en los pines que deben seguirlo (pelo, tela, colas)", + ), + ("Set Ground Plane and Origin", "Definir plano de suelo y origen"), + ("Set Paint Colors to Black and White (D)", "Definir colores de pintura como negro y blanco (D)"), + ("Settings", "Ajustes"), + ("Settings Name:", "Nombre de los ajustes:"), + ("Shadow Darkness", "Oscuridad de la sombra"), + ("Shadow Diffusion", "Difusión de la sombra"), + ("Shadows", "Sombras"), + ("Shape…", "Forma…"), + ("Show", "Mostrar"), + ("Show Layer Name", "Mostrar nombre de la capa"), + ("Show Source Name", "Mostrar nombre del origen"), + ("Show me", "Mostrar cómo hacerlo"), + ("Show:", "Mostrar:"), + ("Shutter Angle:", "Ángulo del obturador:"), + ("Shutter Phase:", "Fase del obturador:"), + ("Size", "Tamaño"), + ("Skip", "Omitir"), + ("Snapping", "Ajuste"), + ("Snapping options", "Opciones de ajuste"), + ("Snapshot", "Instantánea"), + ("Solo Supported Properties", "Mostrar solo las propiedades compatibles"), + ("Solo Switches: {}", "Conmutadores de solo: {}"), + ("Sort", "Ordenar"), + ("Spacing:", "Espaciado:"), + ("Spatial Interpolation:", "Interpolación espacial:"), + ("Speed:", "Velocidad:"), + ("Start Timecode:", "Código de tiempo inicial:"), + ("Start: {} Duration: {}", "Inicio: {} Duración: {}"), + ("Step {} of {}: {}", "Paso {} de {}: {}"), + ("Stop", "Detener"), + ("Stop {} at {}°", "Parada {} en {}°"), + ("Stored in: {}", "Guardado en: {}"), + ("Stretch", "Estirar"), + ("Stretch Factor:", "Factor de estiramiento:"), + ("Stylistic Sets", "Conjuntos estilísticos"), + ("Subpixel Positioning", "Posicionamiento de subpíxeles"), + ("Swap Paint Colors (X)", "Intercambiar colores de pintura (X)"), + ("Sync with System", "Sincronizar con el sistema"), + ("System Default", "Predeterminado del sistema"), + ("Table", "Tabla"), + ("Template name", "Nombre de la plantilla"), + ("Template: {}\n{}", "Plantilla: {}\n{}"), + ("Temporal Interpolation:", "Interpolación temporal:"), + ("The Media Browser is not available here (use File ▸ Import)", "El Navegador de medios no está disponible aquí (usa Archivo ▸ Importar)"), + ("The OpenEXR layer shown in red, green, blue and alpha", "La capa de OpenEXR mostrada en rojo, verde, azul y alfa"), + ("The render queue is empty.", "La cola de procesamiento está vacía."), + ( + "These adjacent keyframes cannot interpolate because their stop counts differ.", + "Estos fotogramas clave adyacentes no pueden interpolarse porque sus números de paradas son distintos.", + ), + ("This ScriptUI panel has closed", "Este panel de ScriptUI se ha cerrado"), + ("This change will create a new solid for this layer.", "Este cambio creará un nuevo sólido para esta capa."), + ("Time", "Tiempo"), + ("Time Stretch…", "Estirar el tiempo…"), + ("Time:", "Tiempo:"), + ("Time: {}", "Tiempo: {}"), + ("Title/Action Safe", "Área segura de título/acción"), + ("Title/action safe", "Área segura de título/acción"), + ("Toggle Switches / Modes", "Alternar conmutadores / modos"), + ("Track Name:", "Nombre del seguimiento:"), + ( + "Trained models are available in the desktop app; the browser uses the classic engine.", + "Los modelos entrenados están disponibles en la aplicación de escritorio; el navegador usa el motor clásico.", + ), + ("Transform", "Transformar"), + ("Transparency grid", "Cuadrícula de transparencia"), + ("Tutorial complete", "Tutorial completado"), + ("Two-Node Camera", "Cámara de dos nodos"), + ("Type", "Tipo"), + ("Type a command or effect…", "Escribe un comando o efecto…"), + ("URL:", "URL:"), + ("Undock Panel", "Desacoplar panel"), + ("Move Panel to New Window", "Mover panel a una ventana nueva"), + ("Move Panel to Main Window", "Mover panel a la ventana principal"), + ("Unfreeze", "Descongelar"), + ("Unlink {}", "Desvincular {}"), + ( + "Use this option to place the currently selected layers together into a new intermediate composition.", + "Usa esta opción para colocar las capas seleccionadas juntas en una nueva composición intermedia.", + ), + ("VR Environments", "Entornos de RV"), + ("Value:", "Valor:"), + ("Variable Font Axes", "Ejes de fuente variable"), + ("Video Codec: {}", "Códec de vídeo: {}"), + ("View:", "Vista:"), + ("Welcome to EffectCraft", "Te damos la bienvenida a EffectCraft"), + ("Width:", "Ancho:"), + ("You can undo this.", "Puedes deshacer esto."), + ("Your changes will be lost if you don't save them.", "Tus cambios se perderán si no los guardas."), + ("Zoom", "Zoom"), + ("built-in", "integrado"), + ("frames per second", "fotogramas por segundo"), + ("template name", "nombre de plantilla"), + ("{} contributors · {} commits", "{} colaboradores · {} commits"), + ("{} x {}, {}MB per 8bpc frame", "{} x {}, {}MB por fotograma de 8 bpc"), + ("{} — coming soon", "{} — próximamente"), + ("{} — double-click to open, {}-click to reveal", "{} — doble clic para abrir, clic con {} para mostrar"), + ("⌘/Ctrl-drag a pin to record its motion", "⌘/Ctrl + arrastra un pin para grabar su movimiento"), + ("▣ {}", "▣ {}"), + ("⚠ {} is used by:", "⚠ {} se usa en:"), + ("3D Reference Axes", "Ejes de referencia 3D"), + ("Affect all layers that use this solid", "Afectar a todas las capas que usan este sólido"), + ("Choose grid and guide options", "Elegir opciones de cuadrícula y guías"), + ("Colorize", "Colorear"), + ("Custom", "Personalizado"), + ("Custom...", "Personalizada..."), + ("Fit", "Ajustar"), + ("Fit up to 100%", "Ajustar hasta el 100%"), + ("Full", "Completa"), + ("Half", "Mitad"), + ("Only this layer uses this solid.", "Solo esta capa usa este sólido."), + ("Proportional Grid", "Cuadrícula proporcional"), + ("Quarter", "Cuarto"), + ("Region of Interest", "Región de interés"), + ("Reset Exposure", "Restablecer exposición"), + ("Show Channel and Color Management Settings", "Mostrar ajustes de canal y gestión del color"), + ("Show Snapshot (hold)", "Mostrar instantánea (mantener)"), + ("Take Snapshot", "Tomar instantánea"), + ("Third", "Tercio"), + ("Toggle Mask and Shape Path Visibility", "Alternar visibilidad de máscara y trazado de forma"), + ("Toggle Transparency Grid", "Alternar cuadrícula de transparencia"), + ("16 bits per channel", "16 bits por canal"), + ("32 bits per channel (float)", "32 bits por canal (flotante)"), + ("8 bits per channel", "8 bits por canal"), + ("Adaptive Resolution", "Resolución adaptable"), + ("Adobe-style built-in", "Integrado al estilo Adobe"), + ("Blue", "Azul"), + ("Current Settings", "Ajustes actuales"), + ("Disable Per-character 3D", "Deshabilitar 3D por carácter"), + ("Draft", "Borrador"), + ("Enable Per-character 3D", "Habilitar 3D por carácter"), + ("Fast Draft", "Borrador rápido"), + ("Fast Previews (Draft 3D, Faster Effects)", "Previsualizaciones rápidas (borrador 3D, efectos más rápidos)"), + ("Green", "Verde"), + ("Import Image Sequence", "Importar secuencia de imágenes"), + ("Import PDF / Illustrator File", "Importar archivo PDF / Illustrator"), + ("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"), + ("OCIO (built-in ACES config)", "OCIO (configuración ACES integrada)"), + ("Off (Final Quality)", "Desactivado (calidad final)"), + ("OpenGL (compatibility)", "OpenGL (compatibilidad)"), + ("Opus Bitrate: {} kbps", "Tasa de bits de Opus: {} kbps"), + ("RGB Straight", "RGB sin premultiplicar"), + ("Red", "Rojo"), + ("Tone map", "Mapeo de tonos"), + ("Use In-Window Menu Bar on macOS", "Usar la barra de menús en la ventana en macOS"), + ("Wireframe", "Estructura alámbrica"), + ("sRGB IEC61966-2.1 (default)", "sRGB IEC61966-2.1 (predeterminado)"), + ("New Project", "Nuevo proyecto"), + ("Open Project…", "Abrir proyecto…"), + ("Templates", "Plantillas"), + ("EffectCraft home page", "Página de inicio de EffectCraft"), + ("EffectCraft on GitHub", "EffectCraft en GitHub"), + ("Motion graphics and visual effects · version {}", "Gráficos en movimiento y efectos visuales · versión {}"), + ("New Composition", "Nueva composición"), + ("Open Demo Project", "Abrir proyecto de demostración"), + ("Import Footage…", "Importar material de archivo…"), + ("Filter recent files", "Filtrar archivos recientes"), + ("NAME", "NOMBRE"), + ("OPENED", "ABIERTO"), + ("SIZE", "TAMAÑO"), + ("KIND", "TIPO"), + ("Your recent files will show here", "Tus archivos recientes aparecerán aquí"), + ("Create a new project, or open the demo project to look around.", "Crea un proyecto nuevo o abre el proyecto de demostración para explorar."), + ("No recent files match the filter", "Ningún archivo reciente coincide con el filtro"), + ("Try another name or folder.", "Prueba con otro nombre o carpeta."), + ("Just now", "Ahora mismo"), + ("Yesterday", "Ayer"), + ("{} minute ago", "Hace {} minuto"), + ("{} minutes ago", "Hace {} minutos"), + ("{} hour ago", "Hace {} hora"), + ("{} hours ago", "Hace {} horas"), + ("{} day ago", "Hace {} día"), + ("{} days ago", "Hace {} días"), + ("EffectCraft project", "Proyecto de EffectCraft"), + ("After Effects project", "Proyecto de After Effects"), + ("Lottie animation", "Animación de Lottie"), + ("Project", "Proyecto"), +]; + #[cfg(test)] mod tests { use super::*; @@ -2680,6 +3233,7 @@ mod tests { .chain(TRADITIONAL_CHINESE.iter().copied()) .chain(UKRAINIAN.iter().copied()) .chain(PORTUGUESE.iter().copied()) + .chain(SPANISH.iter().copied()) .collect() } @@ -2691,7 +3245,7 @@ mod tests { let hant: Vec<&str> = TRADITIONAL_CHINESE.iter().map(|(en, _)| *en).collect(); assert_eq!(hans, hant, "the two Chinese catalogs list different source strings"); // Japanese and Ukrainian may lag the Chinese catalogs: a missing row falls back to English. - for (name, cat) in [("Japanese", JAPANESE), ("Ukrainian", UKRAINIAN), ("Brazilian Portuguese", PORTUGUESE)] { + for (name, cat) in [("Japanese", JAPANESE), ("Ukrainian", UKRAINIAN), ("Brazilian Portuguese", PORTUGUESE), ("Spanish", SPANISH)] { let extra: Vec<&str> = cat.iter().map(|(en, _)| *en).filter(|en| !hans.contains(en)).collect(); assert!(extra.is_empty(), "the {name} catalog lists source strings the Chinese ones don't: {extra:?}"); } @@ -2749,6 +3303,22 @@ mod tests { assert_eq!(tr("Cancel"), "Cancel"); } + /// Spanish panels translate, fill values in order and keep project content; every Chinese source + /// string has a Spanish row. + #[test] + fn spanish_panels_translate_and_keep_project_content() { + let es: Vec<&str> = SPANISH.iter().map(|(en, _)| *en).collect(); + let hans: Vec<&str> = SIMPLIFIED_CHINESE.iter().map(|(en, _)| *en).collect(); + assert_eq!(es, hans, "the Spanish catalog lists different source strings than the Chinese ones"); + set_current("es"); + assert_eq!(tr("Composition Name:"), "Nombre de la composición:"); + assert_eq!(tr("Cancel"), "Cancelar"); + assert_eq!(tr("my layer.mp4"), "my layer.mp4"); + assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "Paso 1 de 3: Colour"); + set_current("en"); + assert_eq!(tr("Cancel"), "Cancel"); + } + /// Values fill the placeholders in order, and a missing value is dropped instead of panicking. #[test] fn tr_args_fills_values_in_order() { From 649ba2e8cf215e8672b1bddcca15fc96a033b019 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Mon, 12 Oct 2026 17:40:15 +0700 Subject: [PATCH 013/181] Desktop: an unknown option or a bad --control port stops the launch (#997) The desktop app imported any argument it didn't know as a file (writing it to Recent Footage) and dropped a missing or non-numeric --control value, starting without a control server. parse_args now returns an error for an unknown option, a --control value that isn't a port from 1 to 65535, or a bad EFFECTCRAFT_CONTROL_PORT (checked only when --control isn't given); main prints it and exits with status 1 before anything starts. --control= works, and -- ends the options so a file named -x can still be opened. Tests: parse_args unit tests, and tests/args.rs runs the binary with its own settings folder and checks the exit, the message and that nothing was written. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/effectcraft/src/main.rs | 123 +++++++++++++++++++++++++++++---- apps/effectcraft/tests/args.rs | 73 +++++++++++++++++++ docs/control-protocol.md | 3 + 3 files changed, 185 insertions(+), 14 deletions(-) create mode 100644 apps/effectcraft/tests/args.rs diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 83015f1a2..f5ad80633 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -1,6 +1,8 @@ //! EffectCraft desktop app. //! -//! Usage: `effectcraft [--control ] [--demo|--empty|--home] [project.ecproj | media files…]` +//! Usage: `effectcraft [--control ] [--demo|--empty|--home] [--] [project.ecproj | media files…]` +//! +//! An unknown option or a bad port prints an error and exits with status 1 before anything starts. //! //! Without a project the app starts with an empty Untitled Project, as After Effects does; //! `--demo` opens the demo project instead (also Help ▸ Open Demo Project). @@ -70,22 +72,51 @@ struct Args { home: Option, } -/// Parse the arguments after the program name; `None` for `--version`. `env_port` is -/// `EFFECTCRAFT_CONTROL_PORT`. -fn parse_args(args: impl IntoIterator, env_port: Option) -> Option { - let mut out = Args { control_port: env_port.and_then(|p| p.parse().ok()), ..Default::default() }; +/// Parse the arguments after the program name; `Ok(None)` for `--version`. `env_port` is +/// `EFFECTCRAFT_CONTROL_PORT`, used when `--control` isn't given. An unknown option or a bad port +/// is an error, so it is neither imported as a file nor dropped; after `--` every argument is a file. +fn parse_args(args: impl IntoIterator, env_port: Result) -> Result, String> { + let mut out = Args::default(); let mut args = args.into_iter(); + let mut options = true; while let Some(a) = args.next() { + if !options { + out.files.push(a); + continue; + } match a.as_str() { - "--control" => out.control_port = args.next().and_then(|p| p.parse().ok()), + "--" => options = false, + "--control" => out.control_port = Some(control_port("--control", args.next().as_deref())?), "--demo" => out.demo = true, "--empty" => out.demo = false, "--home" => out.home = Some(true), - "--version" => return None, - _ => out.files.push(a), + "--version" => return Ok(None), + _ => match a.strip_prefix("--control=") { + Some(v) => out.control_port = Some(control_port("--control", Some(v))?), + None if a.starts_with('-') && a != "-" => return Err(format!("unknown option `{a}`")), + None => out.files.push(a), + }, } } - Some(out) + if out.control_port.is_none() { + out.control_port = match env_port { + Ok(v) if v.is_empty() => None, + Ok(v) => Some(control_port("EFFECTCRAFT_CONTROL_PORT", Some(&v))?), + Err(std::env::VarError::NotPresent) => None, + Err(std::env::VarError::NotUnicode(v)) => Some(control_port("EFFECTCRAFT_CONTROL_PORT", Some(&v.to_string_lossy()))?), + }; + } + Ok(Some(out)) +} + +/// A control-channel port from 1 to 65535 (0 would listen on a port nobody is told); `name` is +/// the flag or variable it came from. +fn control_port(name: &str, value: Option<&str>) -> Result { + let need = format!("{name} needs a port number from 1 to 65535"); + match value { + None => Err(need), + Some(v) => v.parse().ok().filter(|&p| p != 0).ok_or(format!("{need}, not `{v}`")), + } } fn main() -> eframe::Result { @@ -94,9 +125,17 @@ fn main() -> eframe::Result { // feed the compatibility report. effectcraft_engine::logging::install(); effectcraft_engine::logging::install_panic_hook(); - let Some(Args { control_port, files, demo, home }) = parse_args(std::env::args().skip(1), std::env::var("EFFECTCRAFT_CONTROL_PORT").ok()) else { - println!("effectcraft {}", env!("CARGO_PKG_VERSION")); - return Ok(()); + let Args { control_port, files, demo, home } = match parse_args(std::env::args().skip(1), std::env::var("EFFECTCRAFT_CONTROL_PORT")) { + Ok(Some(args)) => args, + Ok(None) => { + println!("effectcraft {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + // Before the session log, preferences and the window: nothing has started yet. + Err(e) => { + eprintln!("effectcraft: {e}"); + std::process::exit(1); + } }; // 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 @@ -425,9 +464,18 @@ fn disable_app_nap() { #[cfg(test)] mod tests { use super::{Args, parse_args}; + use std::env::VarError; + + fn parse_env(args: &[&str], env_port: Result) -> Result, String> { + parse_args(args.iter().map(|a| a.to_string()), env_port) + } fn parse(args: &[&str]) -> Option { - parse_args(args.iter().map(|a| a.to_string()), None) + parse_env(args, Err(VarError::NotPresent)).unwrap() + } + + fn fails(args: &[&str]) -> String { + parse_env(args, Err(VarError::NotPresent)).unwrap_err() } #[cfg(target_os = "linux")] @@ -477,6 +525,53 @@ mod tests { let a = parse(&["--control", "9877", "--home", "a.ecproj"]).unwrap_or_default(); assert_eq!((a.control_port, a.home, a.files), (Some(9877), Some(true), vec!["a.ecproj".to_string()])); assert_eq!(parse(&["--version"]), None); - assert_eq!(parse_args(Vec::new(), Some("9000".into())).map(|a| a.control_port), Some(Some(9000))); + assert_eq!(parse_env(&[], Ok("9000".into())).unwrap().map(|a| a.control_port), Some(Some(9000))); + } + + /// A missing, non-numeric or zero `--control` value stops the launch instead of starting + /// without a control server, and the argument after `--control` isn't reinterpreted (#997). + #[test] + fn control_needs_a_port_from_1_to_65535() { + let need = "--control needs a port number from 1 to 65535"; + assert_eq!(fails(&["--control"]), need); + assert_eq!(fails(&["--control", "--demo"]), format!("{need}, not `--demo`")); + assert_eq!(fails(&["--control", "--version"]), format!("{need}, not `--version`")); + for bad in ["abc", "0", "70000", "-1", ""] { + assert_eq!(fails(&["--control", bad]), format!("{need}, not `{bad}`")); + assert_eq!(fails(&[&format!("--control={bad}")]), format!("{need}, not `{bad}`")); + } + assert_eq!(parse(&["--control=9877"]).map(|a| a.control_port), Some(Some(9877))); + assert_eq!(parse(&["--control", "65535"]).map(|a| a.control_port), Some(Some(65535))); + } + + /// An unknown option is an error, not a file to import (#997); `--` ends the options. + #[test] + fn unknown_options_are_errors_not_files() { + for bad in ["--automation-read-root", "--automation-write-root=/tmp", "-h", "--Demo", "--demo=yes"] { + assert_eq!(fails(&[bad, "/tmp/folder"]), format!("unknown option `{bad}`")); + } + // Parsed left to right: an error before `--version` wins. + assert_eq!(fails(&["--nope", "--version"]), "unknown option `--nope`"); + let a = parse(&["clip.mov", "-", "--", "--demo", "-name.png"]).unwrap_or_default(); + assert_eq!(a.files, ["clip.mov", "-", "--demo", "-name.png"]); + assert!(!a.demo); + } + + /// `--control` wins over `EFFECTCRAFT_CONTROL_PORT`; the variable is checked only when it is + /// used, so a bad value doesn't stop `--control` or `--version`. + #[test] + fn control_port_variable_is_checked_when_used() { + let need = "EFFECTCRAFT_CONTROL_PORT needs a port number from 1 to 65535"; + assert_eq!(parse_env(&[], Ok("abc".into())).unwrap_err(), format!("{need}, not `abc`")); + assert_eq!(parse_env(&[], Ok("0".into())).unwrap_err(), format!("{need}, not `0`")); + assert_eq!(parse_env(&[], Ok(String::new())), Ok(Some(Args::default()))); + assert_eq!(parse_env(&["--control", "9877"], Ok("abc".into())).unwrap().map(|a| a.control_port), Some(Some(9877))); + assert_eq!(parse_env(&["--version"], Ok("abc".into())), Ok(None)); + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + let not_utf8 = VarError::NotUnicode(std::ffi::OsString::from_vec(vec![0x39, 0xff])); + assert_eq!(parse_env(&[], Err(not_utf8)).unwrap_err(), format!("{need}, not `9\u{fffd}`")); + } } } diff --git a/apps/effectcraft/tests/args.rs b/apps/effectcraft/tests/args.rs new file mode 100644 index 000000000..8367d2a99 --- /dev/null +++ b/apps/effectcraft/tests/args.rs @@ -0,0 +1,73 @@ +//! The desktop binary rejects a bad command line before it starts anything (#997). +// Integration tests: unwrapping and panicking on failure is fine here. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +/// A settings folder of its own, so a launch that got past the arguments would show up as +/// preferences (Recent Footage) written there instead of in the user's settings. +fn config_dir(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("ec-args-test-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// Runs the app, which must exit at once with `effectcraft: ` and a failure status. A +/// regression fails instead of leaving a window open: the process is killed after 5 s. +fn rejects(name: &str, args: &[&str], env: &[(&str, &str)], message: &str) { + let config = config_dir(name); + let mut cmd = Command::new(env!("CARGO_BIN_EXE_effectcraft")); + cmd.args(args).env_remove("EFFECTCRAFT_CONTROL_PORT").env_remove("RUST_LOG").env("EFFECTCRAFT_CONFIG_DIR", &config); + for (k, v) in env { + cmd.env(k, v); + } + let mut child = cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + let timed_out = loop { + if child.try_wait().unwrap().is_some() { + break false; + } + if Instant::now() >= deadline { + child.kill().unwrap(); + break true; + } + std::thread::sleep(Duration::from_millis(10)); + }; + let out = child.wait_with_output().unwrap(); + assert!(!timed_out, "{args:?}: the app started or did not exit promptly"); + assert_eq!(out.status.code(), Some(1), "{args:?}"); + assert!(out.stdout.is_empty(), "{args:?}: {:?}", String::from_utf8_lossy(&out.stdout)); + assert_eq!(String::from_utf8(out.stderr).unwrap(), format!("effectcraft: {message}\n"), "{args:?}"); + let written: Vec<_> = std::fs::read_dir(&config).unwrap().collect(); + assert!(written.is_empty(), "{args:?}: wrote {written:?}"); + let _ = std::fs::remove_dir_all(&config); +} + +#[test] +fn unknown_options_stop_the_launch_before_anything_is_imported() { + let folder = config_dir("folder"); + rejects("read-root", &["--control", "47613", "--automation-read-root", folder.to_str().unwrap()], &[], "unknown option `--automation-read-root`"); + rejects("write-root", &["--automation-write-root=/tmp"], &[], "unknown option `--automation-write-root=/tmp`"); + rejects("short", &["-h"], &[], "unknown option `-h`"); + let _ = std::fs::remove_dir_all(&folder); +} + +#[test] +fn a_bad_control_port_stops_the_launch() { + let need = "--control needs a port number from 1 to 65535"; + rejects("missing", &["--control"], &[], need); + rejects("flag", &["--control", "--demo"], &[], &format!("{need}, not `--demo`")); + rejects("text", &["--control", "abc"], &[], &format!("{need}, not `abc`")); + rejects("zero", &["--control=0"], &[], &format!("{need}, not `0`")); + rejects("env", &[], &[("EFFECTCRAFT_CONTROL_PORT", "abc")], "EFFECTCRAFT_CONTROL_PORT needs a port number from 1 to 65535, not `abc`"); +} + +#[test] +fn version_still_prints_with_a_bad_port_variable() { + let out = Command::new(env!("CARGO_BIN_EXE_effectcraft")).arg("--version").env("EFFECTCRAFT_CONTROL_PORT", "abc").output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("effectcraft {}\n", env!("CARGO_PKG_VERSION"))); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f54879817..187542745 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -6,6 +6,9 @@ The desktop app exposes a control channel for agents, tests and the MCP bridge: effectcraft --control 9877 # or EFFECTCRAFT_CONTROL_PORT=9877 ``` +The port must be a number from 1 to 65535, and `--control` overrides the variable. A bad port or an +unknown option prints an error and the app exits with status 1 before it opens a window. + - **Transport:** TCP on `127.0.0.1` only, one JSON object per line in each direction. Connections are persistent; requests on one connection are answered in order. - **Request:** `{"id": 1, "method": "engine.execute", "params": {...}}` (`id` is echoed back, `params` From 15385121b1d27f844521f2be8bc4ae903711d0d1 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Mon, 12 Oct 2026 17:40:15 +0700 Subject: [PATCH 014/181] Contributors: Nael-Nathanael's entry (GitHub profile name) --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 6a5351215..e06c94b51 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -64,3 +64,6 @@ sync_github_name = true [people.webdevtodayjason] display_name = "Jason Brashear" + +[people.Nael-Nathanael] +sync_github_name = true From d9b8f65d7cc563ce8fb57f6b3695e264caa3dc03 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 13:23:06 +0200 Subject: [PATCH 015/181] Render Queue: the failure toast says why the render failed (#568) --- crates/engine/src/render_queue.rs | 13 ++++++++++--- crates/engine/src/rq_tests.rs | 26 +++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs index aae05a383..56754e7c1 100644 --- a/crates/engine/src/render_queue.rs +++ b/crates/engine/src/render_queue.rs @@ -610,10 +610,17 @@ impl Session { let _ = t.join(); } let st = job.shared.snapshot(); - let failed = updates.iter().filter(|u| matches!(u, ItemUpdate::Finished { status: RenderStatus::Failed(_), .. })).count(); + let failures: Vec<&str> = updates + .iter() + .filter_map(|u| if let ItemUpdate::Finished { status: RenderStatus::Failed(e), .. } = u { Some(e.as_str()) } else { None }) + .collect(); + let failed = failures.len(); let stopped = updates.iter().any(|u| matches!(u, ItemUpdate::Finished { status: RenderStatus::UserStopped, .. })); - let msg = if failed > 0 { - format!("Render finished with {failed} failed item(s)") + // The reason is otherwise only in the Status column's tooltip (#568). + let msg = if let [only] = failures.as_slice() { + format!("Render finished with 1 failed item(s): {only}") + } else if let Some(first) = failures.first() { + format!("Render finished with {failed} failed item(s), first: {first}") } else if stopped { "Render stopped".to_string() } else { diff --git a/crates/engine/src/rq_tests.rs b/crates/engine/src/rq_tests.rs index 88c7a6e8e..81a187226 100644 --- a/crates/engine/src/rq_tests.rs +++ b/crates/engine/src/rq_tests.rs @@ -87,7 +87,9 @@ fn add_configure_render() { assert_eq!(std::path::Path::new(q[0].last_output.as_deref().unwrap()), std::path::PathBuf::from(crate::tests_stubs::tmp_out("rq/out.mov"))); assert_eq!(q[1].status, RenderStatus::Failed("boom".into())); assert_eq!(q[2].status.label(), "Unqueued"); - assert!(s.drain_events().iter().any(|e| matches!(e, Event::Toast { error: true, .. }))); + let events = s.drain_events(); + let toast = events.iter().find_map(|e| if let Event::Toast { message, error: true } = e { Some(message.as_str()) } else { None }); + assert_eq!(toast, Some("Render finished with 1 failed item(s): boom"), "the toast says why (#568)"); // Reorder, duplicate, remove, undo. s.execute("renderQueue.move", json!({"item": b["item"], "to": 1})).unwrap(); assert_eq!(s.project.render_queue[0].id, b["item"].as_u64().unwrap()); @@ -114,6 +116,28 @@ fn persists_in_project_file() { assert!(effectcraft_project::Project::from_json(&old).unwrap().render_queue.is_empty()); } +#[test] +fn failure_toast_names_the_first_reason() { + struct Failing; + impl Exporter for Failing { + fn formats(&self) -> Vec { + OutputFormat::ALL.to_vec() + } + fn export(&self, job: &ExportJob, _: &mut dyn FnMut(u64, u64) -> bool) -> Result { + let name = std::path::Path::new(job.path).file_name().unwrap().to_string_lossy().into_owned(); + Err(format!("cannot write {name}")) + } + } + let mut s = demo(); + s.exporter = Some(Arc::new(Failing)); + s.execute("renderQueue.add", json!({"format": "gif", "output": crate::tests_stubs::tmp_out("rq/first.gif")})).unwrap(); + s.execute("renderQueue.add", json!({"format": "gif", "output": crate::tests_stubs::tmp_out("rq/second.gif")})).unwrap(); + s.execute("renderQueue.render", json!({})).unwrap(); + let events = s.drain_events(); + let toast = events.iter().find_map(|e| if let Event::Toast { message, error: true } = e { Some(message.as_str()) } else { None }); + assert_eq!(toast, Some("Render finished with 2 failed item(s), first: cannot write first.gif")); +} + #[test] fn background_render_and_stop() { struct Slow; From 47dbd8dbd52b17d18891358023b66a2b9720232e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= <100806068+MatejKozlovsky@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:45:10 +0200 Subject: [PATCH 016/181] Cryptomatte: the rank planes are resolved once per evaluation and the GPU path reduces them on all cores, instead of formatting and looking up channel names for every pixel in a single-threaded loop (#473) AuxChannels::crypto_rank_planes resolves a layer's (ID, coverage) planes once; crypto_ranks keeps its signature on top of it. The CPU effect and the GPU effect's aux image (effectcraft_effects::crypto_aux_image, rayon) index the planes per pixel. Output is bit-identical. --- crates/effects/src/channel3d.rs | 202 ++++++++++++++++++++++++++++++-- crates/effects/src/lib.rs | 2 +- crates/gpu/src/fx_depth.rs | 18 +-- crates/raster/src/channels3d.rs | 45 ++++++- 4 files changed, 238 insertions(+), 29 deletions(-) diff --git a/crates/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs index 4fc1cc81a..893bef20b 100644 --- a/crates/effects/src/channel3d.rs +++ b/crates/effects/src/channel3d.rs @@ -379,6 +379,43 @@ pub fn selection_hashes(sel: &str, manifest: Option<&[(String, u32)]>) -> Vec = [(&'a [f32], &'a [f32])]; + +/// The selection's coverage at aux pixel `i`: the summed coverage of the selected IDs over all +/// ranks (the Cryptomatte spec's matte), clamped to 0..1. +#[inline] +fn crypto_matte(planes: &RankPlanes, sel: &[u32], i: usize) -> f32 { + planes.iter().map(|(id, c)| (id[i], c[i])).filter(|(id, c)| *c > 0.0 && sel.contains(&id.to_bits())).map(|(_, c)| c).sum::().clamp(0.0, 1.0) +} + +/// The coverage-weighted ID colours at aux pixel `i` (the spec's preview). +#[inline] +fn crypto_colors(planes: &RankPlanes, i: usize) -> [f32; 3] { + let mut c = [0.0f32; 3]; + for (id, cov) in planes { + let (id, cov) = (id[i], cov[i]); + let k = id_color(id); + for j in 0..3 { + c[j] += k[j] * cov; + } + } + c +} + +/// Per aux pixel of Cryptomatte layer `layer`: the coverage-weighted ID colours (RGB) and the +/// selection's coverage (A), computed in parallel with the rank planes resolved once. The GPU +/// effect uploads this image and samples it per layer pixel. +pub fn crypto_aux_image(a: &AuxChannels, layer: &str, sel: &[u32]) -> Image { + let planes = a.crypto_rank_planes(layer); + let mut img = Image::new(a.width, a.height); + img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let c = crypto_colors(&planes, i); + *px = [c[0], c[1], c[2], crypto_matte(&planes, sel, i)]; + }); + img +} + fn cryptomatte(ctx: &EffectCtx, mut b: Buf) -> Buf { let Some(a) = aux(ctx) else { return b }; let layers = a.crypto_layers(); @@ -392,10 +429,10 @@ fn cryptomatte(ctx: &EffectCtx, mut b: Buf) -> Buf { let idx = index_map(&b, &a); let display = ctx.params.e("display"); let matte_only = ctx.params.b("matteOnly"); + let planes = a.crypto_rank_planes(&layer); let src = b.img.clone(); b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { - let ranks = idx[i].map(|k| a.crypto_ranks(&layer, k)).unwrap_or_default(); - let m: f32 = ranks.iter().filter(|(id, c)| *c > 0.0 && sel.contains(&id.to_bits())).map(|(_, c)| *c).sum::().clamp(0.0, 1.0); + let m = idx[i].map_or(0.0, |k| crypto_matte(&planes, &sel, k)); if matte_only { *px = [m, m, m, m]; return; @@ -403,13 +440,7 @@ fn cryptomatte(ctx: &EffectCtx, mut b: Buf) -> Buf { *px = match display { // Colors: coverage-weighted ID colours (the spec's preview), selection brighter. 0 => { - let mut c = [0.0f32; 3]; - for (id, cov) in &ranks { - let k = id_color(*id); - for j in 0..3 { - c[j] += k[j] * cov; - } - } + let c = idx[i].map_or([0.0; 3], |k| crypto_colors(&planes, k)); let c = c.map(|v| v * (1.0 - 0.5 * m) + 0.5 * m); [c[0], c[1], c[2], 1.0] } @@ -692,6 +723,159 @@ mod tests { assert_ne!(c.get(2, 5), c.get(12, 5)); } + /// The parallel aux image (rank planes resolved once) is bit-identical to the per-pixel + /// reduction over `crypto_ranks` it replaces. + #[test] + fn crypto_aux_image_matches_the_per_pixel_reduction() { + let (w, h) = (7u32, 5u32); + let n = (w * h) as usize; + let mut a = AuxChannels::new(w, h, 1.0); + let ids = [crypto_hash("cube"), crypto_hash("sphere"), crypto_hash("ring")].map(effectcraft_raster::channels3d::crypto_float); + for (k, ch) in ["00.R", "00.G", "00.B", "00.A", "01.R", "01.G", "01.B", "01.A"].iter().enumerate() { + let v: Vec = (0..n).map(|i| if k % 2 == 0 { ids[(i + k) % 3] } else { ((i * 7 + k * 3) % 11) as f32 / 10.0 }).collect(); + a.channels.push((format!("CryptoObject{ch}"), v)); + } + let sel = vec![crypto_hash("sphere"), crypto_hash("ring")]; + let img = crypto_aux_image(&a, "CryptoObject", &sel); + for i in 0..n { + let ranks = a.crypto_ranks("CryptoObject", i); + let m: f32 = ranks.iter().filter(|(id, c)| *c > 0.0 && sel.contains(&id.to_bits())).map(|(_, c)| *c).sum::().clamp(0.0, 1.0); + let mut c = [0.0f32; 3]; + for (id, cov) in &ranks { + let k = id_color(*id); + for j in 0..3 { + c[j] += k[j] * cov; + } + } + let want = [c[0], c[1], c[2], m]; + assert_eq!(img.data[i].map(f32::to_bits), want.map(f32::to_bits), "pixel {i}"); + } + } + + /// The lookup before #473, kept as the reference: the rank channels formatted and searched + /// by name for every pixel. + fn ranks_by_name(a: &AuxChannels, layer: &str, i: usize) -> Vec<(f32, f32)> { + let mut out = vec![]; + for k in 0.. { + let get = |c: &str| a.get(&format!("{layer}{k:02}.{c}")); + let (Some(r), Some(g)) = (get("R"), get("G")) else { break }; + out.push((r[i], g[i])); + if let (Some(b), Some(al)) = (get("B"), get("A")) { + out.push((b[i], al[i])); + } + } + out + } + + /// The reduction before #473 over those ranks: (ID colours, selection coverage). + fn reduce_by_name(a: &AuxChannels, layer: &str, sel: &[u32], i: usize) -> ([f32; 3], f32) { + let ranks = ranks_by_name(a, layer, i); + let m: f32 = ranks.iter().filter(|(id, c)| *c > 0.0 && sel.contains(&id.to_bits())).map(|(_, c)| *c).sum::().clamp(0.0, 1.0); + let mut c = [0.0f32; 3]; + for (id, cov) in &ranks { + let k = id_color(*id); + for j in 0..3 { + c[j] += k[j] * cov; + } + } + (c, m) + } + + /// A 6×2 Cryptomatte layer with awkward values: NaN, ±inf, negative and -0.0 coverage, NaN + /// and -0.0 IDs; rank 01 has no B/A pair, rank 02 is missing, so rank 03 (filled with values + /// that would change every pixel) must be ignored as before. + fn edge_aux(with_rank_after_gap: bool) -> AuxChannels { + let (w, h) = (6u32, 2u32); + let nan_payload = f32::from_bits(0x7fc0_1234); + let (hs, hc) = (crypto_float(crypto_hash("sphere")), crypto_float(crypto_hash("cube"))); + let cov = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.5, -0.0, 0.0, 0.25, 1.0, 2.0, f32::MIN_POSITIVE, nan_payload, 0.5]; + let ids = [hs, f32::NAN, hc, -0.0, 0.0, nan_payload, hs, hc, f32::NAN, hs, hc, -0.0]; + let rot = |v: &[f32; 12], k: usize| -> Vec { (0..12).map(|i| v[(i + k) % 12]).collect() }; + let mut a = AuxChannels::new(w, h, 1.0); + let mut push = |n: &str, v: Vec| a.channels.push((format!("CryptoObject{n}"), v)); + push("00.R", rot(&ids, 0)); + push("00.G", rot(&cov, 0)); + push("00.B", rot(&ids, 3)); + push("00.A", rot(&cov, 5)); + push("01.R", rot(&ids, 7)); + push("01.G", rot(&cov, 2)); + if with_rank_after_gap { + push("03.R", vec![hs; 12]); + push("03.G", vec![1.0; 12]); + push("03.B", vec![hc; 12]); + push("03.A", vec![1.0; 12]); + } + a + } + + fn edge_selection() -> Vec { + vec![crypto_hash("sphere"), f32::NAN.to_bits(), 0x7fc0_1234, (-0.0f32).to_bits()] + } + + /// Edge values give bit-identical results to the lookup by name, the missing B/A pair is + /// skipped and a rank after a gap is ignored. + #[test] + fn crypto_aux_image_is_bit_exact_on_edge_values() { + let sel = edge_selection(); + let a = edge_aux(true); + assert_eq!(a.crypto_rank_planes("CryptoObject").len(), 3, "00.R/G, 00.B/A, 01.R/G; not 03 after the gap"); + let img = crypto_aux_image(&a, "CryptoObject", &sel); + let without_gap_rank = crypto_aux_image(&edge_aux(false), "CryptoObject", &sel); + for i in 0..12 { + let (c, m) = reduce_by_name(&a, "CryptoObject", &sel, i); + let want = [c[0], c[1], c[2], m]; + assert_eq!(img.data[i].map(f32::to_bits), want.map(f32::to_bits), "pixel {i}"); + assert_eq!(img.data[i].map(f32::to_bits), without_gap_rank.data[i].map(f32::to_bits), "pixel {i}: the rank after the gap was used"); + } + } + + /// The CPU effect on a 20×10 layer over the 6×2 edge aux: pixels inside the aux area match + /// the lookup by name bit for bit in every display mode, pixels outside it (no aux index) + /// get an empty matte and no ID colour. + #[test] + fn cryptomatte_cpu_path_is_bit_exact_inside_and_outside_the_aux_area() { + let a = edge_aux(true); + let host = AuxHost(Arc::new(a.clone())); + let names = "sphere, <7fc00000>, <7fc01234>, <80000000>"; + assert_eq!(selection_hashes(names, None), edge_selection()); + let src = img().data[0]; + for (display, matte_only) in [(0, false), (1, false), (2, false), (0, true)] { + let out = run( + "ec.3d.cryptomatte", + &[("selection", Value::Str(names.into())), ("display", Value::Enum(display)), ("matteOnly", Value::Bool(matte_only))], + &host, + ); + let expect = |c: [f32; 3], m: f32| -> [f32; 4] { + if matte_only { + return [m; 4]; + } + match display { + 0 => { + let c = c.map(|v| v * (1.0 - 0.5 * m) + 0.5 * m); + [c[0], c[1], c[2], 1.0] + } + 1 => src.map(|v| v * m), + _ => [src[0] * (1.0 - 0.5 * m) + 0.5 * m * src[3], src[1] * (1.0 - 0.5 * m) + 0.5 * m * src[3], src[2] * (1.0 - 0.5 * m), src[3]], + } + }; + for (x, y) in [(0u32, 0u32), (1, 0), (5, 0), (0, 1), (3, 1), (5, 1)] { + let (c, m) = reduce_by_name(&a, "CryptoObject", &edge_selection(), (y * 6 + x) as usize); + assert_eq!( + out.get(x as i64, y as i64).map(f32::to_bits), + expect(c, m).map(f32::to_bits), + "display {display} matteOnly {matte_only} at ({x}, {y})" + ); + } + for (x, y) in [(6u32, 0u32), (19, 0), (0, 2), (12, 9)] { + assert_eq!( + out.get(x as i64, y as i64).map(f32::to_bits), + expect([0.0; 3], 0.0).map(f32::to_bits), + "display {display} matteOnly {matte_only} outside the aux at ({x}, {y})" + ); + } + } + } + #[test] fn cryptomatte_selects_by_name_and_wildcard() { let h = scene(); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index e86adec96..af7bd437b 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -109,7 +109,7 @@ pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time, // effectcraft-gpu fx_tone. pub use ocio::color_stabilizer_maps; // GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes). -pub use channel3d::{CRYPTO_LAYERS, id_color, selection_hashes}; +pub use channel3d::{CRYPTO_LAYERS, crypto_aux_image, id_color, selection_hashes}; pub use ocio::{ColorOp, Straight, Tf, color_program}; pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, drizzle_drops, mercury_blobs, sprite_plan}; pub use sim2::{ diff --git a/crates/gpu/src/fx_depth.rs b/crates/gpu/src/fx_depth.rs index 2e55573c0..4931afe2a 100644 --- a/crates/gpu/src/fx_depth.rs +++ b/crates/gpu/src/fx_depth.rs @@ -276,21 +276,9 @@ fn cryptomatte(e: &mut Enc, ctx: &EffectCtx, b: GBuf, a: &AuxChannels) -> Option let layer = layers.iter().find(|l| l.as_str() == want || l.ends_with(want)).unwrap_or(&layers[0]).clone(); let manifest = a.manifests.iter().find(|(l, _)| *l == layer).map(|(_, m)| m.as_slice()); let sel = effectcraft_effects::selection_hashes(pr.s("selection"), manifest); - // Per aux pixel: coverage-weighted ID colours (RGB) and the selection's coverage (A). - let n = (a.width * a.height) as usize; - let mut img = Image::new(a.width, a.height); - for (i, px) in img.data.iter_mut().enumerate().take(n) { - let ranks = a.crypto_ranks(&layer, i); - let m: f32 = ranks.iter().filter(|(id, c)| *c > 0.0 && sel.contains(&id.to_bits())).map(|(_, c)| *c).sum::().clamp(0.0, 1.0); - let mut c = [0.0f32; 3]; - for (id, cov) in &ranks { - let k = effectcraft_effects::id_color(*id); - for j in 0..3 { - c[j] += k[j] * cov; - } - } - *px = [c[0], c[1], c[2], m]; - } + // Per aux pixel: coverage-weighted ID colours (RGB) and the selection's coverage (A), on all + // cores with the rank planes resolved once. + let img = effectcraft_effects::crypto_aux_image(a, &layer, &sel); let tex = e.g.upload_image(&img)?; let plane = lookup(e, &b, a, &tex, [0.5, 0.5], [0.0; 4]); let mut p = op(7); diff --git a/crates/raster/src/channels3d.rs b/crates/raster/src/channels3d.rs index 010d16db6..b84b64b79 100644 --- a/crates/raster/src/channels3d.rs +++ b/crates/raster/src/channels3d.rs @@ -120,8 +120,11 @@ impl AuxChannels { v } - /// Rank pairs (id, coverage) of Cryptomatte layer `layer` at aux pixel index `i`. - pub fn crypto_ranks(&self, layer: &str, i: usize) -> Vec<(f32, f32)> { + /// The rank planes of Cryptomatte layer `layer`, in rank order: (ID plane, coverage plane) + /// for `layer00.R/G`, `layer00.B/A`, `layer01.R/G`, ... Resolve them once per evaluation and + /// index them per pixel; looking the channels up by name for every pixel is what made the + /// effect slow. + pub fn crypto_rank_planes(&self, layer: &str) -> Vec<(&[f32], &[f32])> { let mut out = vec![]; for k in 0.. { let (r, g, b, a) = ( @@ -131,14 +134,20 @@ impl AuxChannels { self.get(&format!("{layer}{k:02}.A")), ); let (Some(r), Some(g)) = (r, g) else { break }; - out.push((r[i], g[i])); + out.push((r, g)); if let (Some(b), Some(a)) = (b, a) { - out.push((b[i], a[i])); + out.push((b, a)); } } out } + /// Rank pairs (id, coverage) of Cryptomatte layer `layer` at aux pixel index `i`. For a + /// loop over pixels, use [`AuxChannels::crypto_rank_planes`] once instead. + pub fn crypto_ranks(&self, layer: &str, i: usize) -> Vec<(f32, f32)> { + self.crypto_rank_planes(layer).iter().map(|(id, c)| (id[i], c[i])).collect() + } + /// Aux pixel index of a layer-space point (nearest; `None` outside). #[inline] pub fn index_at(&self, lx: f64, ly: f64) -> Option { @@ -237,4 +246,32 @@ mod tests { assert_eq!(a.index_at(1.5, 0.2), Some(1)); assert_eq!(a.index_at(2.5, 0.2), None); } + + /// The rank planes come in rank order (00.R/G, 00.B/A, 01.R/G, ...), match lowercase channel + /// names as Blender writes them, stop at the first missing rank and agree with + /// `crypto_ranks` at every pixel. + #[test] + fn crypto_rank_planes_resolve_ranks_in_order() { + let mut a = AuxChannels::new(2, 1, 1.0); + for (n, v) in [ + ("00.r", [1.0, 2.0]), + ("00.g", [0.5, 0.6]), + ("00.b", [3.0, 4.0]), + ("00.a", [0.25, 0.3]), + ("01.R", [5.0, 6.0]), + ("01.G", [0.1, 0.2]), + ("03.R", [9.0, 9.0]), + ("03.G", [1.0, 1.0]), + ] { + a.channels.push((format!("CryptoMaterial{n}"), v.to_vec())); + } + let planes = a.crypto_rank_planes("CryptoMaterial"); + let ids: Vec = planes.iter().map(|(id, _)| id[1]).collect(); + assert_eq!(ids, vec![2.0, 4.0, 6.0]); + for i in 0..2 { + let from_planes: Vec<(f32, f32)> = planes.iter().map(|(id, c)| (id[i], c[i])).collect(); + assert_eq!(from_planes, a.crypto_ranks("CryptoMaterial", i)); + } + assert!(a.crypto_rank_planes("CryptoObject").is_empty()); + } } From e2188c436ca4c3f2a795903dc9b3e182a665f1b3 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 13:42:01 +0200 Subject: [PATCH 017/181] Portable mode: a portable.txt beside the exe keeps settings in EffectCraftData (#993) --- Cargo.lock | 1 + crates/host/Cargo.toml | 1 + crates/host/src/app_dirs.rs | 219 +++++++++++++++++++++++++++++++++ crates/host/src/lib.rs | 18 +-- docs/preferences.md | 3 +- docs/releasing.md | 7 ++ packaging/windows/package.ps1 | 5 +- packaging/windows/portable.txt | 12 ++ 8 files changed, 251 insertions(+), 15 deletions(-) create mode 100644 crates/host/src/app_dirs.rs create mode 100644 packaging/windows/portable.txt diff --git a/Cargo.lock b/Cargo.lock index 595f50d69..feb9b394e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1363,6 +1363,7 @@ dependencies = [ "exr", "gif", "image", + "log", "rayon", "serde_json", ] diff --git a/crates/host/Cargo.toml b/crates/host/Cargo.toml index 344d54337..80f61e4c3 100644 --- a/crates/host/Cargo.toml +++ b/crates/host/Cargo.toml @@ -12,6 +12,7 @@ workspace = true [dependencies] effectcraft-engine = { workspace = true } effectcraft-project = { workspace = true } +log = { workspace = true } effectcraft-media = { workspace = true } effectcraft-expr = { workspace = true } effectcraft-script = { workspace = true } diff --git a/crates/host/src/app_dirs.rs b/crates/host/src/app_dirs.rs new file mode 100644 index 000000000..de81ea608 --- /dev/null +++ b/crates/host/src/app_dirs.rs @@ -0,0 +1,219 @@ +//! Where EffectCraft keeps its per-user data (settings, shortcuts, crash recovery, logs, models, +//! plug-ins): the one directory [`config_dir`](crate::config_dir) returns. +//! +//! In order: +//! 1. `EFFECTCRAFT_CONFIG_DIR`, when set (tests, agents, custom setups). +//! 2. **Portable mode** (#993): a marker file ([`PORTABLE_MARKERS`]) beside the executable puts +//! everything in `/EffectCraftData`, so a portable copy on a USB stick leaves nothing in +//! `%APPDATA%`. The Windows portable zip ships with `portable.txt`; the MSI doesn't. If that +//! folder can't be written (read-only medium, Program Files), a warning is logged and the +//! platform folder is used. +//! 3. The platform convention: macOS `~/Library/Application Support/EffectCraft`, Windows +//! `%APPDATA%\EffectCraft`, Linux `$XDG_CONFIG_HOME/effectcraft` or `~/.config/effectcraft`. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +/// Files beside the executable that switch on portable mode (either one; contents are ignored). +pub const PORTABLE_MARKERS: [&str; 2] = ["portable.txt", "EffectCraft.portable"]; +/// The data folder created beside the executable in portable mode. +pub const PORTABLE_DATA_DIR: &str = "EffectCraftData"; + +/// How [`DataDir::dir`] was chosen. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mode { + /// `EFFECTCRAFT_CONFIG_DIR`. + Override, + /// `/EffectCraftData`. + Portable, + /// The platform's per-user config directory. + Platform, +} + +/// The resolved data directory. +#[derive(Clone, Debug)] +pub struct DataDir { + /// `None` only when no platform directory is known either (no `HOME`/`APPDATA`). + pub dir: Option, + pub mode: Mode, + /// Set when a portable marker was found but its data folder isn't writable. + pub warning: Option, +} + +/// The data directory for this process, resolved once (the portable check probes the disk). +pub fn current() -> &'static DataDir { + static DIR: OnceLock = OnceLock::new(); + DIR.get_or_init(|| { + let exe = std::env::current_exe().ok(); + let d = resolve(|k| std::env::var_os(k), exe.as_deref().and_then(Path::parent)); + if let Some(w) = &d.warning { + log::warn!("{w}"); + } + d + }) +} + +/// Resolve the data directory from the environment (`env`) and the executable's folder. +pub fn resolve(env: impl Fn(&str) -> Option, exe_dir: Option<&Path>) -> DataDir { + if let Some(d) = env("EFFECTCRAFT_CONFIG_DIR") { + return DataDir { dir: Some(PathBuf::from(d)), mode: Mode::Override, warning: None }; + } + let platform = platform_dir(&env); + let Some((exe_dir, marker)) = exe_dir.and_then(|d| portable_marker(d).map(|m| (d, m))) else { + return DataDir { dir: platform, mode: Mode::Platform, warning: None }; + }; + let data = exe_dir.join(PORTABLE_DATA_DIR); + match ensure_writable(&data) { + Ok(()) => DataDir { dir: Some(data), mode: Mode::Portable, warning: None }, + Err(e) => { + let fallback = platform.as_ref().map_or_else(|| "nowhere (no user folder found)".to_string(), |p| p.display().to_string()); + let warning = + format!("portable mode ({} found) but {} isn't writable ({e}); settings are kept in {fallback} instead", marker.display(), data.display()); + DataDir { dir: platform, mode: Mode::Platform, warning: Some(warning) } + } + } +} + +/// The platform convention, from `env` (so tests can fake `HOME`/`APPDATA`/`XDG_CONFIG_HOME`). +pub fn platform_dir(env: &impl Fn(&str) -> Option) -> Option { + let home = || env("HOME").map(PathBuf::from); + if cfg!(target_os = "macos") { + return home().map(|h| h.join("Library/Application Support/EffectCraft")); + } + if cfg!(target_os = "windows") { + return env("APPDATA").map(|a| PathBuf::from(a).join("EffectCraft")); + } + env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| home().map(|h| h.join(".config"))).map(|c| c.join("effectcraft")) +} + +/// The first portable marker file in `exe_dir`, if any. +pub fn portable_marker(exe_dir: &Path) -> Option { + PORTABLE_MARKERS.iter().map(|m| exe_dir.join(m)).find(|p| p.is_file()) +} + +/// Create `dir` and prove a file can be written in it (a folder can exist yet be read-only). +fn ensure_writable(dir: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(dir)?; + let probe = dir.join(format!(".write-test-{}", std::process::id())); + std::fs::write(&probe, b"")?; + // A leftover probe is harmless; don't fail portable mode over it. + let _ = std::fs::remove_file(&probe); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::sync::atomic::{AtomicUsize, Ordering}; + + fn temp(tag: &str) -> PathBuf { + static N: AtomicUsize = AtomicUsize::new(0); + let d = std::env::temp_dir().join(format!("effectcraft-app-dirs-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + fn env_of(pairs: &[(&str, &Path)]) -> impl Fn(&str) -> Option + use<> { + let m: HashMap = pairs.iter().map(|(k, v)| (k.to_string(), v.as_os_str().to_owned())).collect(); + move |k| m.get(k).cloned() + } + + /// A fake user folder that every platform's convention resolves under. + fn fake_home(root: &Path) -> impl Fn(&str) -> Option + use<> { + let home = root.join("home"); + let appdata = root.join("appdata"); + env_of(&[("HOME", &home), ("APPDATA", &appdata)]) + } + + #[test] + fn no_marker_uses_the_platform_dir() { + let root = temp("platform"); + let exe = root.join("bin"); + std::fs::create_dir_all(&exe).unwrap(); + let d = resolve(fake_home(&root), Some(&exe)); + assert_eq!(d.mode, Mode::Platform); + assert!(d.warning.is_none()); + let dir = d.dir.unwrap(); + if cfg!(target_os = "macos") { + assert_eq!(dir, root.join("home/Library/Application Support/EffectCraft")); + } else if cfg!(target_os = "windows") { + assert_eq!(dir, root.join("appdata").join("EffectCraft")); + } else { + assert_eq!(dir, root.join("home/.config/effectcraft")); + } + assert!(!exe.join(PORTABLE_DATA_DIR).exists(), "no portable folder without a marker"); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn either_marker_keeps_everything_beside_the_exe() { + for marker in PORTABLE_MARKERS { + let root = temp("portable"); + let exe = root.join("EffectCraft"); + std::fs::create_dir_all(&exe).unwrap(); + std::fs::write(exe.join(marker), b"").unwrap(); + let d = resolve(fake_home(&root), Some(&exe)); + assert_eq!(d.mode, Mode::Portable, "{marker}"); + assert!(d.warning.is_none()); + let dir = d.dir.unwrap(); + assert_eq!(dir, exe.join("EffectCraftData")); + assert!(dir.is_dir(), "the data folder is created"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "the write probe is removed"); + assert!(!root.join("home").exists() && !root.join("appdata").exists(), "nothing written to the user folder"); + let _ = std::fs::remove_dir_all(&root); + } + } + + #[test] + fn a_marker_folder_is_not_a_marker() { + let root = temp("dir-marker"); + let exe = root.join("bin"); + std::fs::create_dir_all(exe.join("portable.txt")).unwrap(); + assert_eq!(resolve(fake_home(&root), Some(&exe)).mode, Mode::Platform); + assert!(!exe.join(PORTABLE_DATA_DIR).exists()); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn unwritable_portable_folder_falls_back_with_a_warning() { + let root = temp("unwritable"); + let exe = root.join("bin"); + std::fs::create_dir_all(&exe).unwrap(); + std::fs::write(exe.join("portable.txt"), b"").unwrap(); + // A file where the data folder should go: it can't be created, on every platform. + std::fs::write(exe.join(PORTABLE_DATA_DIR), b"in the way").unwrap(); + let env = fake_home(&root); + let d = resolve(&env, Some(&exe)); + assert_eq!(d.mode, Mode::Platform); + assert_eq!(d.dir, platform_dir(&env)); + assert!(d.dir.is_some()); + let w = d.warning.unwrap(); + assert!(w.contains("portable") && w.contains("isn't writable"), "{w}"); + // No user folder at all: still no panic, just no directory. + let d = resolve(|_| None, Some(&exe)); + assert!(d.dir.is_none() && d.warning.is_some()); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn override_wins_over_portable() { + let root = temp("override"); + let exe = root.join("bin"); + std::fs::create_dir_all(&exe).unwrap(); + std::fs::write(exe.join("portable.txt"), b"").unwrap(); + let custom = root.join("custom"); + let d = resolve(env_of(&[("EFFECTCRAFT_CONFIG_DIR", &custom)]), Some(&exe)); + assert_eq!((d.mode, d.dir), (Mode::Override, Some(custom))); + assert!(!exe.join(PORTABLE_DATA_DIR).exists()); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn missing_exe_dir_or_env_never_panics() { + let d = resolve(|_| None, None); + assert_eq!((d.mode, d.dir), (Mode::Platform, None)); + } +} diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index cf5661a15..1e5311b09 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -12,6 +12,8 @@ use effectcraft_engine::{ExportJob, ExportResult, Exporter, Importer, Session}; use effectcraft_project::Footage; pub use effectcraft_script as script; +pub mod app_dirs; + struct MediaImporter; impl Importer for MediaImporter { @@ -97,20 +99,10 @@ pub fn session() -> Session { /// The platform config directory for EffectCraft (`EFFECTCRAFT_CONFIG_DIR` overrides): /// `~/Library/Application Support/EffectCraft` (macOS), `%APPDATA%\EffectCraft` (Windows), -/// `$XDG_CONFIG_HOME/effectcraft` or `~/.config/effectcraft` (Linux and others). +/// `$XDG_CONFIG_HOME/effectcraft` or `~/.config/effectcraft` (Linux and others). A `portable.txt` +/// beside the executable puts it in `EffectCraftData` there instead ([`app_dirs`]). pub fn config_dir() -> Option { - use std::path::PathBuf; - if let Some(d) = std::env::var_os("EFFECTCRAFT_CONFIG_DIR") { - return Some(PathBuf::from(d)); - } - let home = || std::env::var_os("HOME").map(PathBuf::from); - if cfg!(target_os = "macos") { - return home().map(|h| h.join("Library/Application Support/EffectCraft")); - } - if cfg!(target_os = "windows") { - return std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("EffectCraft")); - } - std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| home().map(|h| h.join(".config"))).map(|c| c.join("effectcraft")) + app_dirs::current().dir.clone() } #[cfg(test)] diff --git a/docs/preferences.md b/docs/preferences.md index 3b92782c0..8b86528a1 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -13,7 +13,8 @@ The model is `effectcraft_engine::prefs::Prefs`: serde, versioned (`version`), a keys (`general.undoLevels`, `autoSave.intervalMinutes`, `labels.3.name`). It is stored as `prefs.json` through the session's `ConfigStore` (the desktop app uses the platform config directory: `~/Library/Application Support/EffectCraft` on macOS, `%APPDATA%\EffectCraft` on -Windows, `$XDG_CONFIG_HOME/effectcraft` on Linux; the web app keeps it in browser storage: the +Windows, `$XDG_CONFIG_HOME/effectcraft` on Linux, or `EffectCraftData` beside the executable when a +`portable.txt` sits there, as in the Windows portable zip; the web app keeps it in browser storage: the Origin Private File System, or IndexedDB where OPFS can't write, see [web.md](web.md)). Loading migrates older layouts, keeps unknown keys (from newer versions) and falls back to defaults for values that don't parse. diff --git a/docs/releasing.md b/docs/releasing.md index 3cd1f112b..bd28d8ec3 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -74,6 +74,13 @@ that with stub binaries ([`test-msi.ps1`](../packaging/windows/test-msi.ps1)): a an upgrade that keeps it, a repair, uninstall, and the Program Files default. The wizard pages themselves are checked by hand. +The portable zip ships [`portable.txt`](../packaging/windows/portable.txt) beside `effectcraft.exe`. +That marker (or an `EffectCraft.portable` file) switches on portable mode: the settings folder +(preferences, shortcuts, recovery files, logs, plug-ins) becomes `EffectCraftData\` next to the +exe instead of `%APPDATA%\EffectCraft`. If that folder isn't writable the app logs a warning and +uses `%APPDATA%`. The MSI has no marker. The logic is in `crates/host/src/app_dirs.rs` and works +the same on macOS and Linux; `EFFECTCRAFT_CONFIG_DIR` still wins over it. + Every binary reports its version: `effectcraft --version`, `effectcraft-cli --version` and *Help › About EffectCraft*. diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 7f1320c8d..7654fe1f3 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -5,7 +5,7 @@ .DESCRIPTION Produces, in $env:DIST (default: dist/release): effectcraft--windows-.msi per-machine installer (WiX v5) - effectcraft--windows--portable.zip effectcraft.exe + effectcraft-cli.exe + effectcraft--windows--portable.zip effectcraft.exe + effectcraft-cli.exe + portable.txt The binaries link the C runtime statically (+crt-static), so neither the MSI nor the portable zip needs the Visual C++ redistributable. Signing is delegated to sign.ps1 (skipped with a @@ -110,6 +110,9 @@ foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE') { $p = Join-Path $Root $f if (Test-Path $p) { Copy-Item $p $Portable } } +# portable.txt beside effectcraft.exe switches on portable mode: settings, shortcuts and recovery +# files go to EffectCraftData\ next to the exe instead of %APPDATA% (#993; see app_dirs.rs). +Copy-Item (Join-Path $PSScriptRoot 'portable.txt') $Portable $Zip = Join-Path $Dist "effectcraft-$Version-windows-$Arch-portable.zip" Remove-Item -Force $Zip -ErrorAction SilentlyContinue Compress-Archive -Path $Portable -DestinationPath $Zip diff --git a/packaging/windows/portable.txt b/packaging/windows/portable.txt new file mode 100644 index 000000000..e36056daf --- /dev/null +++ b/packaging/windows/portable.txt @@ -0,0 +1,12 @@ +EffectCraft portable mode + +While this file sits next to effectcraft.exe, EffectCraft keeps its settings, keyboard shortcuts, +crash-recovery files, logs and plug-ins in the EffectCraftData folder beside it instead of +%APPDATA%\EffectCraft. Copy the whole folder to move EffectCraft with its settings. + +The disk cache, media cache and conformed audio still go to %LOCALAPPDATA%\EffectCraft unless +you choose other folders in Edit > Preferences > Disk. + +Delete this file to use the normal per-user settings folder (%APPDATA%\EffectCraft) instead. +If EffectCraftData can't be written (for example on a read-only drive), EffectCraft logs a +warning and uses %APPDATA%\EffectCraft instead. From e9c8d703a7f1e9ecb3bf63e9c36fa13d283c17a8 Mon Sep 17 00:00:00 2001 From: Themolx <183303841+Themolx@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:12:12 +0200 Subject: [PATCH 018/181] Rotation tool: keep counting past a full turn, as in After Effects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The viewer's Rotation tool wrote start rotation + (atan2 angle now - atan2 angle at press), so the value jumped a full revolution back whenever the pointer crossed atan2's ±180° seam (left of the anchor) and could never pass 360°. Sum the per-frame angle change, wrapped to (-180°, 180°], instead: the value keeps growing across revolutions in both directions (1x +0.0°, -1x -90.0°…), Shift snaps the accumulated value to 45°, other selected layers follow, and one drag is still one undo step. Non-finite angles turn by nothing. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/viewer.rs | 83 ++++++++++++++++++++++++++--- crates/ui-egui/tests/ui_viewer.rs | 46 ++++++++++++++++ 2 files changed, 122 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index cbb842d91..fe583c8ce 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -91,7 +91,11 @@ enum Gesture { Rotate { layer: LayerId, center: Pos2, - start_angle: f64, + /// The pointer's angle about the centre on the last frame (degrees, as atan2 gives it). + last_angle: f64, + /// The turn so far, summed frame by frame so it keeps counting past a full revolution in + /// either direction (After Effects: 1x +0.0°, −1x −90.0°…) instead of wrapping at ±180°. + turned: f64, start_rot: f64, /// The other selected layers and their rotations: they turn by the same angle. others: Vec<(LayerId, f64)>, @@ -447,6 +451,26 @@ fn snap_handle(f: [f64; 2], h: [f64; 2], a: [f64; 2], l2c: &Mat3, axes: [bool; 2 [0, 1].map(|k| if axes[k] && h[k].abs() > 1e-9 { q[k] / h[k] } else { f[k] }) } +/// The turn from angle `from` to angle `to` (degrees), wrapped to (−180°, 180°]: the shortest way +/// round, so summing it frame by frame follows the pointer across atan2's ±180° seam and past full +/// revolutions. Non-finite input turns by nothing. +fn turn_delta(from: f64, to: f64) -> f64 { + let d = to - from; + if !d.is_finite() { + return 0.0; + } + let w = (d + 180.0).rem_euclid(360.0) - 180.0; + if w <= -180.0 { w + 360.0 } else { w } +} + +/// The Rotation tool's value: the rotation as the drag began plus the turn so far (which may span +/// revolutions), snapped to 45° steps with Shift. +fn rotate_value(start_rot: f64, turned: f64, shift: bool) -> f64 { + let r = start_rot + turned; + let r = if r.is_finite() { r } else { start_rot }; + if shift { (r / 45.0).round() * 45.0 } else { r } +} + /// A drag `d` (comp pixels), kept to the axis it moves along most when `shift` is held. fn one_axis(d: [f64; 2], shift: bool) -> [f64; 2] { if !shift { @@ -1368,7 +1392,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { Gesture::Rotate { layer: l, center, - start_angle: ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees(), + last_angle: ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees(), + turned: 0.0, start_rot: rot, others, } @@ -1570,12 +1595,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/scale", "value": v, "merge": merge})); } } - Gesture::Rotate { layer, center, start_angle, start_rot, others } => { + Gesture::Rotate { layer, center, last_angle, turned, start_rot, others } => { + // Count the turn incrementally: the absolute angle wraps at ±180°, so writing it + // straight would jump a revolution back each time the pointer went round. let ang = ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees(); - let mut r = start_rot + (ang - start_angle); - if mods.shift { - r = (r / 45.0).round() * 45.0; - } + let turned = turned + turn_delta(last_angle, ang); + ui.data_mut(|dd| dd.insert_temp(gid, Gesture::Rotate { layer, center, last_angle: ang, turned, start_rot, others: others.clone() })); + // Shift snaps the accumulated value (so 405° snaps to 1x +45°, not to 45°). + let r = rotate_value(start_rot, turned, mods.shift); let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/rotation", "value": r, "merge": merge})); for (lid, r0) in others { let _ = @@ -2611,6 +2638,48 @@ mod tests { use super::*; use effectcraft_engine::geom::{Mat4, vec3}; + /// The Rotation tool counts past a full turn in both directions, as in After Effects: the + /// pointer going round the anchor 1.5 turns clockwise gives 540°, then back 2 turns gives + /// −180°, with no jump where atan2 wraps from 180° to −180°. + #[test] + fn rotate_tool_accumulates_revolutions() { + // Screen y points down, so a growing atan2 angle is clockwise on screen. + let drag = |start: f64, steps: &[f64]| { + let (mut last, mut turned, mut out) = (start, 0.0, vec![]); + for &a in steps { + // atan2's range: (−180°, 180°]. + let a = (a + 180.0).rem_euclid(360.0) - 180.0; + turned += turn_delta(last, a); + last = a; + out.push(turned); + } + (last, turned, out) + }; + let cw: Vec = (1..=54).map(|i| i as f64 * 10.0).collect(); + let (last, turned, seq) = drag(0.0, &cw); + assert!((turned - 540.0).abs() < 1e-9, "{turned}"); + assert!(seq.windows(2).all(|w| (w[1] - w[0] - 10.0).abs() < 1e-9), "no jump: {seq:?}"); + assert!((rotate_value(0.0, turned, false) - 540.0).abs() < 1e-9); + let ccw: Vec = (1..=72).map(|i| 540.0 - i as f64 * 10.0).collect(); + let (_, back, seq) = drag(last, &ccw); + assert!(seq.windows(2).all(|w| (w[1] - w[0] + 10.0).abs() < 1e-9), "no jump: {seq:?}"); + let total = turned + back; + assert!((total + 180.0).abs() < 1e-9, "{total}"); + assert!((rotate_value(30.0, total, false) + 150.0).abs() < 1e-9); + // Shift snaps the accumulated value, not a wrapped one. + assert_eq!(rotate_value(0.0, 400.0, true), 405.0); + assert_eq!(rotate_value(0.0, -400.0, true), -405.0); + // Across the seam: 170° → −170° is +20°, and back is −20°; exactly half a turn is +180°. + assert!((turn_delta(170.0, -170.0) - 20.0).abs() < 1e-9); + assert!((turn_delta(-170.0, 170.0) + 20.0).abs() < 1e-9); + assert_eq!(turn_delta(0.0, 180.0), 180.0); + assert_eq!(turn_delta(0.0, -180.0), 180.0); + // Never crash or poison the value on non-finite input. + assert_eq!(turn_delta(f64::NAN, 10.0), 0.0); + assert_eq!(turn_delta(0.0, f64::INFINITY), 0.0); + assert_eq!(rotate_value(15.0, f64::NAN, false), 15.0); + } + /// #882: the frame's corner sits on a physical pixel at any display scaling, so 100 % maps /// comp pixels 1:1 onto screen pixels. #[test] diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index 07036ca81..3022cb8a6 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -2088,3 +2088,49 @@ fn fit_up_to_100_follows_the_maximized_panel() { h.run_steps(3); assert!((rect(&h, "viewer.comp").width() - 640.0 / ppp).abs() < 0.01); } + +/// The Rotation tool keeps counting past a full turn, as in After Effects: going round the anchor +/// 1.5 turns clockwise gives 540° (1x +180.0°), going back 2 turns gives −180°, with no jump a +/// revolution back where the pointer crosses the left of the anchor. One drag is one undo step. +#[test] +fn rotate_tool_counts_revolutions_both_ways() { + let mut h = harness(); + let box_id = h.state().session.active_comp().unwrap().layers[0].id; + h.state_mut().session.execute("layer.select", json!({"layers": [box_id.0]})).unwrap(); + h.state_mut().ui.tool = Tool::Rotate; + h.run_steps(2); + let rotation = |h: &Harness<'_, EffectcraftApp>| { + h.state().session.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/rotation").unwrap().value.as_f64() + }; + let center = screen(&h, [320.0, 180.0]); + // Inside the 80 px box, so the drag starts on it (not on the Plate behind). + let r = (screen(&h, [350.0, 180.0]) - center).x; + let at = |deg: f64| center + vec2((deg.to_radians().cos() as f32) * r, (deg.to_radians().sin() as f32) * r); + let undo_before = h.state().session.history.undo.len(); + // Press on the box, right of its anchor; then go round in 10° steps. + let press = center + vec2(r * 0.3, 0.0); + h.input_mut().events.push(Event::PointerMoved(press)); + h.input_mut().events.push(Event::PointerButton { pos: press, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + let mut seen = vec![]; + let mut walk = |h: &mut Harness<'_, EffectcraftApp>, degs: Vec| { + for d in degs { + h.input_mut().events.push(Event::PointerMoved(at(d))); + h.step(); + seen.push(rotation(h)); + } + }; + walk(&mut h, (0..=54).map(|i| i as f64 * 10.0).collect()); + // The gesture applies the previous frame's pointer: hold a frame. + h.step(); + let cw = rotation(&h); + assert!((cw - 540.0).abs() < 1.0, "1.5 turns clockwise: {cw}"); + walk(&mut h, (1..=72).map(|i| 540.0 - i as f64 * 10.0).collect()); + let end = at(-180.0); + h.input_mut().events.push(Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + let ccw = rotation(&h); + assert!((ccw + 180.0).abs() < 1.0, "back two turns: {ccw}"); + assert!(seen.windows(2).all(|w| (w[1] - w[0]).abs() <= 20.0), "no jump: {seen:?}"); + assert_eq!(h.state().session.history.undo.len(), undo_before + 1, "one drag, one undo step"); +} From 9e66e0d2ec827cefefe63de244e90182239be813 Mon Sep 17 00:00:00 2001 From: Themolx <183303841+Themolx@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:45:21 +0200 Subject: [PATCH 019/181] New comps from footage get a unique name, as Duplicate does Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/comp.rs | 7 +-- crates/engine/src/commands/file_more.rs | 4 ++ crates/engine/src/commands/project_items.rs | 3 +- crates/engine/src/tests_menu_cmds.rs | 51 +++++++++++++++++++++ crates/project/src/lib.rs | 13 ++++++ 5 files changed, 70 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/commands/comp.rs b/crates/engine/src/commands/comp.rs index 3fcf9c6d7..e00b4eb24 100644 --- a/crates/engine/src/commands/comp.rs +++ b/crates/engine/src/commands/comp.rs @@ -108,12 +108,7 @@ fn new_comp(s: &mut Session, p: &Value) -> Result { } apply_settings(&mut c, p); let id = s.edit("New Composition", None, |proj, st| { - let mut name = name.clone(); - let mut k = 2; - while proj.items.values().any(|i| i.name == name) { - name = format!("{} {k}", name.trim_end_matches(|ch: char| ch.is_ascii_digit()).trim_end()); - k += 1; - } + let name = proj.unique_item_name(&name, |_| false); let id = proj.add_item(&name, Label::Sandstone, None, ItemKind::Comp(c.into())); st.project_selection = vec![id]; Ok(id) diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index ef26d59b9..1c703e5e0 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -207,6 +207,10 @@ fn new_comp_from_selection(s: &mut Session, p: &Value) -> Result { let name = lead.name.rsplit_once('.').map(|(a, _)| a.to_string()).unwrap_or(lead.name.clone()); let c = Comp { pixel_aspect, ..Comp::new(w.max(1), h.max(1), rate, dur) }; let cid = s.edit("New Comp from Selection", None, |proj, st| { + // A name another item already has gets the next free number, as Duplicate gives + // ("clip", "clip 2", "clip 3"). The comp's own footage and solid sources don't + // count, so an extensionless one still names its first comp; a source comp does. + let name = proj.unique_item_name(&name, |i| g.contains(&i.id) && !matches!(i.kind, ItemKind::Comp(_))); let cid = proj.add_item(&name, Label::Sandstone, None, ItemKind::Comp(c.into())); st.project_selection = vec![cid]; Ok(cid) diff --git a/crates/engine/src/commands/project_items.rs b/crates/engine/src/commands/project_items.rs index 197c11a0d..0868349ab 100644 --- a/crates/engine/src/commands/project_items.rs +++ b/crates/engine/src/commands/project_items.rs @@ -219,8 +219,7 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { if it.is_folder() { return Err(bad(c, "folders can't be duplicated")); } - let base = it.name.trim_end_matches(|ch: char| ch.is_ascii_digit()).trim_end().to_string(); - let name = (2..).map(|i| format!("{base} {i}")).find(|n| proj.find_by_name(n).is_none()).unwrap_or_default(); + let name = proj.numbered_item_name(&it.name, |_| false); let mut kind = it.kind.clone(); if let effectcraft_project::ItemKind::Comp(comp) = &mut kind { let comp = std::sync::Arc::make_mut(comp); diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index cb83c1639..3e1611727 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -1069,6 +1069,57 @@ fn new_comp_from_selection_is_one_undo_step_with_the_dialog_options() { assert!(s.execute("file.newCompFromSelection", json!({"single": true, "dimensionsFrom": 2})).is_err()); } +#[test] +fn comps_made_from_the_same_footage_get_numbered_names_as_duplicate_does() { + let mut s = Session::default(); + let add = |s: &mut Session, name: &str, kind: ItemKind| std::sync::Arc::make_mut(&mut s.project).add_item(name, effectcraft_color::Label::None, None, kind); + let still = || { + ItemKind::Footage(effectcraft_project::Footage { + kind: effectcraft_project::FootageKind::Still, + width: 200, + height: 100, + has_video: true, + ..Default::default() + }) + }; + let clip = add(&mut s, "clip.png", still()); + let other = add(&mut s, "other.png", still()); + let make = |s: &mut Session, sel: Vec, p: Value| -> Vec { + s.state.project_selection = sel; + let r = s.execute("file.newCompFromSelection", p).unwrap(); + r["comps"].as_array().unwrap().iter().map(|c| s.project.item(effectcraft_project::ItemId(c.as_u64().unwrap())).unwrap().name.clone()).collect() + }; + // The same footage again and again: "clip", "clip 2", "clip 3". + assert_eq!(make(&mut s, vec![clip], json!({})), ["clip"]); + assert_eq!(make(&mut s, vec![clip], json!({})), ["clip 2"]); + assert_eq!(make(&mut s, vec![clip], json!({})), ["clip 3"]); + // Several items at once (one comp each): every new comp has a name of its own. + assert_eq!(make(&mut s, vec![clip, other, clip], json!({})), ["clip 4", "other", "clip 5"]); + // One comp for all, named after the item it takes its settings from. + assert_eq!(make(&mut s, vec![clip, other], json!({"single": true})), ["clip 6"]); + assert_eq!(make(&mut s, vec![clip, other], json!({"single": true, "dimensionsFrom": 1})), ["other 2"]); + // Undo removes the latest comp and frees its name for the next one. + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.project.find_by_name("other 2").is_none()); + assert_eq!(make(&mut s, vec![other], json!({})), ["other 2"]); + // A solid or extensionless footage still names its own first comp; the second is numbered. + let red = add(&mut s, "Red Solid 1", ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 0.0, 0.0], width: 100, height: 100, pixel_aspect: 1.0 })); + assert_eq!(make(&mut s, vec![red], json!({})), ["Red Solid 1"]); + assert_eq!(make(&mut s, vec![red], json!({})), ["Red Solid 2"]); + // Duplicate numbers its copies the same way, from the same helper. + let first = s.project.find_by_name("clip").unwrap().id; + s.state.project_selection = vec![first]; + let r = s.execute("project.duplicate", json!({})).unwrap(); + assert_eq!(s.project.item(effectcraft_project::ItemId(r["items"][0].as_u64().unwrap())).unwrap().name, "clip 7"); + // A comp made from a comp doesn't take its source comp's name. + assert_eq!(make(&mut s, vec![first], json!({})), ["clip 8"]); + // Several comps at once are one undo step; undoing frees all their names. + assert_eq!(make(&mut s, vec![clip, other], json!({})), ["clip 9", "other 3"]); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.project.find_by_name("clip 9").is_none() && s.project.find_by_name("other 3").is_none()); + assert_eq!(make(&mut s, vec![other, clip], json!({})), ["other 3", "clip 9"]); +} + #[test] fn project_files_keep_their_format_warn_about_newer_versions_and_never_save_unreadable() { let mut s = comp(); diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index fee3b8ce7..313b7467b 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1375,6 +1375,19 @@ impl Project { pub fn find_by_name(&self, name: &str) -> Option<&Item> { self.items.values().find(|i| i.name == name) } + /// A numbered name for a copy of `base` that no project item has (Edit ▸ Duplicate): its + /// trailing number is dropped and the lowest free one from 2 is added (`Comp 1` → `Comp 2`, + /// `Name` → `Name 2`, then `Name 3`). Items for which `ignore` holds don't count. + pub fn numbered_item_name(&self, base: &str, ignore: impl Fn(&Item) -> bool) -> String { + let stem = base.trim_end_matches(|c: char| c.is_ascii_digit()).trim_end(); + let taken = |n: &str| self.items.values().any(|i| i.name == n && !ignore(i)); + (2u64..).map(|k| format!("{stem} {k}")).find(|n| !taken(n)).unwrap_or_default() + } + /// `base` when no project item has that name, else the next free numbered name as + /// [`Project::numbered_item_name`] gives it. Items for which `ignore` holds don't count. + pub fn unique_item_name(&self, base: &str, ignore: impl Fn(&Item) -> bool) -> String { + if self.items.values().any(|i| i.name == base && !ignore(i)) { self.numbered_item_name(base, ignore) } else { base.to_string() } + } pub fn folder_named(&self, name: &str) -> Option { self.items.values().find(|i| i.is_folder() && i.name == name).map(|i| i.id) } From 3174e68d0eeb093fa9d6d6c9af97131d1c0d50f9 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 14:43:18 +0200 Subject: [PATCH 020/181] =?UTF-8?q?Timeline:=20Markers=20=E2=96=B8=20Add?= =?UTF-8?q?=20Marker=20in=20the=20layer=20context=20menu=20(#976)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-clicking a layer offered no way to mark it. The layer context menu now has After Effects' Markers submenu with Add Marker (layer.addMarker, as Layer ▸ Markers ▸ Add Marker), which marks the right-clicked layer, or the selection it belongs to, at the current time. The submenu title reuses the menu catalogs' "Markers" translation in the panel catalogs. --- crates/ui-egui/src/i18n/ui.rs | 6 ++++ crates/ui-egui/src/panels/timeline.rs | 3 ++ .../ui-egui/tests/ui_blank_context_menus.rs | 29 +++++++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index f31d0951c..3a49acad4 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -295,6 +295,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("MIT OR Apache-2.0 • Fonts: Inter, JetBrains Mono (OFL)", "MIT OR Apache-2.0 • フォント:Inter、JetBrains Mono (OFL)"), ("Magnification ratio popup", "拡大率のポップアップ"), ("Make Comp Size", "コンポジションサイズにする"), + ("Markers", "マーカー"), ("Masks", "マスク"), ("Maximize Panel", "パネルを最大化"), ("Media Replacement", "メディアの置き換え"), @@ -742,6 +743,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("MIT OR Apache-2.0 • Fonts: Inter, JetBrains Mono (OFL)", "MIT 或 Apache-2.0 • 字体:Inter、JetBrains Mono(OFL)"), ("Magnification ratio popup", "放大倍率弹出菜单"), ("Make Comp Size", "设为合成长宽"), + ("Markers", "标记"), ("Masks", "蒙版"), ("Maximize Panel", "最大化面板"), ("Media Replacement", "媒体替换"), @@ -1277,6 +1279,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("MIT OR Apache-2.0 • Fonts: Inter, JetBrains Mono (OFL)", "MIT 或 Apache-2.0 • 字型:Inter、JetBrains Mono(OFL)"), ("Magnification ratio popup", "放大倍率快顯選單"), ("Make Comp Size", "設為合成長寬"), + ("Markers", "標記"), ("Masks", "遮色片"), ("Maximize Panel", "最大化面板"), ("Media Replacement", "媒體替換"), @@ -1815,6 +1818,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("MIT OR Apache-2.0 • Fonts: Inter, JetBrains Mono (OFL)", "MIT OR Apache-2.0 • Шрифти: Inter, JetBrains Mono (OFL)"), ("Magnification ratio popup", "Меню коефіцієнта збільшення"), ("Make Comp Size", "За розміром композиції"), + ("Markers", "Маркери"), ("Masks", "Маски"), ("Maximize Panel", "Розгорнути панель"), ("Media Replacement", "Заміна матеріалу"), @@ -2351,6 +2355,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("MIT OR Apache-2.0 • Fonts: Inter, JetBrains Mono (OFL)", "MIT OU Apache-2.0 • Fontes: Inter, JetBrains Mono (OFL)"), ("Magnification ratio popup", "Menu de proporção de ampliação"), ("Make Comp Size", "Ajustar ao tamanho da composição"), + ("Markers", "Marcadores"), ("Masks", "Máscaras"), ("Maximize Panel", "Maximizar painel"), ("Media Replacement", "Substituição de mídia"), @@ -2795,6 +2800,7 @@ const CZECH: &[(&str, &str)] = &[ ("MIT OR Apache-2.0 • Fonts: Inter, JetBrains Mono (OFL)", "MIT OR Apache-2.0 • Písma: Inter, JetBrains Mono (OFL)"), ("Magnification ratio popup", "Nabídka míry přiblížení"), ("Make Comp Size", "Nastavit velikost kompozice"), + ("Markers", "Značky"), ("Masks", "Masky"), ("Maximize Panel", "Maximalizovat panel"), ("Media Replacement", "Nahrazení médií"), diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 72f104290..8f4a6be72 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3569,6 +3569,9 @@ pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], vector: bool, action item(ui, "Freeze Frame", "layer.freezeFrame", json!({"layers": layers})); item(ui, "Freeze on Last Frame", "layer.freezeOnLastFrame", json!({"layers": layers})); }); + ui.menu_button(crate::i18n::tr("Markers"), |ui| { + item(ui, "Add Marker", "layer.addMarker", json!({"layers": layers})); + }); ui.menu_button(crate::i18n::tr("Blending Mode"), |ui| { widgets::menu_scroll(ui, |ui| { for m in BlendMode::ALL { diff --git a/crates/ui-egui/tests/ui_blank_context_menus.rs b/crates/ui-egui/tests/ui_blank_context_menus.rs index fcbf3d09c..e6fc87b2c 100644 --- a/crates/ui-egui/tests/ui_blank_context_menus.rs +++ b/crates/ui-egui/tests/ui_blank_context_menus.rs @@ -226,3 +226,32 @@ fn context_menu_rows_leave_no_gap_between_them() { } snapshot(&mut h, "project-rows"); } + +/// #976: a layer's context menu has After Effects' Markers ▸ Add Marker, which marks the layer that +/// was right-clicked (not the one selected before, and not the composition) at the current time. +#[test] +fn layer_context_menu_adds_a_layer_marker() { + let mut h = harness(); + let solid = |h: &mut Harness<'_, EffectcraftApp>, name: &str| -> u64 { + h.state_mut().session.execute("layer.newSolid", json!({"name": name, "color": "#406080"})).unwrap()["layer"].as_u64().unwrap() + }; + let (below, top) = (solid(&mut h, "Below"), solid(&mut h, "Top")); + h.state_mut().session.execute("layer.select", json!({"layers": [below]})).unwrap(); + h.state_mut().session.execute("time.set", json!({"time": 1.5})).unwrap(); + h.run_steps(3); + let r = rect(&h, &format!("timeline.layer.{top}.row")); + right_click(&mut h, pos2(r.min.x + 210.0, r.center().y)); + h.get_by_label("Markers ⏵").click(); + h.run_steps(3); + snapshot(&mut h, "layer-markers"); + h.get_by_label("Add Marker").click(); + h.run_steps(3); + let now = h.state().session.time().seconds(); + let comp = h.state().session.active_comp().unwrap(); + let markers = |id: u64| -> Vec { comp.layer(effectcraft_engine::project::LayerId(id)).unwrap().markers.iter().map(|m| m.time.seconds()).collect() }; + assert_eq!(markers(top), vec![now]); + assert!((now - 1.5).abs() < 0.01, "at the current time: {now}"); + assert!(markers(below).is_empty(), "{:?}", markers(below)); + assert!(comp.markers.is_empty(), "no composition marker"); + assert_eq!(h.state().session.history.undo.last().map(|u| u.0.as_str()), Some("Add Marker")); +} From 424dcab40b8e5119f91ae40f02a0395bb498e3e8 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 14:43:18 +0200 Subject: [PATCH 021/181] Shortcuts: Numpad * marks the selected layers (#976) Numpad * always added a composition marker. As in After Effects, it now adds a layer marker to the selected layers at the current time, and a composition marker only when no layer is selected. --- crates/ui-egui/src/menus.rs | 5 +++++ crates/ui-egui/tests/ui_timeline_zoom.rs | 27 +++++++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b10b0e31c..dda1b617e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1744,6 +1744,11 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only // comes as text. if !tab_only && clipboard && app.dialog.is_none() && ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(t) if t == "*"))) { let bound = app.session.shortcuts().bindings().into_iter().find(|(k, _)| *k == "Num*").map(|(_, b)| (b.command.clone(), b.params.clone())); + // As in After Effects, it marks the selected layers; only with none selected does it add + // a composition marker (#976). + let bound = bound.map(|(id, params)| { + if id == "comp.addMarker" && !app.session.state.selected_layers.is_empty() { ("layer.addMarker".to_owned(), params) } else { (id, params) } + }); if let Some((id, params)) = bound && let Err(e) = invoke(app, ctx, &id, if params.is_null() { json!({}) } else { params }) { diff --git a/crates/ui-egui/tests/ui_timeline_zoom.rs b/crates/ui-egui/tests/ui_timeline_zoom.rs index 2c8026001..601bcd6fc 100644 --- a/crates/ui-egui/tests/ui_timeline_zoom.rs +++ b/crates/ui-egui/tests/ui_timeline_zoom.rs @@ -333,13 +333,38 @@ fn dragging_out_of_the_marker_bin_adds_a_comp_marker() { assert_eq!(markers(&h), vec![13.0]); let history = &h.state().session.history.undo; assert_eq!((history.len(), history.last().map(|u| u.0.as_str())), (undo + 1, Some("Add Marker")), "one undo step"); - // Numpad * (it arrives as text): Add Marker at the current time. + // Numpad * (it arrives as text) with no layer selected: Add Marker at the current time. + h.state_mut().session.execute("edit.deselectAll", json!({})).unwrap(); h.state_mut().session.execute("time.set", json!({"time": 20.0})).unwrap(); h.event(Event::Text("*".into())); h.run_steps(2); assert_eq!(markers(&h), vec![13.0, 20.0]); } +/// #976: as in After Effects, Numpad * marks the selected layers at the current time; only with +/// no layer selected does it add a composition marker. +#[test] +fn numpad_star_marks_the_selected_layer() { + let (mut h, layer) = harness(); + let comp_markers = + |h: &Harness<'_, EffectcraftApp>| -> Vec { h.state().session.active_comp().unwrap().markers.iter().map(|m| m.time.seconds()).collect() }; + let layer_markers = |h: &Harness<'_, EffectcraftApp>| -> Vec { + let comp = h.state().session.active_comp().unwrap(); + comp.layer(effectcraft_engine::project::LayerId(layer)).unwrap().markers.iter().map(|m| m.time.seconds()).collect() + }; + let star = |h: &mut Harness<'_, EffectcraftApp>, time: f64| { + h.state_mut().session.execute("time.set", json!({"time": time})).unwrap(); + h.event(Event::Text("*".into())); + h.run_steps(2); + }; + h.state_mut().session.execute("layer.select", json!({"layers": [layer]})).unwrap(); + star(&mut h, 2.0); + assert_eq!((layer_markers(&h), comp_markers(&h)), (vec![2.0], vec![])); + h.state_mut().session.execute("edit.deselectAll", json!({})).unwrap(); + star(&mut h, 3.0); + assert_eq!((layer_markers(&h), comp_markers(&h)), (vec![2.0], vec![3.0])); +} + /// #468: zoomed in, a CTI scrub held past the right end of the ruler scrolls the timeline (the /// CTI rides the edge), past the left end scrolls it back; during playback the view pages to /// keep the CTI in sight. From dc1b048f8ac7c1c80121b793aef5a74b884fd8ac Mon Sep 17 00:00:00 2001 From: FeiLiuEM Date: Mon, 12 Oct 2026 20:49:02 +0800 Subject: [PATCH 022/181] text: the Chinese fallback families follow the script the locale asks for The Han fallback list carried a Simplified and a Traditional face for every platform (Microsoft YaHei next to Microsoft JhengHei, PingFang SC next to PingFang TC), Simplified first. The fallback search keeps the first family whose face covers the character, and the two scripts share their code points - only the glyph forms differ - so a zh-hant interface was drawn with Simplified forms, and a Traditional face could not win on a machine that also had a Simplified one. The list is now two, picked by the script the locale in force asks for: Hant, or a Taiwan, Hong Kong or Macao region. A bare zh stays Simplified, as the interface's own locale mapping resolves it. `asks_traditional_chinese` normalises case and separators, and `chinese_families_for` is pure, so the choice is testable without installed fonts. The Han order and the Japanese and Korean lists are untouched. Verified: cargo fmt --all --check clean, cargo clippy -p effectcraft-text --all-targets -- -D warnings clean, cargo test -p effectcraft-text 54 tests pass. --- crates/text/src/fonts.rs | 65 ++++++++++++++++++++++++++++++---------- 1 file changed, 49 insertions(+), 16 deletions(-) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 4091344d2..7ef9c1d82 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -588,21 +588,16 @@ const JAPANESE_FAMILIES: &[&str] = &[ "IPAGothic", "VL Gothic", ]; -const CHINESE_FAMILIES: &[&str] = &[ - "Microsoft YaHei", - "Microsoft JhengHei", - "SimSun", - "MingLiU", - "PingFang SC", - "PingFang TC", - "Noto Sans CJK SC", - "Noto Sans CJK TC", - "Noto Sans SC", - "Noto Sans TC", - "Source Han Sans SC", - "Source Han Sans TC", - "WenQuanYi Micro Hei", -]; +/// Han ideograph families for Simplified Chinese, in the order the platforms' own fallback tables +/// prefer them (Windows, macOS, then the Linux/Noto names). Missing ones cost one name lookup each. +const SIMPLIFIED_CHINESE_FAMILIES: &[&str] = + &["Microsoft YaHei", "SimSun", "PingFang SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "WenQuanYi Micro Hei"]; +/// The same for Traditional Chinese. The two scripts share almost all of their code points but not +/// the glyph forms (`东` against `東`'s neighbours, `直`, `骨`), so one list for both drew a +/// Traditional interface with Simplified forms - the faces do not differ in *coverage*, which is +/// what the fallback search checks, so the script has to pick the list. +const TRADITIONAL_CHINESE_FAMILIES: &[&str] = + &["Microsoft JhengHei", "MingLiU", "PingFang TC", "Noto Sans CJK TC", "Noto Sans TC", "Source Han Sans TC", "WenQuanYi Micro Hei"]; const KOREAN_FAMILIES: &[&str] = &["Malgun Gothic", "Gulim", "Apple SD Gothic Neo", "Noto Sans CJK KR", "Noto Sans KR", "Source Han Sans KR", "NanumGothic"]; /// The platforms' plain text families for the other scripts the bundled fonts lack, in the same /// order (Windows, macOS, then the Linux/Noto names). @@ -691,10 +686,29 @@ fn han_order() -> [FallbackScript; 3] { han_order_for(&active_cjk_locale()) } +/// Whether a CJK tag asks for Traditional Chinese: a `Hant` script, or a Taiwan, Hong Kong or +/// Macao region (`zh-TW`, `zh_HK`, `zh_MO.UTF-8`). Anything else Chinese - including a bare `zh` - +/// is Simplified, as in the interface's own locale mapping. +fn asks_traditional_chinese(tag: &str) -> bool { + tag.to_ascii_lowercase().split(['-', '_', '.']).any(|p| matches!(p, "hant" | "tw" | "hk" | "mo")) +} + +/// The Chinese families to try first for `tag`: one list per script, since coverage does not tell +/// the two apart - a Simplified face covers the Traditional glyphs' code points, so the fallback +/// search would keep it and draw the wrong forms. +fn chinese_families_for(tag: &str) -> &'static [&'static str] { + if asks_traditional_chinese(tag) { TRADITIONAL_CHINESE_FAMILIES } else { SIMPLIFIED_CHINESE_FAMILIES } +} + +/// [`chinese_families_for`] for the locale in force ([`active_cjk_locale`]). +fn chinese_families() -> &'static [&'static str] { + chinese_families_for(&active_cjk_locale()) +} + fn families_for(s: FallbackScript) -> &'static [&'static str] { match s { FallbackScript::Japanese => JAPANESE_FAMILIES, - FallbackScript::Han => CHINESE_FAMILIES, + FallbackScript::Han => chinese_families(), FallbackScript::Korean => KOREAN_FAMILIES, FallbackScript::Arabic => ARABIC_FAMILIES, FallbackScript::Hebrew => HEBREW_FAMILIES, @@ -1079,6 +1093,25 @@ mod tests { assert_eq!(han_order(), han_order_for(&active_cjk_locale())); } + #[test] + fn the_chinese_family_list_follows_the_script_the_locale_asks_for() { + for tag in ["zh", "zh-CN", "zh_Hans", "zh_CN.UTF-8", "zh-SG"] { + assert_eq!(chinese_families_for(tag), SIMPLIFIED_CHINESE_FAMILIES, "{tag}"); + } + for tag in ["zh-Hant", "zh-Hant-TW", "zh_TW", "zh-HK", "zh_MO.UTF-8"] { + assert_eq!(chinese_families_for(tag), TRADITIONAL_CHINESE_FAMILIES, "{tag}"); + } + // Each list carries its own script's faces and not the other's, which is the point of + // splitting them: `PingFang SC` covers the same code points as `PingFang TC`, so the + // fallback search would have kept it and drawn Simplified forms in a Traditional interface. + assert!(SIMPLIFIED_CHINESE_FAMILIES.iter().any(|f| f.contains("PingFang SC") && !f.contains("Source"))); + assert!(!SIMPLIFIED_CHINESE_FAMILIES.iter().any(|f| f.contains("TC") || f.contains("JhengHei"))); + assert!(TRADITIONAL_CHINESE_FAMILIES.iter().any(|f| f.contains("PingFang TC"))); + assert!(!TRADITIONAL_CHINESE_FAMILIES.iter().any(|f| f.contains(" SC") || f.contains("YaHei"))); + // Han text is the only thing these lists serve; the Japanese order does not change. + assert_eq!(han_order_for("ja_JP.UTF-8")[0], FallbackScript::Japanese); + } + #[test] fn bundled_ui_fonts_cover_the_ukrainian_alphabet() { const ALPHABET: &str = "АБВГҐДЕЄЖЗИІЇЙКЛМНОПРСТУФХЦЧШЩЬЮЯабвгґдеєжзиіїйклмнопрстуфхцчшщьюя’«»"; From fbb74339da4a632609e68ce294e0b8f1be323d31 Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:07:05 +0200 Subject: [PATCH 023/181] GPU Information: Enter presses OK The message box's only button is OK, the default; Enter now dismisses it (back to Settings when opened from there), as Escape already does. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/forms.rs | 3 ++- crates/ui-egui/tests/ui_settings_info.rs | 31 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 2193b605d..5099cc4bb 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -905,7 +905,8 @@ pub(crate) fn close_info(app: &mut EffectcraftApp) { pub fn show_info(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let (title, body) = app.dialog_state.info.clone(); - let mut close = false; + // Enter presses OK, the default (and only) button. + let mut close = !ctx.egui_wants_keyboard_input() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); // The text wraps within this small dialog; avoid a horizontal scrollbar changing its height. super::dialogs::modal_vertical(ctx, &title, vec2(440.0, 160.0), t, |ui| { ui.label(tr(&body)); diff --git a/crates/ui-egui/tests/ui_settings_info.rs b/crates/ui-egui/tests/ui_settings_info.rs index 9d87eb049..955a709b4 100644 --- a/crates/ui-egui/tests/ui_settings_info.rs +++ b/crates/ui-egui/tests/ui_settings_info.rs @@ -154,3 +154,34 @@ fn gpu_information_dialog_stays_in_place() { assert!((current.min - start.min).length() < 1.0, "GPU Information dialog moved"); } } + +/// Enter presses OK, the dialog's default button: back to Settings when it was opened from +/// there, closed otherwise. +#[test] +fn gpu_information_enter_presses_ok() { + let mut session = Session::default(); + session.execute("comp.new", json!({"width":32,"height":32,"duration":2})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(session)); + h.run_steps(4); + let enter = |h: &mut Harness<'_, EffectcraftApp>| { + for pressed in [true, false] { + h.event(Event::Key { key: Key::Enter, physical_key: Some(Key::Enter), pressed, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + }; + let ctx = h.ctx.clone(); + menus::invoke(h.state_mut(), &ctx, "app.gpuInfo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(Dialog::Info)); + enter(&mut h); + assert!(h.state().dialog.is_none(), "Enter closed GPU Information"); + + menus::invoke(h.state_mut(), &ctx, "app.settings", json!({"page":"previews"})).unwrap(); + h.run_steps(3); + let at = rect(&h, "settings.button.GPU Information").center(); + click(&mut h, at); + assert_eq!(h.state().dialog, Some(Dialog::Info)); + enter(&mut h); + assert_eq!(h.state().dialog, Some(Dialog::Settings), "Enter returned to the pending Settings transaction"); +} From b0ea0e787b415644027d5b2a47454fc5b7681fae Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:51:22 +0200 Subject: [PATCH 024/181] UI: confirm before deleting a user template or a shortcut preset Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ui.rs | 4 + crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menus.rs | 11 +- crates/ui-egui/src/panels/dialogs.rs | 8 +- crates/ui-egui/src/panels/shortcut_editor.rs | 91 +++++++++++++++- crates/ui-egui/src/panels/templates.rs | 103 ++++++++++++++++--- 6 files changed, 200 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index f31d0951c..2383e928c 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -513,6 +513,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Welcome to EffectCraft", "EffectCraftへようこそ"), ("Width:", "幅:"), ("You can undo this.", "この操作は取り消せます。"), + ("You can't undo this.", "この操作は取り消せません。"), ("Your changes will be lost if you don't save them.", "保存しないと、変更内容は失われます。"), ("Zoom", "ズーム"), ("built-in", "組み込み"), @@ -959,6 +960,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Welcome to EffectCraft", "欢迎使用 EffectCraft"), ("Width:", "宽度:"), ("You can undo this.", "您可以撤销此操作。"), + ("You can't undo this.", "此操作无法撤销。"), ("Your changes will be lost if you don't save them.", "如果不存储,你的更改将会丢失。"), ("Zoom", "缩放"), ("built-in", "内置"), @@ -1494,6 +1496,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Welcome to EffectCraft", "歡迎使用 EffectCraft"), ("Width:", "寬度:"), ("You can undo this.", "您可以復原此操作。"), + ("You can't undo this.", "此操作無法復原。"), ("Your changes will be lost if you don't save them.", "如果不儲存,你的更改將會丟失。"), ("Zoom", "縮放"), ("built-in", "內建"), @@ -2043,6 +2046,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Welcome to EffectCraft", "Ласкаво просимо до EffectCraft"), ("Width:", "Ширина:"), ("You can undo this.", "Це можна скасувати."), + ("You can't undo this.", "Це не можна скасувати."), ("Your changes will be lost if you don't save them.", "Якщо не зберегти зміни, їх буде втрачено."), ("Zoom", "Масштаб"), ("built-in", "вбудований"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 746d917bc..a5d1c668d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -97,6 +97,8 @@ pub enum Dialog { DeleteItems, /// A gradient property's Gradient Editor (`panels::gradient_editor`, `prop.editGradient`). GradientEditor, + /// Delete a user template from Home? (`panels::templates`). + DeleteTemplate, } /// The desktop's light or dark appearance where egui cannot see it (Wayland compositors that send diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b10b0e31c..e626fd555 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1661,7 +1661,11 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only // Dialog cancellation owns Escape even when a text field has keyboard focus. if !tab_only && app.dialog.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) { // A popup or colour picker takes Escape first (the picker goes back to its original colour). - if crate::panels::shortcut_editor::recording(app) || egui::Popup::is_any_open(ctx) || crate::color_picker::open_recently(ctx) { + if crate::panels::shortcut_editor::recording(app) + || crate::panels::shortcut_editor::confirming(app) + || egui::Popup::is_any_open(ctx) + || crate::color_picker::open_recently(ctx) + { return; } ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); @@ -1770,8 +1774,9 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only } // Escape closes dialogs. if key == egui::Key::Escape && app.dialog.is_some() { - // Escape while recording a shortcut cancels the recording, not the editor. - if crate::panels::shortcut_editor::recording(app) { + // Escape while recording a shortcut cancels the recording, not the editor; so does + // Escape in its Delete Preset prompt. + if crate::panels::shortcut_editor::recording(app) || crate::panels::shortcut_editor::confirming(app) { continue; } if app.dialog == Some(crate::Dialog::Info) { diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index e95a667ec..f38da3f5e 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -62,6 +62,8 @@ pub struct DialogState { pub delete_items: super::delete_items::Pending, /// The Gradient Editor's draft and the property it edits. pub gradient: super::gradient_editor::Draft, + /// The user template the Delete Template prompt asks about: its id and name. + pub delete_template: Option<(String, String)>, } pub fn open_new_comp(app: &mut EffectcraftApp) { @@ -221,6 +223,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) { Dialog::UnsavedChanges => super::unsaved::show(app, ctx, &t), Dialog::DeleteItems => super::delete_items::show(app, ctx, &t), Dialog::GradientEditor => super::gradient_editor::show(app, ctx, &t), + Dialog::DeleteTemplate => super::templates::show_delete(app, ctx, &t), } } @@ -389,11 +392,12 @@ pub(crate) fn press_key(app: &mut EffectcraftApp, ctx: &egui::Context, key: egui } /// The letter that answers the open confirmation with Cmd (Ctrl elsewhere): Cmd+D is Delete in -/// Delete Items, Cmd+R is Revert in Revert Project. The native menu bar hands these keys back +/// Delete Items, Delete Template and Delete Preset, Cmd+R is Revert in Revert Project. The native menu bar hands these keys back /// while the prompt is open ([`crate::native_menu::activate`]). pub(crate) fn command_key(app: &EffectcraftApp) -> Option { match app.dialog? { - Dialog::DeleteItems => Some(egui::Key::D), + Dialog::DeleteItems | Dialog::DeleteTemplate => Some(egui::Key::D), + Dialog::Shortcuts if super::shortcut_editor::confirming(app) => Some(egui::Key::D), Dialog::UnsavedChanges if super::unsaved::is_revert(app) => Some(egui::Key::R), _ => None, } diff --git a/crates/ui-egui/src/panels/shortcut_editor.rs b/crates/ui-egui/src/panels/shortcut_editor.rs index c8948ab8c..21213103e 100644 --- a/crates/ui-egui/src/panels/shortcut_editor.rs +++ b/crates/ui-egui/src/panels/shortcut_editor.rs @@ -12,7 +12,8 @@ //! Automation ids: `shortcuts.preset`, `shortcuts.search`, `shortcuts.mod.`, //! `shortcuts.key.`, `shortcuts.row.`, `shortcuts.record`, `shortcuts.assign`, //! `shortcuts.removeKey.`, `shortcuts.resetCommand`, `shortcuts.duplicate`, -//! `shortcuts.rename`, `shortcuts.delete`, `shortcuts.import`, `shortcuts.export`, +//! `shortcuts.rename`, `shortcuts.delete` (asks first: `dialog.deletePreset.delete`, +//! `dialog.deletePreset.cancel`), `shortcuts.import`, `shortcuts.export`, //! `shortcuts.resetPreset`, `shortcuts.close`. use crate::i18n::{tr, tr_args}; @@ -39,6 +40,8 @@ pub struct EditorState { pub pending: String, /// Rename field for the active preset. pub rename: Option, + /// The preset the Delete Preset prompt asks about. + pub confirm_delete: Option, } const APP_COLOR: Color32 = Color32::from_rgb(0x8e, 0x6c, 0xe8); @@ -54,6 +57,11 @@ pub fn recording(app: &EffectcraftApp) -> bool { app.dialog == Some(Dialog::Shortcuts) && app.dialog_state.shortcuts.recording } +/// `true` while Delete Preset asks over the editor (Escape answers it, not the editor). +pub fn confirming(app: &EffectcraftApp) -> bool { + app.dialog == Some(Dialog::Shortcuts) && app.dialog_state.shortcuts.confirm_delete.is_some() +} + /// Shortcut text of a key event (`Cmd+Shift+K`). pub fn combo_text(m: egui::Modifiers, k: egui::Key) -> Option { use egui::Key::*; @@ -269,10 +277,11 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { st.rename = Some(active.clone()); } } + // A deleted preset can't be brought back: Delete asks first (the prompt below). let b = ui.add_enabled(custom, egui::Button::new(tr("Delete"))); app.auto.add("shortcuts.delete", b.rect, "Delete"); if b.clicked() { - run.push(("shortcuts.preset".into(), json!({"op": "delete", "name": active}))); + st.confirm_delete = Some(active.clone()); } let b = ui.add_enabled(custom, egui::Button::new(tr("Reset Preset"))); app.auto.add("shortcuts.resetPreset", b.rect, "Reset Preset"); @@ -559,6 +568,41 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { close |= r.clicked(); }); }); + // Delete Preset, over the editor. + if let Some(name) = st.confirm_delete.clone() { + let mut answer: Option = None; + super::dialogs::modal(ctx, "Delete Preset", vec2(460.0, 150.0), t, |ui| { + ui.label(RichText::new(format!("Delete the preset “{name}”?")).color(t.tab_text_active)); + ui.add_space(4.0); + ui.label(RichText::new(tr("You can't undo this.")).color(t.text_dim).size(11.0)); + ui.add_space(18.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let r = ui.add(egui::Button::new(RichText::new(tr(" Delete ")).color(Color32::WHITE)).fill(t.danger)); + app.auto.add("dialog.deletePreset.delete", r.rect, "Delete"); + if r.clicked() { + answer = Some(true); + } + let r = ui.button(tr("Cancel")); + app.auto.add("dialog.deletePreset.cancel", r.rect, "Cancel"); + if r.clicked() { + answer = Some(false); + } + }); + }); + if answer.is_none() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + answer = Some(false); + } + // Cmd+D (Ctrl elsewhere) is Delete. + if answer.is_none() && super::dialogs::ui_command_key(ctx, egui::Key::D) { + answer = Some(true); + } + if let Some(delete) = answer { + st.confirm_delete = None; + if delete { + run.push(("shortcuts.preset".into(), json!({"op": "delete", "name": name}))); + } + } + } app.dialog_state.shortcuts = st; for (cmd, params) in run { if let Err(e) = app.session.execute(&cmd, params) { @@ -603,6 +647,49 @@ mod tests { out.textures_delta.clear(); } + #[test] + fn deleting_a_preset_asks_first() { + fn click(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str) { + let [x, y, w, h] = app.auto.find(id).unwrap_or_else(|| panic!("{id} is not on screen")).rect; + let pos = pos2(x + w / 2.0, y + h / 2.0); + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + for event in [egui::Event::PointerMoved(pos), button(true), button(false)] { + frame(app, ctx, vec![event]); + } + // Two more: the automation ids of the clicked frame age out. + frame(app, ctx, vec![]); + frame(app, ctx, vec![]); + } + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + app.session.execute("shortcuts.preset", json!({"op": "duplicate", "from": DEFAULT_PRESET, "name": "Mine"})).unwrap(); + app.session.execute("shortcuts.preset", json!({"op": "select", "name": "Mine"})).unwrap(); + let has = |app: &EffectcraftApp| app.session.keymaps.names().iter().any(|n| n == "Mine"); + open(&mut app); + for _ in 0..3 { + frame(&mut app, &ctx, vec![]); + } + assert!(app.auto.find("dialog.deletePreset.delete").is_none()); + click(&mut app, &ctx, "shortcuts.delete"); + assert!(has(&app) && confirming(&app), "the click only asks"); + click(&mut app, &ctx, "dialog.deletePreset.cancel"); + assert!(has(&app) && app.auto.find("dialog.deletePreset.delete").is_none(), "Cancel keeps the preset"); + assert_eq!(app.dialog, Some(Dialog::Shortcuts), "the editor stays open"); + click(&mut app, &ctx, "shortcuts.delete"); + click(&mut app, &ctx, "dialog.deletePreset.delete"); + assert!(!has(&app), "confirmed: the preset is gone"); + assert!(!confirming(&app) && app.dialog == Some(Dialog::Shortcuts)); + // Cmd+D is Delete; Enter is not. + app.session.execute("shortcuts.preset", json!({"op": "duplicate", "from": DEFAULT_PRESET, "name": "Mine"})).unwrap(); + app.session.execute("shortcuts.preset", json!({"op": "select", "name": "Mine"})).unwrap(); + app.dialog_state.shortcuts.confirm_delete = Some("Mine".into()); + crate::panels::dialogs::press_key(&mut app, &ctx, egui::Key::Enter, egui::Modifiers::NONE); + assert!(has(&app) && confirming(&app)); + crate::panels::dialogs::press_key(&mut app, &ctx, egui::Key::D, egui::Modifiers::COMMAND); + assert!(!has(&app) && !confirming(&app)); + } + #[test] fn recording_a_shortcut_does_not_also_type_the_key_into_the_field() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); diff --git a/crates/ui-egui/src/panels/templates.rs b/crates/ui-egui/src/panels/templates.rs index 6c0a376a7..ec65ee6d7 100644 --- a/crates/ui-egui/src/panels/templates.rs +++ b/crates/ui-egui/src/panels/templates.rs @@ -4,7 +4,10 @@ //! are rendered by our own renderer, one per frame, the first time the gallery shows them. //! //! Automation ids: `home.templates.` (card), `home.templates..open`, -//! `home.templates..delete` (user templates), `home.templates.saveCurrent`. +//! `home.templates..delete` (user templates), `home.templates.saveCurrent`. Deleting a user +//! template removes its file and can't be undone, so the card's delete button asks first +//! (`dialog.deleteTemplate.delete`, `dialog.deleteTemplate.cancel`); agents running +//! `templates.delete` with `engine.execute` are never asked. use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::json; @@ -13,7 +16,7 @@ use effectcraft_engine::templates::{self, THUMB_H, THUMB_W, TemplateInfo}; use crate::icons::{self, Icon}; use crate::theme::Tokens; -use crate::{EffectcraftApp, widgets}; +use crate::{Dialog, EffectcraftApp, widgets}; /// The gallery's entries (re-listed when the user template files change). pub fn entries(app: &mut EffectcraftApp) -> Vec { @@ -87,7 +90,7 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) { let card_h = thumb_h + 62.0; let view = Rect::from_min_max(pos2(x, top), area.max); let mut open: Option = None; - let mut delete: Option = None; + let mut delete: Option<(String, String)> = None; let mut budget = true; let mut child = ui.new_child(egui::UiBuilder::new().max_rect(view).layout(egui::Layout::top_down(egui::Align::Min))); egui::ScrollArea::vertical().id_salt("home-templates-scroll").auto_shrink([false, false]).show(&mut child, |ui| { @@ -142,7 +145,7 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) { app.auto.add(&format!("{base}.delete"), dr, "Delete Template"); if dresp.clicked() { del_hit = true; - delete = Some(tpl.id.clone()); + delete = Some((tpl.id.clone(), tpl.name.clone())); } } if resp.clicked() && !del_hit { @@ -150,13 +153,9 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) { } } }); - if let Some(id) = delete { - match app.session.execute("templates.delete", json!({"id": id})) { - Ok(_) => { - app.template_thumbs.remove(&id); - } - Err(e) => app.ui.status = e.to_string(), - } + if let Some(pending) = delete { + app.dialog_state.delete_template = Some(pending); + app.dialog = Some(Dialog::DeleteTemplate); } else if let Some(id) = open { // A modified project asks to save first (the template opens once answered). if super::unsaved::guard(app, "templates.create", &json!({"id": id})) { @@ -169,6 +168,52 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) { } } +/// Delete Template: the user template waiting on the prompt (`dialog_state.delete_template`, its +/// id and name). +pub fn show_delete(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { + let Some((id, name)) = app.dialog_state.delete_template.clone() else { + app.dialog = None; + return; + }; + let mut answer: Option = None; + super::dialogs::modal(ctx, "Delete Template", vec2(460.0, 150.0), t, |ui| { + ui.label(egui::RichText::new(format!("Delete the template “{name}”?")).color(t.tab_text_active)); + ui.add_space(4.0); + ui.label(egui::RichText::new(crate::i18n::tr("You can't undo this.")).color(t.text_dim).size(11.0)); + ui.add_space(18.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let r = ui.add(egui::Button::new(egui::RichText::new(crate::i18n::tr(" Delete ")).color(Color32::WHITE)).fill(t.danger)); + app.auto.add("dialog.deleteTemplate.delete", r.rect, "Delete"); + if r.clicked() { + answer = Some(true); + } + let r = ui.button(crate::i18n::tr("Cancel")); + app.auto.add("dialog.deleteTemplate.cancel", r.rect, "Cancel"); + if r.clicked() { + answer = Some(false); + } + }); + }); + if answer.is_none() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + answer = Some(false); + } + // Cmd+D (Ctrl elsewhere) is Delete. + if answer.is_none() && super::dialogs::ui_command_key(ctx, egui::Key::D) { + answer = Some(true); + } + let Some(delete) = answer else { return }; + app.dialog = None; + app.dialog_state.delete_template = None; + if delete { + match app.session.execute("templates.delete", json!({"id": id})) { + Ok(_) => { + app.template_thumbs.remove(&id); + } + Err(e) => app.ui.status = e.to_string(), + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -185,6 +230,27 @@ mod tests { .clear(); } + /// Click the widget registered as `id`, then draw the frame that follows. + fn click(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str) { + let [x, y, w, h] = app.auto.find(id).unwrap_or_else(|| panic!("{id} is not on screen")).rect; + let pos = pos2(x + w / 2.0, y + h / 2.0); + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + for event in [egui::Event::PointerMoved(pos), button(true), button(false)] { + ctx.run_ui(egui::RawInput { events: vec![event], ..Default::default() }, |ui| { + app.auto.begin_frame(); + if app.ui.start_screen { + crate::panels::home::show(app, ui, Rect::from_min_size(pos2(0.0, 0.0), vec2(1280.0, 860.0))); + } + crate::panels::dialogs::show(app, ui.ctx()); + }) + .textures_delta + .clear(); + } + // Two more: a new dialog is laid out, and the automation ids of the clicked frame age out. + frame(app, ctx); + frame(app, ctx); + } + fn app_with_store() -> EffectcraftApp { let s = effectcraft_engine::Session { config: Some(std::sync::Arc::new(effectcraft_engine::config::MemoryConfig::default())), ..Default::default() }; EffectcraftApp::new(s) @@ -240,10 +306,23 @@ mod tests { app.session.execute("templates.create", json!({"id": mine})).unwrap(); assert!(app.session.path.is_none()); assert!(app.session.active_comp().is_some_and(|c| c.layers.iter().any(|l| l.name == "BRAND NAME"))); - app.session.execute("templates.delete", json!({"id": mine})).unwrap(); + // The card's delete button asks first: the file can't be brought back. + app.ui.start_screen = true; frame(&mut app, &ctx); + click(&mut app, &ctx, &format!("home.templates.{mine}.delete")); + assert_eq!(app.dialog, Some(Dialog::DeleteTemplate)); + assert!(app.session.path.is_none() && entries(&mut app).iter().any(|t| t.id == mine), "nothing deleted or opened yet"); + click(&mut app, &ctx, "dialog.deleteTemplate.cancel"); + assert!(app.dialog.is_none() && entries(&mut app).iter().any(|t| t.id == mine), "Cancel keeps the template"); + // Enter is not Delete; Cmd+D is. + click(&mut app, &ctx, &format!("home.templates.{mine}.delete")); + crate::panels::dialogs::press_key(&mut app, &ctx, egui::Key::Enter, egui::Modifiers::NONE); + assert_eq!(app.dialog, Some(Dialog::DeleteTemplate)); + crate::panels::dialogs::press_key(&mut app, &ctx, egui::Key::D, egui::Modifiers::COMMAND); + assert!(app.dialog.is_none() && app.dialog_state.delete_template.is_none()); frame(&mut app, &ctx); assert!(app.auto.find(&format!("home.templates.{mine}")).is_none()); + assert!(!entries(&mut app).iter().any(|t| t.id == mine)); } #[test] From 63cfcfbb907127ee75b6bb2aea14cb6d1409d0f4 Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:13:18 +0200 Subject: [PATCH 025/181] Timeline: UU reveals an effect's changed parameters only The modified reveal showed every effect in full. It now compares each effect with a new instance and reveals the parameters whose value differs, plus the groups a new instance lacks (puppet meshes and pins, paint strokes). An effect whose plug-in is missing still shows in full. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/animation.rs | 41 +++++++++++++++++++++++-- crates/engine/src/tests_timeline.rs | 21 +++++++++++++ crates/ui-egui/src/panels/timeline.rs | 11 +++++-- 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/commands/animation.rs b/crates/engine/src/commands/animation.rs index c700123d7..a94256834 100644 --- a/crates/engine/src/commands/animation.rs +++ b/crates/engine/src/commands/animation.rs @@ -349,6 +349,25 @@ fn changed_from(g: &PropGroup, default: &PropGroup, out: &mut Vec) { } } +/// What of effect group `g` differs from `new`, a new instance of the effect: parameters with +/// another value, and in full the groups a new instance lacks (puppet meshes and pins, paint +/// strokes: what the user made). +fn effect_changes(g: &PropGroup, new: &PropGroup, out: &mut Vec) { + for c in &g.children { + match c { + Node::Prop(pr) => { + if new.get(&pr.match_id).is_some_and(|d| d.value != pr.value) && !out.contains(&pr.uid) { + out.push(pr.uid); + } + } + Node::Group(sg) => match new.sub(&sg.match_id) { + Some(d) => effect_changes(sg, d, out), + None => out.push(sg.uid), + }, + } + } +} + fn reveal(s: &mut Session, p: &Value) -> Result { let kind = str_p(p, "kind").unwrap_or("keyframes").to_string(); let cid = super::comp_id(s, p)?; @@ -389,12 +408,30 @@ fn reveal(s: &mut Session, p: &Value) -> Result { found.push(pr.uid); } }); - // Modified: effects and masks count as modifications in full (AE reveals them). - if kind == "modified" && matches!(top.match_id.as_str(), "effects" | "masks") { + // Modified: masks count as modifications in full (AE reveals them). + if kind == "modified" && top.match_id == "masks" { for g in top.groups() { found.push(g.uid); } } + // Modified: an effect's parameters that differ from a new instance's. + if kind == "modified" && top.match_id == "effects" { + let size = effectcraft_render::effect_bounds(&s.project, comp, l).0; + for g in top.groups() { + let spec = match &g.kind { + GroupKind::Effect { effect } => effectcraft_effects::find(effect), + _ => None, + }; + // A missing plug-in's defaults aren't known: the effect shows in full. + let Some(spec) = spec else { + found.push(g.uid); + continue; + }; + let mut next = 0; + let new = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), "", size); + effect_changes(g, &new, &mut found); + } + } if kind == "modified" && top.match_id == "text" { for g in top.group("animators").into_iter().flat_map(|a| a.groups()) { found.push(g.uid); diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 009af9e52..665e18be9 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -814,6 +814,27 @@ fn modified_reveal_takes_in_options_and_layer_styles() { assert_eq!(modified(&mut s, light), ["/lightOptions/intensity"]); } +/// UU shows the parameters of an effect that differ from a new instance's, not the whole effect: +/// a just-applied effect shows nothing, and a reset parameter goes again. +#[test] +fn modified_reveal_shows_changed_effect_parameters_only() { + let (mut s, l) = setup(); + s.execute("layer.select", json!({"layers": [l]})).unwrap(); + s.execute("effect.apply", json!({"effect": "ec.blur.gaussian"})).unwrap(); + let modified = |s: &mut Session| -> Vec { + let r = s.execute("anim.reveal", json!({"kind": "modified"})).unwrap(); + r["props"].as_array().unwrap().iter().filter_map(|p| p["prop"].as_u64()).map(|u| path_of(s, l, u)).collect() + }; + assert!(modified(&mut s).is_empty(), "{:?}", modified(&mut s)); + s.execute("prop.set", json!({"layer": l, "path": "effects/#1/blurriness", "value": 12})).unwrap(); + assert_eq!(modified(&mut s), ["/effects/ec.blur.gaussian/blurriness"]); + // Keyframed at its default value: animated, so still shown. + s.execute("prop.set", json!({"layer": l, "path": "effects/#1/blurriness", "value": 0})).unwrap(); + assert!(modified(&mut s).is_empty(), "{:?}", modified(&mut s)); + s.execute("prop.toggleAnimation", json!({"layer": l, "path": "effects/#1/blurriness"})).unwrap(); + assert_eq!(modified(&mut s), ["/effects/ec.blur.gaussian/blurriness"]); +} + /// Enable Time Remapping on a layer extended past its source's end (its last frame frozen) puts /// the end key at the source's end, not at the extended out point (#290). #[test] diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 72f104290..8cb05101d 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3980,6 +3980,8 @@ mod tests { let n_keys = app.ui.timeline.reveal_props.len(); assert_eq!(app.ui.timeline.reveal, vec!["props"]); assert!(n_keys >= 1); + let bx = app.session.state.selected_layers[0].0; + app.session.execute("prop.set", json!({"layer": bx, "path": "effects/#1/blurriness", "value": 12})).unwrap(); crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap(); assert!(app.ui.timeline.reveal_props.len() > n_keys, "UU reveals modified properties too"); } @@ -4007,9 +4009,14 @@ mod tests { let at = |n: &str| rows.iter().position(|r| r == n).unwrap(); assert_eq!(rows[at("Plate") + 1].trim(), "Scale", "{rows:?}"); assert_eq!(rows[at("Box") + 1].trim(), "Position", "{rows:?}"); - // UU (modified), then U later: Box shows its keyframed properties only again. + // UU (modified): the effect's changed parameter only, under the effect twirled open. + // Then U later: Box shows its keyframed properties only again. + app.session.execute("prop.set", json!({"layer": bx, "path": "effects/#1/blurriness", "value": 12})).unwrap(); crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap(); - assert!(labels(&app).iter().any(|r| r.trim() == ">Gaussian Blur"), "UU shows the effect"); + let rows = labels(&app); + let fx = rows.iter().position(|r| r.trim().ends_with("Gaussian Blur")).unwrap_or_else(|| panic!("UU shows the effect: {rows:?}")); + assert_eq!(rows[fx + 1].trim(), "Blurriness", "{rows:?}"); + assert!(!rows.iter().any(|r| r.trim() == "Blur Dimensions"), "unchanged parameters stay hidden: {rows:?}"); app.last_reveal = Some(("keyframes".into(), -10.0)); crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap(); app.last_reveal = Some(("keyframes".into(), -10.0)); From fcf56415dcba10db58b4edd0d102bb1d287811cb Mon Sep 17 00:00:00 2001 From: Themolx <183303841+Themolx@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:14:43 +0200 Subject: [PATCH 026/181] Project panel: Replace with Precomposition (used N times) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-clicking a footage item or solid in the Project panel offers "Replace with Precomposition (used N times)": the item goes into a new composition of its size, pixel aspect and frame rate, in the same folder, and every layer in every composition that used it switches to that composition, keeping timing, transforms, effects and masks. One undo step. Agents run it as `file.replaceWithPrecomp`. The Project panel's details header also shows how often the selected item is used ("Name ▾, used 3 times"); the ▾ lists and opens the comps that use it. `Project::item_users` and the `project.usedIn` query back both. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/app_more.rs | 70 ++++++++++++++++++++- crates/engine/src/commands/project_items.rs | 17 +++++ crates/engine/src/tests_m145.rs | 41 ++++++++++++ crates/engine/src/tests_project_items.rs | 21 +++++++ crates/project/src/lib.rs | 17 +++++ crates/project/src/tests.rs | 24 +++++++ crates/ui-egui/src/i18n/ui.rs | 24 +++++++ crates/ui-egui/src/panels/project.rs | 61 +++++++++++++++++- docs/agents.md | 3 + docs/footage.md | 7 +++ 10 files changed, 282 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/commands/app_more.rs b/crates/engine/src/commands/app_more.rs index 9f6b7c99c..e435169c8 100644 --- a/crates/engine/src/commands/app_more.rs +++ b/crates/engine/src/commands/app_more.rs @@ -3,8 +3,10 @@ //! (Create VR Environment, Extract Cubemap), the "Assign Shortcut to …" submenus, Help ▸ Enable //! Logging / Reveal Logging File / System Compatibility Report, and `File.execute()` for scripts. +use effectcraft_color::Label; use effectcraft_geom::{Mat4, Vec3, vec3}; -use effectcraft_project::{ItemId, ItemKind, LayerSource}; +use effectcraft_project::{Comp, FootageKind, ItemId, ItemKind, LayerSource}; +use effectcraft_time::{FrameRate, Tick}; use serde_json::{Value, json}; use super::{CommandSpec, always, b_p, bad, comp_id, has_comp, has_layers, has_project_selection, str_p}; @@ -170,6 +172,71 @@ fn replace_with_layered_comp(s: &mut Session, p: &Value) -> Result { }) } +/// File ▸ Replace Footage ▸ With Precomposition (also in the Project panel's context menu): +/// the footage or solid item goes into a new composition of its size, pixel aspect and frame +/// rate, placed in the same folder, and every layer in every composition that used the item now +/// uses that composition instead. The layers keep their timing, transforms, effects and masks; +/// the item stays in the project (the new composition uses it). The composition lasts as long as +/// the footage, or as long as the longest layer that showed it for stills and solids. One undo +/// step. +fn replace_with_precomp(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "file.replaceWithPrecomp"; + let item = p.get("item").and_then(Value::as_u64).map(ItemId).or_else(|| s.state.project_selection.first().copied()); + let item = item.ok_or_else(|| bad(CMD, "select a footage or solid item"))?; + let it = s.project.item(item).ok_or_else(|| bad(CMD, "no such item"))?.clone(); + let (w, h, pixel_aspect, rate, own) = match &it.kind { + ItemKind::Footage(f) if matches!(f.kind, FootageKind::Video | FootageKind::Still | FootageKind::Sequence) && f.has_video => { + (f.width, f.height, f.pixel_aspect, it.frame_rate(), it.duration()) + } + ItemKind::Solid(so) => (so.width, so.height, so.pixel_aspect, None, None), + _ => return Err(bad(CMD, "the item is not visual footage or a solid")), + }; + // Adjustment layers only borrow a solid for their size; they keep it. + let is_user = |l: &effectcraft_project::Layer| { + !l.switches.adjustment && matches!(l.source, LayerSource::Footage { item: i } | LayerSource::Solid { item: i } if i == item) + }; + // The longest stretch of the item's own time any layer shows, and the first comp's rate. + let mut used = Tick::ZERO; + let mut used_rate = None; + for (_, c) in s.project.comps() { + for l in c.layers.iter().filter(|l| is_user(l)) { + used = used.max(l.layer_time(l.in_point)).max(l.layer_time(l.out_point)); + used_rate.get_or_insert(c.frame_rate); + } + } + let rate = rate.or(used_rate).unwrap_or(FrameRate::FPS_29_97); + let fallback = Tick::from_seconds_f64(if s.prefs.import.still_footage == "seconds" { s.prefs.import.still_seconds } else { 10.0 }); + let dur = own.filter(|d| *d > Tick::ZERO).unwrap_or(if used > Tick::ZERO { used } else { fallback }).max(rate.frame_duration()); + let base = it.name.rsplit_once('.').map(|(a, _)| a.to_string()).unwrap_or(it.name.clone()); + let name = format!("{base} Comp"); + grouped(s, "Replace with Precomposition", |s| { + let c = Comp { pixel_aspect, ..Comp::new(w.max(1), h.max(1), rate, dur) }; + let comp = s.edit("Replace with Precomposition", None, |proj, _| Ok(proj.add_item(&name, Label::Sandstone, it.parent, ItemKind::Comp(c.into()))))?; + let inner = id_of(&s.execute("layer.addItem", json!({"comp": comp.0, "item": item.0, "time": 0.0, "duration": dur.seconds()}))?, "layer")?; + let replaced = s.edit("Replace with Precomposition", None, |proj, st| { + let mut n = 0u64; + let ids: Vec = proj.items.keys().copied().filter(|c| *c != comp).collect(); + for cid in ids { + if let Some(c) = proj.comp_mut(cid) { + for l in c.layers.iter_mut().filter(|l| is_user(l)) { + l.source = LayerSource::Comp { item: comp }; + n += 1; + } + } + } + st.project_selection = vec![comp]; + Ok(n) + })?; + Ok(json!({"comp": comp.0, "layer": inner, "replaced": replaced})) + }) +} + +fn has_footage_or_solid(s: &Session) -> std::result::Result<(), String> { + has_project_selection(s)?; + let ok = s.state.project_selection.iter().any(|i| matches!(s.project.item(*i).map(|x| &x.kind), Some(ItemKind::Footage(_) | ItemKind::Solid(_)))); + if ok { Ok(()) } else { Err("select a footage or solid item".into()) } +} + fn has_footage(s: &Session) -> std::result::Result<(), String> { has_project_selection(s)?; let ok = s.state.project_selection.iter().any(|i| matches!(s.project.item(*i).map(|x| &x.kind), Some(ItemKind::Footage(_)))); @@ -594,6 +661,7 @@ pub fn specs() -> Vec { ), cmd!("file.saveCopyAsXml", "Save a Copy As XML...", ["File", "Save As"], None, "{path: .ecprojx}", always, save_copy_xml), cmd!("file.replaceWithLayeredComp", "With Layered Comp", ["File", "Replace Footage"], None, "{item?}", has_footage, replace_with_layered_comp), + cmd!("file.replaceWithPrecomp", "Replace with Precomposition", [], None, "{item?}", has_footage_or_solid, replace_with_precomp), cmd!("file.executeFile", "Execute File", [], None, "{path, confirmed?}", always, execute_file), cmd!("view.assign3dShortcut", "Assign Shortcut to 3D View", [], None, "{slot: F10|F11|F12, comp?}", has_comp, assign_view_shortcut), cmd!( diff --git a/crates/engine/src/commands/project_items.rs b/crates/engine/src/commands/project_items.rs index 197c11a0d..723869ca1 100644 --- a/crates/engine/src/commands/project_items.rs +++ b/crates/engine/src/commands/project_items.rs @@ -168,6 +168,22 @@ fn usage(s: &mut Session, p: &Value) -> Result { Ok(json!({"items": doomed.len(), "layers": layers, "comps": comps})) } +/// How many times an item (default: the first selected) is used: the layers, in every +/// composition, whose source it is, as the Project panel's "used N times", and the compositions +/// that use it (its ▾ menu opens one), ordered by name. +fn used_in(s: &mut Session, p: &Value) -> Result { + let c = "project.usedIn"; + let id = match p.get("item") { + Some(v) => item_ref(s, v, c)?, + None => *s.state.project_selection.first().ok_or_else(|| bad(c, "no `item` given and nothing selected in the Project panel"))?, + }; + let users = s.project.item_users(id); + let count = users.iter().fold(0usize, |n, (_, k)| n.saturating_add(*k)); + let comps: Vec = + users.iter().map(|(cid, n)| json!({"id": cid.0, "name": s.project.item(*cid).map(|i| i.name.as_str()).unwrap_or_default(), "layers": n})).collect(); + Ok(json!({"item": id.0, "count": count, "comps": comps})) +} + /// Delete project items (folders with their contents). Layers that use a deleted item and Render /// Queue items of deleted comps go too, like After Effects' Edit ▸ Clear in the Project panel. fn delete(s: &mut Session, p: &Value) -> Result { @@ -266,6 +282,7 @@ pub fn specs() -> Vec { delete ), query!("project.usage", "Project Item Usage", "{items?: [id|name] (default: selected)} → {items, layers, comps} that deleting them removes", usage), + query!("project.usedIn", "Project Item Used In", "{item?: id|name (default: selected)} → {item, count, comps: [{id, name, layers}]}", used_in), cmd!("project.duplicate", "Duplicate Project Items", [], None, "{items?: [id|name] (default: selected)} → {items}", always, duplicate), ] } diff --git a/crates/engine/src/tests_m145.rs b/crates/engine/src/tests_m145.rs index 183ae65fd..61a278aa7 100644 --- a/crates/engine/src/tests_m145.rs +++ b/crates/engine/src/tests_m145.rs @@ -701,3 +701,44 @@ fn unsaved_project_renders_to_movies_or_home_not_the_working_directory() { assert_eq!(crate::render_queue::default_output_base(Some(home.clone())), Some(home.join("Movies"))); assert_eq!(crate::render_queue::default_output_base(None), None); } + +#[test] +fn replace_with_precomp_swaps_every_use_of_the_item_for_one_new_comp() { + let mut s = session(); + let main = s.active_comp_id().unwrap(); + let l = solid(&mut s, "#ff0000"); + s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [10.0, 20.0]})).unwrap(); + let item = layer(&s, l).source.item().unwrap(); + let in_main = |s: &Session| s.project.comp(main).unwrap().layer(LayerId(l)).unwrap().clone(); + let props = in_main(&s).props; + // A second comp uses the same solid for its whole 4 s (longer than Main). + let other = + ItemId(s.execute("comp.new", json!({"name": "Other", "width": 64, "height": 48, "duration": 4, "frameRate": 10})).unwrap()["comp"].as_u64().unwrap()); + s.execute("layer.addItem", json!({"comp": other.0, "item": item.0, "time": 0.0})).unwrap(); + let undo_before = s.history.undo.len(); + + let r = s.execute("file.replaceWithPrecomp", json!({"item": item.0})).unwrap(); + assert_eq!(r["replaced"], 2); + let pre = ItemId(r["comp"].as_u64().unwrap()); + let pc = s.project.comp(pre).unwrap(); + assert_eq!((pc.width, pc.height), (32, 24)); + assert_eq!(pc.duration, Tick::from_seconds_f64(4.0)); + assert_eq!(pc.layers.len(), 1); + assert_eq!(pc.layers[0].source.item(), Some(item)); + assert_eq!(s.project.item(pre).unwrap().name, format!("{} Comp", s.project.item(item).unwrap().name)); + for cid in [main, other] { + let c = s.project.comp(cid).unwrap(); + assert!(c.layers.iter().all(|l| matches!(l.source, effectcraft_project::LayerSource::Comp { item } if item == pre))); + } + // The layer keeps its transform; the item stays in the project; one undo step. + assert_eq!(in_main(&s).props, props); + assert!(s.project.item(item).is_some()); + assert_eq!(s.history.undo.len(), undo_before + 1); + s.undo(); + assert!(s.project.item(pre).is_none()); + assert_eq!(in_main(&s).source.item(), Some(item)); + + // Compositions and folders can't be precomposed this way. + assert!(s.execute("file.replaceWithPrecomp", json!({"item": other.0})).is_err()); + assert!(s.execute("file.replaceWithPrecomp", json!({"item": 999_999})).is_err()); +} diff --git a/crates/engine/src/tests_project_items.rs b/crates/engine/src/tests_project_items.rs index b9363f658..7b9586e85 100644 --- a/crates/engine/src/tests_project_items.rs +++ b/crates/engine/src/tests_project_items.rs @@ -167,3 +167,24 @@ fn delete_removes_selected_groups_not_their_layer() { delete(&mut s); assert!(s.active_comp().unwrap().layer(LayerId(t)).is_none(), "a fixed group selected: the layer goes"); } + +/// `project.usedIn`: the Project panel's "used N times" and its ▾ list of comps, by name. +#[test] +fn used_in_counts_layers_across_comps() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Inner", "width": 64, "height": 64, "duration": 1})).unwrap(); + let inner = s.active_comp_id().unwrap(); + s.execute("comp.new", json!({"name": "Outer", "width": 64, "height": 64, "duration": 1})).unwrap(); + let outer = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": inner.0})).unwrap(); + s.execute("layer.addItem", json!({"item": inner.0})).unwrap(); + let r = s.execute("project.usedIn", json!({"item": "Inner"})).unwrap(); + assert_eq!(r["count"], 2); + assert_eq!(r["comps"], json!([{"id": outer.0, "name": "Outer", "layers": 2}])); + // Default: the selected item; an unused one is used 0 times. + s.execute("project.select", json!({"items": [outer.0]})).unwrap(); + let r = s.execute("project.usedIn", json!({})).unwrap(); + assert_eq!((r["item"].as_u64(), r["count"].as_u64()), (Some(outer.0), Some(0))); + assert_eq!(r["comps"], json!([])); + assert!(s.execute("project.usedIn", json!({"item": "Nope"})).is_err()); +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index fee3b8ce7..c5d264e61 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1382,6 +1382,23 @@ impl Project { pub fn comp_of_layer(&self, layer: LayerId) -> Option { self.comps().find(|(_, c)| c.layer(layer).is_some()).map(|(id, _)| *id) } + /// The compositions whose layers use `item` as their source (footage, solid, 3D model or + /// precomp), each with how many of its layers do, ordered by comp name then id. Only direct + /// layers count, as in After Effects' Project panel ("used 3 times"): a solid inside a precomp + /// is used by the precomp, not by the comps that nest it. The total is the sum of the counts. + pub fn item_users(&self, item: ItemId) -> Vec<(ItemId, usize)> { + let mut users: Vec<(ItemId, String, usize)> = self + .items + .iter() + .filter_map(|(id, it)| { + let comp = it.as_comp()?; + let n = comp.layers.iter().filter(|l| l.source.item() == Some(item)).count(); + (n > 0).then(|| (*id, it.name.to_lowercase(), n)) + }) + .collect(); + users.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0))); + users.into_iter().map(|(id, _, n)| (id, n)).collect() + } /// Whether comp `inner` is (transitively) nested inside comp `outer` (to prevent cycles). pub fn comp_contains(&self, outer: ItemId, inner: ItemId) -> bool { if outer == inner { diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index 494c2a9e2..c19d81ee2 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -182,3 +182,27 @@ fn project_files_remember_the_open_comps() { let odd = text.replacen(&format!("\"activeComp\": {}", cid.0), "\"activeComp\": \"Main\"", 1); assert_eq!(crate::saved_view(&odd), crate::SavedView { active_comp: None, open_comps: vec![cid] }); } + +/// The Project panel's "used N times": layers in every comp that use the item directly, a precomp +/// counted by the comps it is nested in, and an unused item used by nothing. +#[test] +fn item_users_count_layers_in_every_comp() { + let (mut p, main) = project_with_layer(); + let sid = p.find_by_name("Red Solid").unwrap().id; + let comp = p.comp(main).unwrap().clone(); + let pre = p.add_item("A Precomp", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let unused = p.add_item("Unused", effectcraft_color::Label::Red, None, ItemKind::Solid(Solid { color: [0.0; 3], width: 8, height: 8, pixel_aspect: 1.0 })); + // Two more layers of the solid in the precomp (it starts with one, cloned from Main), and the + // precomp nested in Main. + for _ in 0..2 { + let l = build::layer(&mut p, &comp, "Red Solid", LayerSource::Solid { item: sid }, (1920, 1080), None); + p.comp_mut(pre).unwrap().layers.push(l); + } + let nest = build::layer(&mut p, &comp, "A Precomp", LayerSource::Comp { item: pre }, (1920, 1080), None); + p.comp_mut(main).unwrap().layers.push(nest); + // Ordered by comp name: "A Precomp" before "Main". + assert_eq!(p.item_users(sid), vec![(pre, 3), (main, 1)]); + assert_eq!(p.item_users(pre), vec![(main, 1)]); + assert!(p.item_users(unused).is_empty()); + assert!(p.item_users(main).is_empty()); +} diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index f31d0951c..ed7bb394c 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -317,6 +317,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Navigation", "ナビゲーション"), ("New", "新規"), ("New Comp from Selection", "選択範囲から新規コンポジション"), + ("Replace with Precomposition", "プリコンポジションに置き換え"), ("New Duration:", "新しいデュレーション:"), ("New composition name:", "新規コンポジション名:"), ("New from Render Queue item:", "レンダーキュー項目から新規作成:"), @@ -352,6 +353,9 @@ const JAPANESE: &[(&str, &str)] = &[ ("One-Node Camera", "1ノードカメラ"), ("Opacity:", "不透明度:"), ("Open Composition", "コンポジションを開く"), + ("used once", "1 回使用"), + ("used {} times", "{} 回使用"), + ("Compositions that use this item", "このアイテムを使用しているコンポジション"), ("Open Last Saved Project", "最後に保存したプロジェクトを開く"), ("Open New Composition", "新規コンポジションを開く"), ("Open Output", "出力を開く"), @@ -764,6 +768,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Navigation", "导航"), ("New", "新建"), ("New Comp from Selection", "由所选项目新建合成"), + ("Replace with Precomposition", "替换为预合成"), ("New Duration:", "新持续时间:"), ("New composition name:", "新合成名称:"), ("New from Render Queue item:", "由渲染队列项新建:"), @@ -796,6 +801,9 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("One-Node Camera", "单节点摄像机"), ("Opacity:", "不透明度:"), ("Open Composition", "打开合成"), + ("used once", "使用 1 次"), + ("used {} times", "使用 {} 次"), + ("Compositions that use this item", "使用此项目的合成"), ("Open Last Saved Project", "打开上次保存的项目"), ("Open New Composition", "打开新建合成"), ("Open Output", "打开输出"), @@ -1299,6 +1307,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Navigation", "導航"), ("New", "新建"), ("New Comp from Selection", "由所選專案新建合成"), + ("Replace with Precomposition", "取代為預先合成"), ("New Duration:", "新持續時間:"), ("New composition name:", "新合成名稱:"), ("New from Render Queue item:", "由算圖佇列項新建:"), @@ -1331,6 +1340,9 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("One-Node Camera", "單節點攝影機"), ("Opacity:", "不透明度:"), ("Open Composition", "開啟合成"), + ("used once", "使用 1 次"), + ("used {} times", "使用 {} 次"), + ("Compositions that use this item", "使用此項目的合成"), ("Open Last Saved Project", "開啟上次儲存的專案"), ("Open New Composition", "開啟新建合成"), ("Open Output", "開啟輸出"), @@ -1837,6 +1849,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Navigation", "Навігація"), ("New", "Створити"), ("New Comp from Selection", "Нова композиція з виділеного"), + ("Replace with Precomposition", "Замінити попередньою композицією"), ("New Duration:", "Нова тривалість:"), ("New composition name:", "Назва нової композиції:"), ("New from Render Queue item:", "Створити з елемента черги рендерингу:"), @@ -1875,6 +1888,9 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("One-Node Camera", "Одновузлова камера"), ("Opacity:", "Непрозорість:"), ("Open Composition", "Відкрити композицію"), + ("used once", "використано 1 раз"), + ("used {} times", "використань: {}"), + ("Compositions that use this item", "Композиції, що використовують цей елемент"), ("Open Last Saved Project", "Відкрити останній збережений проєкт"), ("Open New Composition", "Відкрити нову композицію"), ("Open Output", "Відкрити результат"), @@ -2373,6 +2389,10 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Navigation", "Navegação"), ("New", "Novo"), ("New Comp from Selection", "Nova composição a partir da seleção"), + ("Replace with Precomposition", "Substituir por pré-composição"), + ("used once", "usado uma vez"), + ("used {} times", "usado {} vezes"), + ("Compositions that use this item", "Composições que usam este item"), ("New Duration:", "Nova duração:"), ("New composition name:", "Nome da nova composição:"), ("New from Render Queue item:", "Novo a partir do item da fila de renderização:"), @@ -2817,6 +2837,10 @@ const CZECH: &[(&str, &str)] = &[ ("Navigation", "Navigace"), ("New", "Nový"), ("New Comp from Selection", "Nová kompozice z výběru"), + ("Replace with Precomposition", "Nahradit předkompozicí"), + ("used once", "použito jednou"), + ("used {} times", "použito {}×"), + ("Compositions that use this item", "Kompozice, které tuto položku používají"), ("New Duration:", "Nová délka trvání:"), ("New composition name:", "Název nové kompozice:"), ("New from Render Queue item:", "Nová šablona z položky fronty vykreslování:"), diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index e18f76bc9..730a38917 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -361,8 +361,53 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // The details stay inside the panel (a margin on the right), cut short with "…". let tx = thumb.max.x + 10.0; let max_w = head.max.x - 10.0 - tx; - let r = widgets::text_fit(&p, pos2(tx, thumb.min.y + 6.0), Align2::LEFT_CENTER, &it.name, Tokens::semibold(12.0), max_w, t.text); - app.auto.add("project.details.name", r, &it.name); + // "Name ▾, used 3 times" (After Effects): the layers in every comp whose source the item + // is; the ▾ lists those comps and opens the one picked. Nothing for an unused item. + let users = app.session.project.item_users(it.id); + let count = users.iter().fold(0usize, |n, (_, k)| n.saturating_add(*k)); + let name_y = thumb.min.y + 6.0; + if count == 0 { + let r = widgets::text_fit(&p, pos2(tx, name_y), Align2::LEFT_CENTER, &it.name, Tokens::semibold(12.0), max_w, t.text); + app.auto.add("project.details.name", r, &it.name); + } else { + let used = if count == 1 { crate::i18n::tr("used once").to_owned() } else { crate::i18n::tr_args("used {} times", &[&count]) }; + let used = format!(", {used}"); + let used_w = p.layout_no_wrap(used.clone(), Tokens::ui(11.0), t.text_dim).size().x; + let arrow_w = 14.0; + // The name gives way first, but keeps at least half the room; the count is cut after it. + let name_max = (max_w - arrow_w - used_w).max(max_w * 0.5); + let r = widgets::text_fit(&p, pos2(tx, name_y), Align2::LEFT_CENTER, &it.name, Tokens::semibold(12.0), name_max, t.text); + app.auto.add("project.details.name", r, &it.name); + let ar = Rect::from_min_size(pos2(r.max.x + 1.0, name_y - 7.0), vec2(arrow_w, 14.0)); + let tip = crate::i18n::tr("Compositions that use this item"); + let aresp = ui.interact(ar, egui::Id::new("proj-used-in"), Sense::click()).on_hover_text(tip); + icons::paint(&p, Rect::from_center_size(ar.center(), vec2(9.0, 9.0)), Icon::ChevronDown, if aresp.hovered() { t.text } else { t.text_dim }); + app.auto.add("project.details.usedIn", ar, tip); + // The comma sits right after the arrow (its glyph ends short of the hit area). + let ux = ar.max.x - 3.0; + let room = (head.max.x - 10.0 - ux).max(0.0); + let ur = widgets::text_fit(&p, pos2(ux, name_y), Align2::LEFT_CENTER, &used, Tokens::ui(11.0), room, t.text_dim); + app.auto.add("project.details.used", ur, &used); + let names: Vec<(u64, String, usize)> = + users.iter().map(|(cid, n)| (cid.0, app.session.project.item(*cid).map(|c| c.name.clone()).unwrap_or_default(), *n)).collect(); + let mut open = None; + egui::Popup::menu(&aresp).show(|ui| { + for (cid, name, n) in &names { + let label = if *n == 1 { name.clone() } else { format!("{name} ({n})") }; + let b = ui.button(label); + app.auto.add(&format!("project.details.usedIn.{cid}"), b.rect, name); + if b.clicked() { + open = Some(*cid); + ui.close(); + } + } + }); + if let Some(cid) = open + && let Err(e) = crate::menus::invoke(app, &ctx, "comp.open", json!({"comp": cid})) + { + app.ui.status = e; + } + } let mut lines = vec![]; if let Some((w, h)) = it.dimensions() { lines.push(format!("{w} x {h} (1.00)")); @@ -808,6 +853,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("file.newCompFromSelection".into(), json!({}))); ui.close(); } + // File ▸ Replace Footage ▸ With Precomposition: every layer using the item uses a + // new composition holding it instead. The label says how many layers that changes. + let uses: usize = app.session.project.item_users(*id).iter().map(|(_, n)| n).sum(); + let label = match uses { + 0 => crate::i18n::tr("Replace with Precomposition").to_owned(), + 1 => format!("{} ({})", crate::i18n::tr("Replace with Precomposition"), crate::i18n::tr("used once")), + n => format!("{} ({})", crate::i18n::tr("Replace with Precomposition"), crate::i18n::tr_args("used {} times", &[&n])), + }; + if (footage || matches!(it.kind, ItemKind::Solid(_))) && ui.button(label).clicked() { + actions.push(("file.replaceWithPrecomp".into(), json!({"item": id.0}))); + ui.close(); + } ui.separator(); ui.menu_button(crate::i18n::tr("Label"), |ui| { for l in Label::ALL { diff --git a/docs/agents.md b/docs/agents.md index 01738edd1..0601d4e44 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -227,6 +227,9 @@ build real projects through these interfaces; they are worked examples of everyt * **Several footage items at once**: `file.newCompFromSelection {single, dimensionsFrom, duration, sequence, overlap, overlapDuration, transition, addToRenderQueue}` is one undo step (select the items first: `project.select`). +* **Where an item is used**: `project.usedIn {item?}` → `{item, count, comps: [{id, name, layers}]}` + counts the layers, in every comp, whose source the item is (direct uses only, as the Project + panel's "Name ▾, used 3 times" header; its ▾ lists the comps and opens the one picked). * **Locked layers** are left alone: `edit.clear`, `layer.arrange`, `layer.transform` and `layer.centerAnchor` skip them and fail when every target is locked ("… is locked"); Select All and `layer.selectNext` / `selectPrevious` step over them. Unlock with diff --git a/docs/footage.md b/docs/footage.md index 458c4c990..e626eee0d 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -98,6 +98,13 @@ vectors, positions and IDs are never the picture. EXtractoR reads every other la - **File ▸ Replace Footage ▸ File…** (Ctrl+H) points the item at another file. Pick a numbered file to replace it with that file's whole sequence (`file.replaceFootage {"path": …, "sequence": false}` for one frame). +- **Replace with Precomposition (used N times)**, right-clicking a footage item or solid in the + Project panel, puts the item in a new composition of its size, + pixel aspect and frame rate, in the same folder, and switches every layer in every composition + that used the item to that composition. Layers keep their timing, transforms, effects and + masks, and the item stays in the project. The composition lasts as long as the footage, or as + the longest layer that showed a still or solid. One undo step (`file.replaceWithPrecomp + {"item"?}`). - **File ▸ Reload Footage** (Ctrl+Alt+L) reads the files again. A sequence picks up frames added to its folder (a render still in progress) and keeps its interpretation. From c99012a77a9009138e570354150392b1158a3402 Mon Sep 17 00:00:00 2001 From: FeiLiuEM Date: Mon, 12 Oct 2026 21:27:12 +0800 Subject: [PATCH 027/181] theme: the symbol test asks the definitions, not epaint's replacement-face check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `interface_symbols_have_a_glyph_in_every_family` (01400f0) is red on main: it asks `Fonts::has_glyph` per symbol, which answers "does this family resolve `c` to a face other than the family's replacement face" - and the replacement face is the first in the chain carrying epaint's PRIMARY_REPLACEMENT_CHAR ('◻'). Inter has neither a replacement glyph nor triangles, so in the `medium` and `semibold` families that face is the weight face itself: everything it owns (the whole Latin set, `•`, `›`, `·`, `▶`, `◀`, `●`, `○`, `⚠`, `…`, `×`) came back as "no glyph" and only the characters it leaves to JetBrains Mono (`▸`) answered true. Rendering is not affected - the atlas resolves through the same chain and does find the weight face - so the assertion was wrong, not the fonts: the bundled Inter faces carry ten of the eleven symbols and JetBrains Mono carries all eleven. The test now asserts what the wiring promises, from the definitions that were installed: each family draws with its own first face and its chain reaches the symbol fallback. Verified on Linux (the test is red before and green after) with cargo fmt --all --check and cargo test -p effectcraft-ui-egui --lib theme::tests. --- crates/ui-egui/src/theme.rs | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index abc6f5762..9a766b3b5 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -657,15 +657,28 @@ mod tests { } #[test] - fn interface_symbols_have_a_glyph_in_every_family() { + fn every_family_reaches_the_symbol_fallback() { let ctx = egui::Context::default(); install(&ctx, &Tokens::for_kind(Default::default()), "en"); ctx.run_ui(Default::default(), |_| {}).textures_delta.clear(); - for family in [FontFamily::Proportional, FontFamily::Monospace, FontFamily::Name("medium".into()), FontFamily::Name("semibold".into())] { - let id = FontId::new(12.0, family); - for c in "▸•›·▶◀●○⚠…×".chars() { - assert!(ctx.fonts_mut(|f| f.has_glyph(&id, c)), "{c} has no glyph in {id:?}"); - } + // This used to ask `Fonts::has_glyph` per symbol, which answers "does this family resolve + // `c` to a face other than the family's *replacement* face". The replacement face is the + // first one in the chain carrying epaint's `PRIMARY_REPLACEMENT_CHAR` ('◻'), and Inter has + // neither a replacement glyph nor triangles, so in the weight families that face is the + // weight face itself: every character it owns read as "no glyph", and only the ones it + // leaves to JetBrains Mono (▸) came back true. Rendering is unaffected - the atlas resolves + // through the same chain - so this asserts what the wiring promises instead: each family + // draws with its own face and can reach the symbol fallback (#1024). + let defs = ctx.fonts(|f| f.definitions().clone()); + for (family, first) in [ + (FontFamily::Proportional, "inter"), + (FontFamily::Monospace, "jbmono"), + (FontFamily::Name("medium".into()), "inter-medium"), + (FontFamily::Name("semibold".into()), "inter-semibold"), + ] { + let chain = defs.families.get(&family).unwrap_or_else(|| panic!("{family:?} is not bound to any fonts")); + assert_eq!(chain.first().map(String::as_str), Some(first), "{family:?} draws with the wrong face: {chain:?}"); + assert!(chain.iter().any(|name| name == "jbmono"), "{family:?} cannot reach the symbol fallback: {chain:?}"); } } From cbc1ce26e2afefa305e74de4b6805b34089512a4 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 00:35:59 +1100 Subject: [PATCH 028/181] Cover timeline right-click Pre-compose grouping the whole selection (#985, #1003) Right-clicking selected layers and choosing Pre-compose grouped only the right-clicked layer in 0.8.0 while Layer > Pre-compose grouped all of them. The menu plumbing was fixed since; add a regression test driving the dialog from both the outline row and the layer bar and asserting both selected layers end up nested in the new composition. --- crates/ui-egui/tests/ui_workflow_selection.rs | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/ui_workflow_selection.rs b/crates/ui-egui/tests/ui_workflow_selection.rs index 697ff2a26..6b46277df 100644 --- a/crates/ui-egui/tests/ui_workflow_selection.rs +++ b/crates/ui-egui/tests/ui_workflow_selection.rs @@ -1,6 +1,6 @@ //! User-reported selection, Project deletion, and render-queue drop regressions. use effectcraft_engine::{Session, project::LayerId}; -use effectcraft_ui_egui::{EffectcraftApp, dock::PanelKind}; +use effectcraft_ui_egui::{Dialog, EffectcraftApp, dock::PanelKind}; use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable as _; @@ -508,3 +508,44 @@ fn project_context_move_to_root_acts_on_the_whole_selection() { assert_eq!(project.item(effectcraft_engine::project::ItemId(*id)).unwrap().parent, None); } } + +/// Timeline right-click "Pre-compose…" groups every selected layer into one composition, +/// like Layer > Pre-compose does, from both the outline row and the layer bar +/// (#985, #1003). +#[test] +fn timeline_context_menu_precompose_groups_the_whole_selection() { + let right_click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| { + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + }; + let row = |h: &Harness<'_, EffectcraftApp>, id: LayerId| { + let r = rect(h, &format!("timeline.layer.{}.row", id.0)); + pos2(r.min.x + 210.0, r.center().y) + }; + let bar = |h: &Harness<'_, EffectcraftApp>, id: LayerId| rect(h, &format!("timeline.layer.{}.bar", id.0)).center(); + for via_bar in [false, true] { + let mut h = harness(); + let ids: Vec = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect(); + let mut want: Vec = + h.state().session.active_comp().unwrap().layers.iter().filter(|l| l.id == ids[0] || l.id == ids[1]).map(|l| l.name.clone()).collect(); + want.sort(); + h.state_mut().session.execute("layer.select", json!({"layers":[ids[0].0, ids[1].0]})).unwrap(); + h.run_steps(3); + let p = if via_bar { bar(&h, ids[1]) } else { row(&h, ids[1]) }; + right_click(&mut h, p); + h.get_by_label("Pre-compose…").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(Dialog::Precompose), "via_bar={via_bar}: Pre-compose opens its dialog"); + let ok = rect(&h, "dialog.precompose.ok").center(); + click(&mut h, ok, Modifiers::NONE); + h.run_steps(3); + // The dialog opens the new composition holding both nested layers. + let mut nested: Vec = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.name.clone()).collect(); + nested.sort(); + assert_eq!(nested, want, "via_bar={via_bar}: both selected layers are nested"); + } +} From 341076359a910959aaf6c4f925b0e812e3f236de Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 00:39:37 +1100 Subject: [PATCH 029/181] Nudge selected layers with the arrow keys from any panel but Project (#961) The viewer.nudge shortcuts bailed out unless Composition, Viewer or Timeline had focus, so with any other panel focused the arrows silently did nothing. After Effects nudges from almost anywhere; only the Project panel owns the arrows for its own keyboard navigation. Typing, dialogs, menus and selected mask vertices still keep the keys. Tests: arrow nudge from Effect Controls, no nudge from Project, Shift+arrow 10x, time untouched, click-row path. --- crates/ui-egui/src/menus.rs | 12 ++-- crates/ui-egui/tests/ui_frame_step.rs | 89 +++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b10b0e31c..5f1a7ba51 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -240,10 +240,11 @@ fn reveal_targets(app: &EffectcraftApp) -> Vec { } } -/// The arrow keys over the Composition panel or the Timeline: move the selected layers 1 pixel -/// at the viewer's magnification (`up`, `down`, `left`, `right`), 10 with Shift (`up10`…), as -/// After Effects does, so zoomed in they move by fractions of a comp pixel. Not while typing, -/// or with mask points selected. +/// The arrow keys nudge the selected layers 1 pixel at the viewer's magnification (`up`, +/// `down`, `left`, `right`), 10 with Shift (`up10`…), as After Effects does, so zoomed in +/// they move by fractions of a comp pixel. From any panel except Project (which owns the +/// arrow keys for its own keyboard navigation, #961); not while typing, with a dialog or +/// menu open, or with mask points selected. fn nudge(app: &mut EffectcraftApp, ctx: &egui::Context, dir: &str) -> Result { let (side, n) = match dir.strip_suffix("10") { Some(side) => (side, 10.0), @@ -257,8 +258,7 @@ fn nudge(app: &mut EffectcraftApp, ctx: &egui::Context, dir: &str) -> Result return Err(format!("unknown nudge `{dir}`")), }; let st = &app.session.state; - let over = matches!(app.ui.focused, PanelKind::Composition | PanelKind::Viewer(_) | PanelKind::Timeline); - if !over || st.selected_layers.is_empty() || st.text_edit.is_some() || !st.selected_vertices.is_empty() { + if app.ui.focused == PanelKind::Project || st.selected_layers.is_empty() || st.text_edit.is_some() || !st.selected_vertices.is_empty() { return Ok(Value::Null); } // Screen pixels per comp pixel. diff --git a/crates/ui-egui/tests/ui_frame_step.rs b/crates/ui-egui/tests/ui_frame_step.rs index cd3ac329e..a57e35295 100644 --- a/crates/ui-egui/tests/ui_frame_step.rs +++ b/crates/ui-egui/tests/ui_frame_step.rs @@ -3,6 +3,7 @@ use effectcraft_engine::Session; use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; use egui::{Event, Key, Modifiers}; use egui_kittest::Harness; use serde_json::json; @@ -42,3 +43,91 @@ fn cmd_arrows_step_the_current_frame() { key(&mut h, Key::ArrowRight, Modifiers::NONE); assert_eq!(frame(&h), 1); } + +/// With a layer selected, the plain arrow keys nudge it (1 screen pixel at the viewer +/// magnification, 10 with Shift), as After Effects does (#961). +#[test] +fn plain_arrows_nudge_the_selected_layer() { + use effectcraft_engine::project::LayerId; + let mut session = Session::default(); + session.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30.0, "duration": 4.0})).unwrap(); + let lid = session.execute("layer.newSolid", json!({"name": "Box", "width": 40, "height": 40, "color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + session.execute("layer.select", json!({"layers": [lid]})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(session)); + h.run_steps(2); + let x = |h: &Harness<'_, EffectcraftApp>| { + h.state().session.active_comp().unwrap().layer(LayerId(lid)).unwrap().props.prop("transform/position").unwrap().value.as_vec3()[0] + }; + let frame = |h: &Harness<'_, EffectcraftApp>| { + let app = h.state(); + app.session.time() + }; + let (x0, t0) = (x(&h), frame(&h)); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert!(x(&h) > x0, "ArrowRight nudges the layer right (from {x0})"); + assert_eq!(frame(&h), t0, "nudging does not move the time"); + let x1 = x(&h); + key(&mut h, Key::ArrowRight, Modifiers::SHIFT); + let x2 = x(&h); + assert!(x2 > x1 + (x1 - x0) * 5.0, "Shift+ArrowRight nudges ten times further (from {x1})"); + key(&mut h, Key::ArrowLeft, Modifiers::NONE); + assert!(x(&h) < x2, "ArrowLeft nudges back (from {x2})"); +} + +/// The realistic path: clicking a Timeline row selects the layer, and the arrow keys then +/// nudge it (#961). +#[test] +fn clicking_a_timeline_row_then_arrows_nudge_the_layer() { + use effectcraft_engine::project::LayerId; + let mut session = Session::default(); + session.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30.0, "duration": 4.0})).unwrap(); + let lid = session.execute("layer.newSolid", json!({"name": "Box", "width": 40, "height": 40, "color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + session.execute("edit.deselectAll", json!({})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(session)); + h.run_steps(3); + let row = { + let e = h.state().auto.find(&format!("timeline.layer.{lid}.row")).expect("timeline row"); + egui::Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])) + }; + let p = egui::pos2(row.min.x + 210.0, row.center().y); + h.event(Event::PointerMoved(p)); + h.step(); + h.event(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.event(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + assert_eq!(h.state().session.state.selected_layers, vec![LayerId(lid)]); + let x = |h: &Harness<'_, EffectcraftApp>| { + h.state().session.active_comp().unwrap().layer(LayerId(lid)).unwrap().props.prop("transform/position").unwrap().value.as_vec3()[0] + }; + let x0 = x(&h); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert!(x(&h) > x0, "ArrowRight nudges the clicked layer (from {x0})"); +} + +/// The arrows nudge the selected layer from any panel except Project, which owns them for +/// its own keyboard navigation (#961). +#[test] +fn arrows_nudge_from_any_panel_but_project() { + use effectcraft_engine::project::LayerId; + let mut session = Session::default(); + session.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30.0, "duration": 4.0})).unwrap(); + let lid = session.execute("layer.newSolid", json!({"name": "Box", "width": 40, "height": 40, "color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + session.execute("layer.select", json!({"layers": [lid]})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(session)); + h.run_steps(2); + let x = |h: &Harness<'_, EffectcraftApp>| { + h.state().session.active_comp().unwrap().layer(LayerId(lid)).unwrap().props.prop("transform/position").unwrap().value.as_vec3()[0] + }; + h.state_mut().ui.focused = PanelKind::EffectControls; + h.run_steps(2); + let x0 = x(&h); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert!(x(&h) > x0, "arrows nudge with Effect Controls focused (from {x0})"); + // Project owns the arrows for its item navigation: the layer stays put. + h.state_mut().ui.focused = PanelKind::Project; + h.run_steps(2); + let x1 = x(&h); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert_eq!(x(&h), x1, "arrows do not nudge with Project focused"); +} From 66a6b99d12fa0cd5203852cb649209f61e0a9c9f Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 00:42:54 +1100 Subject: [PATCH 030/181] Share the Scale Constrain Proportions link between Properties and Timeline (#956) The Properties panel kept its own ephemeral per-layer link toggle, so unlinking Scale there left the Timeline linked and vice versa, and its edit math differed from the Timeline's. Use the shared per-property unlinked set and widgets::constrained, with the stable match id instead of the display name, plus Alt-edits-one-axis like the Timeline. --- crates/ui-egui/src/panels/properties.rs | 25 +++++++++------- crates/ui-egui/tests/ui_panels.rs | 38 +++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index e565034e2..2649a38ef 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -277,16 +277,18 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, let c = value.components(); let n = if pr.shown_dims > 0 && !(layer.is_3d() && c.len() == 3) { pr.shown_dims as usize } else { c.len() }; let mut x = vx; - let link_id = egui::Id::new(("props-scale-link", lid)); - let linked = is_scale && ui.data(|d| d.get_temp::(link_id)).unwrap_or(true); - if is_scale { + // Scale: the chain link (Constrain Proportions, on by default), sharing the + // Timeline's per-property state so both panels agree (#956). + let linkable = pr.match_id == "scale"; + let linked = linkable && !app.ui.timeline.unlinked.contains(&uid); + if linkable { // Constrain Proportions toggle, left of the values. let lr = Rect::from_center_size(pos2(x - 12.0, cy), vec2(12.0, 12.0)); - let resp = ui.interact(lr, link_id.with("btn"), Sense::click()); + let resp = ui.interact(lr, egui::Id::new(("props-link", uid)), Sense::click()).on_hover_text(tr("Constrain Proportions")); icons::paint(p, lr, Icon::Link, if linked { t.accent } else { t.text_faint }); app.auto.add(&format!("{base}.constrain"), lr, "Constrain Proportions"); - if resp.clicked() { - ui.data_mut(|d| d.insert_temp(link_id, !linked)); + if resp.clicked() && !app.ui.timeline.unlinked.remove(&uid) { + app.ui.timeline.unlinked.insert(uid); } } for d in 0..n.min(c.len()) { @@ -295,13 +297,14 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, app.auto.add(&format!("{base}.value.{d}"), vr, &pr.name); if let Some(nv) = nv { let mut nc = c.clone(); - if linked && c[d].abs() > 1e-9 { - let k = nv / c[d]; - for v in nc.iter_mut().take(n) { - *v *= k; + // Alt edits one value of a linked pair, as in the Timeline. + let shown = if linked && !ui.input(|i| i.modifiers.alt && !widgets::proportional_scrub(i.modifiers)) { n.min(c.len()) } else { 1 }; + let from = if shown == 1 { d } else { 0 }; + for (e, v) in widgets::constrained(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() { + if let Some(slot) = nc.get_mut(from + e) { + *slot = v; } } - nc[d] = nv; set(actions, json!(nc)); } x = vr.max.x + 10.0; diff --git a/crates/ui-egui/tests/ui_panels.rs b/crates/ui-egui/tests/ui_panels.rs index e3fee868c..af05cb400 100644 --- a/crates/ui-egui/tests/ui_panels.rs +++ b/crates/ui-egui/tests/ui_panels.rs @@ -466,6 +466,44 @@ fn essential_graphics_scale_keeps_constrain_proportions() { assert!(unlinked[0] > linked[0] && unlinked[1] == linked[1], "unlinked, X moves alone: {unlinked:?}"); } +/// #956: the Properties panel's Scale chain link shares the Timeline's Constrain Proportions +/// state: unlinking in Properties unlinks the Timeline row too, and X/Y scale together only +/// while linked. +#[test] +fn properties_scale_link_shares_the_timeline_state() { + let mut h = harness(); + let layer = h.state().session.active_comp().unwrap().layers[0].id.0; + h.state_mut().session.execute("layer.select", json!({"layers": [layer]})).unwrap(); + h.state_mut().session.execute("prop.set", json!({"layer": layer, "path": "transform/scale", "value": [100.0, 50.0]})).unwrap(); + open(&mut h, "properties"); + let uid = { + let comp = h.state().session.active_comp().unwrap(); + let l = comp.layers.iter().find(|l| l.id.0 == layer).unwrap(); + l.props.prop("transform/scale").unwrap().uid + }; + let link = format!("properties.prop.{uid}.constrain"); + assert!(h.state().auto.find(&link).is_some(), "the chain link is drawn"); + let scale = |h: &Harness<'_, EffectcraftApp>| { + let comp = h.state().session.active_comp().unwrap(); + let l = comp.layers.iter().find(|l| l.id.0 == layer).unwrap(); + l.props.prop("transform/scale").unwrap().value.components() + }; + // Linked: dragging X scales Y with it. + drag_x(&mut h, &format!("properties.prop.{uid}.value.0"), 40.0); + let linked = scale(&h); + assert!(linked[0] > 100.0, "{linked:?}"); + assert!((linked[1] / linked[0] - 0.5).abs() < 1e-6, "Y follows X in proportion: {linked:?}"); + // Unlinking in Properties writes the shared state, so the Timeline row unlinks too. + click(&mut h, &link); + assert!(h.state().ui.timeline.unlinked.contains(&uid), "unlinking writes the shared state"); + drag_x(&mut h, &format!("properties.prop.{uid}.value.0"), 40.0); + let unlinked = scale(&h); + assert!(unlinked[0] > linked[0] && unlinked[1] == linked[1], "unlinked, X moves alone: {unlinked:?}"); + // Re-linking clears it again. + click(&mut h, &link); + assert!(!h.state().ui.timeline.unlinked.contains(&uid), "re-linking clears the shared state"); +} + /// #757: Effects & Presets lists the built-in Transitions presets under * Animation Presets; /// double-clicking one applies it to the selected layer (effect plus keyframes). #[test] From 0c343212ae24b30174f7686e0d9b06e5f768550d Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:48:13 +0200 Subject: [PATCH 031/181] Timeline: a group twirled open by hand under a reveal shows all its properties Collapsing and expanding an effect that shows only its revealed parameters (U, UU) now lists every parameter. The next reveal on the layer filters it again. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline.rs | 44 +++++++++++++++++++-------- crates/ui-egui/src/state.rs | 18 +++++++++++ 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 8cb05101d..2e7d35e53 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -945,7 +945,7 @@ fn reveal_rows(app: &EffectcraftApp, l: &Layer, kinds: &[String], rows: &mut Vec } wanted.extend(picked.iter().copied()); } - revealed_rows(rows, l, &wanted, &shown_groups, &tl.open_groups); + revealed_rows(rows, l, &wanted, &shown_groups, &tl.open_groups, tl.full_groups.get(&l.id.0)); if has("waveform") && let Some(item) = super::waveform::audio_item(&app.session.project, l) { @@ -956,13 +956,22 @@ fn reveal_rows(app: &EffectcraftApp, l: &Layer, kinds: &[String], rows: &mut Vec /// Rows for the revealed properties `shown` of `l`, in tree order. Those inside an effect show /// under the effect and its groups, as in After Effects (Puppet ▸ Mesh 1 ▸ Deform ▸ Puppet /// Pin 1 ▸ Position: every pin's Position is told apart, #273); the others (Transform, masks…) -/// on their own. Groups in `skip` already show with their contents. -fn revealed_rows(rows: &mut Vec, l: &Layer, shown: &std::collections::BTreeSet, skip: &[u64], open: &std::collections::BTreeSet) { +/// on their own. Groups in `skip` already show with their contents; those in `full` (twirled +/// open by hand since the reveal) show all of theirs. +fn revealed_rows( + rows: &mut Vec, + l: &Layer, + shown: &std::collections::BTreeSet, + skip: &[u64], + open: &std::collections::BTreeSet, + full: Option<&std::collections::BTreeSet>, +) { struct Tree<'a> { l: &'a Layer, wanted: &'a dyn Fn(&Property) -> bool, skip: &'a [u64], open: &'a std::collections::BTreeSet, + full: Option<&'a std::collections::BTreeSet>, } fn holds(g: &PropGroup, wanted: &dyn Fn(&Property) -> bool) -> bool { g.children.iter().any(|c| match c { @@ -981,6 +990,10 @@ fn revealed_rows(rows: &mut Vec, l: &Layer, shown: &std::collections::BTree if !o { return; } + if t.full.is_some_and(|f| f.contains(&g.uid)) { + push_group(rows, t.l, g, depth + 1, t.open); + return; + } for c in &g.children { match c { Node::Prop(p) if (t.wanted)(p) => rows.push(Row { layer: t.l.id, depth: depth + 1, kind: RowKind::Prop { uid: p.uid } }), @@ -990,7 +1003,7 @@ fn revealed_rows(rows: &mut Vec, l: &Layer, shown: &std::collections::BTree } } let wanted = |p: &Property| shown.contains(&p.uid) && prop_visible(p, l); - let tree = Tree { l, wanted: &wanted, skip, open }; + let tree = Tree { l, wanted: &wanted, skip, open, full }; for c in &l.props.children { match c { Node::Group(fx) if fx.match_id == "effects" => { @@ -2177,7 +2190,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if *has_children { let tw = Rect::from_center_size(pos2(indent, cy), vec2(13.0, 13.0)); if widgets::twirl(ui, tw, *open, egui::Id::new(("gtw", uid)), &t).clicked() { - ui_actions.push(UiAct::ToggleGroup(*uid)); + ui_actions.push(UiAct::ToggleGroup(row.layer.0, *uid)); } app.auto.add(&format!("timeline.group.{uid}.twirl"), tw, name); } @@ -2246,7 +2259,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false}))); actions.push(("window.panel".into(), json!({"panel": "EffectControls"}))); } else { - ui_actions.push(UiAct::ToggleGroup(*uid)); + ui_actions.push(UiAct::ToggleGroup(row.layer.0, *uid)); } } } @@ -3085,6 +3098,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Twirled by hand, the layer shows its whole tree again. tl.reveal.clear(); tl.layer_reveal.remove(&id); + tl.full_groups.remove(&id); let open = !tl.open_layers.contains(&id); if open { tl.open_layers.insert(id); @@ -3103,11 +3117,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } - UiAct::ToggleGroup(uid) => { - if !app.ui.timeline.open_groups.remove(&uid) { - app.ui.timeline.open_groups.insert(uid); - } - } + UiAct::ToggleGroup(layer, uid) => app.ui.timeline.toggle_group(layer, uid), UiAct::SetTime(tt) => app.session.set_time(tt), UiAct::EndMerge => app.session.history.merge_key = None, UiAct::ChangeRenderer => { @@ -3229,7 +3239,7 @@ fn layer_cells( enum UiAct { ToggleLayer(u64, bool), - ToggleGroup(u64), + ToggleGroup(u64, u64), SetTime(Tick), EndMerge, /// Composition Settings on its 3D Renderer tab. @@ -4017,6 +4027,16 @@ mod tests { let fx = rows.iter().position(|r| r.trim().ends_with("Gaussian Blur")).unwrap_or_else(|| panic!("UU shows the effect: {rows:?}")); assert_eq!(rows[fx + 1].trim(), "Blurriness", "{rows:?}"); assert!(!rows.iter().any(|r| r.trim() == "Blur Dimensions"), "unchanged parameters stay hidden: {rows:?}"); + // Collapsed and expanded by hand, the effect shows all its parameters; UU filters again. + let fx_uid = app.session.active_comp().unwrap().layer(LayerId(bx)).unwrap().effects().unwrap().groups().next().unwrap().uid; + app.ui.timeline.toggle_group(bx, fx_uid); + assert!(!labels(&app).iter().any(|r| r.trim() == "Blurriness"), "collapsed"); + app.ui.timeline.toggle_group(bx, fx_uid); + let rows = labels(&app); + assert!(rows.iter().any(|r| r.trim() == "Blurriness") && rows.iter().any(|r| r.trim() == "Blur Dimensions"), "{rows:?}"); + app.last_reveal = Some(("keyframes".into(), f64::MAX)); + crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap(); + assert!(!labels(&app).iter().any(|r| r.trim() == "Blur Dimensions"), "UU again shows the changed ones only"); app.last_reveal = Some(("keyframes".into(), -10.0)); crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap(); app.last_reveal = Some(("keyframes".into(), -10.0)); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index d0a232797..a73e25cfc 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -305,6 +305,7 @@ impl TimelineState { /// reveal. Other layers keep theirs. pub fn apply_reveal(&mut self, layers: &[u64], kinds: Vec) { for id in layers { + self.full_groups.remove(id); if kinds.is_empty() { self.open_layers.remove(id); self.layer_reveal.remove(id); @@ -315,6 +316,18 @@ impl TimelineState { } self.reveal = kinds; } + + /// Twirl group `uid` of `layer` by hand. Opened under a reveal, it shows all its properties + /// (collapse and expand an effect that shows only its revealed parameters to see them all). + pub fn toggle_group(&mut self, layer: u64, uid: u64) { + if self.open_groups.remove(&uid) { + return; + } + self.open_groups.insert(uid); + if self.layer_reveal.contains_key(&layer) { + self.full_groups.entry(layer).or_default().insert(uid); + } + } } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -344,6 +357,10 @@ pub struct TimelineState { /// Properties / groups (uids) shown by the `props` reveal (Animation ▸ Reveal Properties…). #[serde(default)] pub reveal_props: BTreeSet, + /// Groups of a revealed layer (by layer id) twirled open by hand since the reveal: they show + /// their whole contents, not just the revealed properties. + #[serde(default)] + pub full_groups: BTreeMap>, /// Graph Editor: `auto` (Auto-Select Graph Type, the default: the speed graph when every /// property shown is spatial, else the value graph), `value` or `speed`. #[serde(default = "auto_graph")] @@ -414,6 +431,7 @@ impl Default for TimelineState { reveal: vec![], layer_reveal: BTreeMap::new(), reveal_props: BTreeSet::new(), + full_groups: BTreeMap::new(), graph_mode: auto_graph(), graph_show_selected: true, graph_auto_zoom: true, From f5c6487cf11d7ce02b39d3063f4c86934d2dc89e Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 00:50:56 +1100 Subject: [PATCH 032/181] Reveal enabled Time Remap on U even with no keys left (#990) anim.reveal keyframes/animation only matched keyframed top-level properties, so Time Remap vanished from U once its keys were deleted even though enabling remapping is itself the modification (and freshly enabled remaps, which get two keys, kept showing). Match the bare timeRemap prop for both kinds, as modified already did. --- crates/engine/src/commands/animation.rs | 6 +++--- crates/engine/src/tests_timeline.rs | 25 ++++++++++++++++++++++++ crates/ui-egui/src/panels/timeline.rs | 26 +++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/commands/animation.rs b/crates/engine/src/commands/animation.rs index c700123d7..199cd743a 100644 --- a/crates/engine/src/commands/animation.rs +++ b/crates/engine/src/commands/animation.rs @@ -361,11 +361,11 @@ fn reveal(s: &mut Session, p: &Value) -> Result { let anchor = [w as f64 / 2.0, h as f64 / 2.0]; let center = [comp.width as f64 / 2.0, comp.height as f64 / 2.0]; // Properties at the top of the layer's tree (Time Remap): enabled time remapping is a - // modification in itself. + // modification in itself, so it reveals even with no keys left on it (#990). for pr in l.props.props() { let hit = match kind.as_str() { - "keyframes" => pr.is_animated(), - "animation" => pr.is_animated() || pr.has_expression(), + "keyframes" => pr.is_animated() || pr.match_id == "timeRemap", + "animation" => pr.is_animated() || pr.has_expression() || pr.match_id == "timeRemap", _ => pr.match_id == "timeRemap" || modified(pr, "", anchor, center), }; if hit { diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 009af9e52..9014faeb0 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -750,6 +750,31 @@ fn reveal_shortcuts_take_in_time_remap() { } } +/// U (and the animation reveal) still show Time Remap after its keys are gone: enabling +/// remapping is itself the modification, so a keyless remap must not vanish from U (#990). +#[test] +fn reveal_shortcuts_take_in_keyless_time_remap() { + let (mut s, l) = precomp_setup(); + let revealed = |s: &mut Session, kind: &str| -> Vec { + let r = s.execute("anim.reveal", json!({"kind": kind})).unwrap(); + r["props"].as_array().unwrap().iter().filter(|p| p["layer"] == l).filter_map(|p| p["prop"].as_u64()).collect() + }; + s.execute("layer.enableTimeRemap", json!({})).unwrap(); + let uid = prop(&s, l, "timeRemap").uid; + assert_eq!(prop(&s, l, "timeRemap").keys.len(), 2); + // Delete both keys one at a time; remapping stays enabled. + for _ in 0..2 { + let t = prop(&s, l, "timeRemap").keys[0].time.seconds(); + s.execute("keys.select", json!({"keys": [{"layer": l, "prop": uid, "time": t}]})).unwrap(); + s.execute("edit.clear", json!({})).unwrap(); + } + let tr = prop(&s, l, "timeRemap"); + assert!(tr.keys.is_empty(), "remapping is still enabled, with no keys"); + for kind in ["keyframes", "animation", "modified"] { + assert_eq!(revealed(&mut s, kind), vec![uid], "{kind}"); + } +} + /// UU on a layer nothing was changed on shows nothing, whatever its kind: cameras, lights, text /// and nulls have their own Transform defaults, and Separate Dimensions keeps the comp centre. #[test] diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 72f104290..daf50d608 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -4050,6 +4050,32 @@ mod tests { } } + /// U shows Time Remap while remapping is enabled even after its keys are deleted (#990): + /// enabling remapping is itself the modification. + #[test] + fn u_reveals_keyless_time_remap() { + let mut s = effectcraft_engine::Session::default(); + let inner = s.execute("comp.new", json!({"name": "Inner", "width": 32, "height": 32, "duration": 2})).unwrap()["comp"].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "duration": 4})).unwrap(); + let nested = s.execute("layer.addItem", json!({"item": inner})).unwrap()["layer"].as_u64().unwrap(); + let mut app = EffectcraftApp::new(s); + let ctx = egui::Context::default(); + crate::menus::invoke(&mut app, &ctx, "layer.enableTimeRemap", json!({"layers": [nested]})).unwrap(); + let uid = app.session.active_comp().unwrap().layer(LayerId(nested)).unwrap().props.prop("timeRemap").unwrap().uid; + for _ in 0..2 { + let t = app.session.active_comp().unwrap().layer(LayerId(nested)).unwrap().props.prop("timeRemap").unwrap().keys[0].time.seconds(); + app.session.execute("keys.select", json!({"keys": [{"layer": nested, "prop": uid, "time": t}]})).unwrap(); + app.session.execute("edit.clear", json!({})).unwrap(); + } + assert!(app.session.active_comp().unwrap().layer(LayerId(nested)).unwrap().props.prop("timeRemap").unwrap().keys.is_empty()); + app.session.execute("layer.select", json!({"layers": [nested]})).unwrap(); + crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap(); + assert_eq!(app.ui.timeline.layer_reveal.get(&nested), Some(&vec!["props".to_string()])); + let rows = labels(&app); + let at = rows.iter().position(|r| r == "Inner").unwrap(); + assert_eq!(rows[at + 1].trim(), "Time Remap", "{rows:?}"); + } + /// Enabling time remapping twirls the layer open on Time Remap (#157); disabling it reveals /// nothing. #[test] From 35124cc9242afaa2311de43a71af8dd6a325edb1 Mon Sep 17 00:00:00 2001 From: FeiLiuEM Date: Mon, 12 Oct 2026 21:57:49 +0800 Subject: [PATCH 033/181] text: the Traditional list drops a Simplified face and adds the macOS Traditional ones WenQuanYi Micro Hei is a Simplified-oriented face, so keeping it in the Traditional list - even as a last resort - could still draw Simplified forms, which is the one thing the split is for. Heiti TC and Hiragino Sans CNS are the macOS Traditional faces the family's own lists use (photocraft's `CjkScript::TraditionalChinese`). Dropping the entry does not lose the face: the generic scan behind the script lists still finds it when nothing Traditional is installed. The test now walks the `FallbackScript::Han` arm as well, so a list wired to the wrong script fails it, and it refuses a WenQuanYi entry in the Traditional list. --- crates/text/src/fonts.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 7ef9c1d82..401b107a2 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -597,7 +597,7 @@ const SIMPLIFIED_CHINESE_FAMILIES: &[&str] = /// Traditional interface with Simplified forms - the faces do not differ in *coverage*, which is /// what the fallback search checks, so the script has to pick the list. const TRADITIONAL_CHINESE_FAMILIES: &[&str] = - &["Microsoft JhengHei", "MingLiU", "PingFang TC", "Noto Sans CJK TC", "Noto Sans TC", "Source Han Sans TC", "WenQuanYi Micro Hei"]; + &["Microsoft JhengHei", "MingLiU", "PingFang TC", "Heiti TC", "Hiragino Sans CNS", "Noto Sans CJK TC", "Noto Sans TC", "Source Han Sans TC"]; const KOREAN_FAMILIES: &[&str] = &["Malgun Gothic", "Gulim", "Apple SD Gothic Neo", "Noto Sans CJK KR", "Noto Sans KR", "Source Han Sans KR", "NanumGothic"]; /// The platforms' plain text families for the other scripts the bundled fonts lack, in the same /// order (Windows, macOS, then the Linux/Noto names). @@ -1107,7 +1107,10 @@ mod tests { assert!(SIMPLIFIED_CHINESE_FAMILIES.iter().any(|f| f.contains("PingFang SC") && !f.contains("Source"))); assert!(!SIMPLIFIED_CHINESE_FAMILIES.iter().any(|f| f.contains("TC") || f.contains("JhengHei"))); assert!(TRADITIONAL_CHINESE_FAMILIES.iter().any(|f| f.contains("PingFang TC"))); - assert!(!TRADITIONAL_CHINESE_FAMILIES.iter().any(|f| f.contains(" SC") || f.contains("YaHei"))); + assert!(!TRADITIONAL_CHINESE_FAMILIES.iter().any(|f| f.contains(" SC") || f.contains("YaHei") || f.contains("WenQuanYi"))); + assert!(TRADITIONAL_CHINESE_FAMILIES.iter().any(|f| f.contains("Heiti TC") || f.contains("Hiragino Sans CNS"))); + // The arm the fallback itself walks: Han hands over the list the locale's script picked. + assert_eq!(families_for(FallbackScript::Han), chinese_families_for(&active_cjk_locale())); // Han text is the only thing these lists serve; the Japanese order does not change. assert_eq!(han_order_for("ja_JP.UTF-8")[0], FallbackScript::Japanese); } From 742472440eda824f12476d2ff46f6c6a064d41c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= <100806068+MatejKozlovsky@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:00:05 +0200 Subject: [PATCH 034/181] Export: cap GPU frames rendering at once (#879) With a GPU accelerator every frame of a batch rendered in parallel, each holding its own working textures, so a 30-frame 1080p batch could take the whole card (20+ GB on an RTX 3090) and run shared-memory GPUs out of memory. Cx::frames now renders GPU frames in consecutive chunks of 3 (EFFECTCRAFT_GPU_INFLIGHT overrides it), the same cap as the export scheduler in #898; output order and the CPU path are unchanged. Fixes #879 --- crates/export/src/pipeline.rs | 114 +++++++++++++++++++++++++++++++++- 1 file changed, 112 insertions(+), 2 deletions(-) diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index 7b451726d..e58ee849c 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -15,6 +15,36 @@ use web_time::Instant; use crate::{Job, RenderQuality, out}; +/// GPU frames rendered at once by default, the same cap as the export scheduler's +/// `MAX_INFLIGHT` (3 hides readback and recording without stacking up working textures). +pub(crate) const GPU_INFLIGHT_DEFAULT: usize = 3; +/// Overrides [`GPU_INFLIGHT_DEFAULT`]: a whole number of frames, at least 1. +pub(crate) const GPU_INFLIGHT_ENV: &str = "EFFECTCRAFT_GPU_INFLIGHT"; + +/// GPU frames in flight from the `EFFECTCRAFT_GPU_INFLIGHT` value: a positive whole number, or +/// the default when it is unset, 0 or not a number. +pub(crate) fn gpu_inflight(var: Option<&str>) -> usize { + var.and_then(|v| v.trim().parse::().ok()).filter(|&n| n >= 1).unwrap_or(GPU_INFLIGHT_DEFAULT) +} + +/// `f` over the frames `ks` in parallel, results in `ks` order. With `cap`, at most that many +/// run at once: the frames go in consecutive chunks of `cap`, each rendered in parallel. A +/// chunk holds no lock or slot, so a failing or panicking frame cannot leak one, and nested +/// parallelism inside `f` (or a single thread) cannot deadlock. +pub(crate) fn map_frames(ks: Vec, cap: Option, f: impl Fn(u64) -> T + Sync + Send) -> Vec { + use rayon::prelude::*; + match cap { + None => ks.into_par_iter().map(f).collect(), + Some(n) => { + let mut out = Vec::with_capacity(ks.len()); + for chunk in ks.chunks(n.max(1)) { + out.par_extend(chunk.par_iter().map(|&k| f(k))); + } + out + } + } +} + /// A job being exported: the job, the project with the Render Settings overrides applied, the /// job's layer cache and per-frame timing for the render log. pub(crate) struct Cx<'a> { @@ -132,8 +162,11 @@ impl<'a> Cx<'a> { } return out; } - use rayon::prelude::*; - ks.into_par_iter().map(|k| f(k, self.frame(comp, k))).collect() + // #879: with a GPU accelerator every frame in flight holds its own working textures, so + // a whole batch rendered at once can exhaust VRAM (or shared memory, which crashes + // instead of falling back). Cap the frames rendering at once; the CPU path is unchanged. + let cap = self.job.accel.map(|_| gpu_inflight(std::env::var(GPU_INFLIGHT_ENV).ok().as_deref())); + map_frames(ks, cap, |k| f(k, self.frame(comp, k))) } /// Output frame `i`: sampled (fields, pulldown), at the colour depth, cropped and resized. @@ -367,6 +400,83 @@ fn premultiplied8(img: &Image) -> Vec { #[cfg(test)] mod tests { use super::*; + use rayon::prelude::*; + + /// Frames `0..n` through [`map_frames`] with `cap`, recording the most running at once. + fn run_capped(n: u64, cap: Option) -> (Vec, usize) { + use std::sync::atomic::{AtomicUsize, Ordering}; + let (now, max) = (AtomicUsize::new(0), AtomicUsize::new(0)); + let out = map_frames((0..n).collect(), cap, |k| { + let c = now.fetch_add(1, Ordering::SeqCst) + 1; + max.fetch_max(c, Ordering::SeqCst); + std::thread::sleep(std::time::Duration::from_millis(2)); + now.fetch_sub(1, Ordering::SeqCst); + k * 10 + }); + (out, max.load(Ordering::SeqCst)) + } + + #[test] + fn capped_frames_keep_their_order() { + let want: Vec = (0..40).map(|k| k * 10).collect(); + for cap in [None, Some(1), Some(3), Some(7)] { + assert_eq!(run_capped(40, cap).0, want, "cap {cap:?}"); + } + } + + #[test] + fn gpu_frames_in_flight_respect_the_cap() { + // #879: every frame of a batch rendered on the GPU at once and exhausted VRAM. + let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().unwrap(); + pool.install(|| { + for cap in [1, 2, 3] { + let (out, max) = run_capped(30, Some(cap)); + assert_eq!(out.len(), 30); + assert!(max <= cap, "cap {cap}: {max} frames ran at once"); + } + }); + } + + #[test] + fn a_cap_above_the_frame_count_renders_them_all() { + let (out, max) = run_capped(2, Some(8)); + assert_eq!(out, vec![0, 10]); + assert!(max <= 2); + assert!(run_capped(0, Some(3)).0.is_empty()); + } + + #[test] + fn cap_one_on_a_single_thread_does_not_deadlock() { + let pool = rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap(); + let (out, max) = pool.install(|| run_capped(10, Some(1))); + assert_eq!(out.len(), 10); + assert_eq!(max, 1); + // Nested parallelism inside a frame (as the renderer has) on one thread. + let out = pool.install(|| map_frames((0..6).collect(), Some(2), |k| (0..4u64).into_par_iter().map(|i| i + k).sum::())); + assert_eq!(out, (0..6).map(|k| 6 + 4 * k).collect::>()); + } + + #[test] + fn a_failing_frame_does_not_hold_up_the_rest() { + let out = map_frames((0..9).collect(), Some(1), |k| if k == 3 { Err(k) } else { Ok(k) }); + assert_eq!(out.iter().filter(|r| r.is_err()).count(), 1); + assert_eq!(out.len(), 9); + assert_eq!(out[8], Ok(8)); + // A panicking frame propagates once and leaves no slot behind: the next batch runs. + let r = std::panic::catch_unwind(|| map_frames((0..6).collect(), Some(2), |k| if k == 2 { panic!("frame {k}") } else { k })); + assert!(r.is_err()); + assert_eq!(run_capped(6, Some(2)).0.len(), 6); + } + + #[test] + fn gpu_inflight_override_is_a_positive_number_or_the_default() { + assert_eq!(gpu_inflight(None), GPU_INFLIGHT_DEFAULT); + assert_eq!(gpu_inflight(Some("1")), 1); + assert_eq!(gpu_inflight(Some(" 5 ")), 5); + for bad in ["0", "", "-2", "three", "2.5"] { + assert_eq!(gpu_inflight(Some(bad)), GPU_INFLIGHT_DEFAULT, "{bad:?}"); + } + } #[test] fn layers_render_once_per_job() { From d33e82a571f25db43e0473a0605146064485391a Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 01:01:11 +1100 Subject: [PATCH 035/181] Cover mask vertex multi-select: Shift-click toggle and marquee (#1004) Shift-click adds a vertex to the selection and removes it again, a plain click collapses to one, and a marquee from empty viewer area selects the enclosed vertices (additive with Shift) without moving the layer. These behaviors regressed or were never covered; the tests drive them through the Composition viewer like a user. --- crates/ui-egui/tests/ui_viewer.rs | 90 +++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index 07036ca81..e71bbec8e 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -2088,3 +2088,93 @@ fn fit_up_to_100_follows_the_maximized_panel() { h.run_steps(3); assert!((rect(&h, "viewer.comp").width() - 640.0 / ppp).abs() < 0.01); } + +/// Mask vertices multi-select with the Selection tool: Shift-click adds a vertex to the +/// selection and Shift-clicking it again removes it; a plain click collapses to one (#1004). +#[test] +fn mask_shift_click_toggles_vertices() { + use egui::Modifiers; + let mut h = harness(); + let plate = layer_id(&h, "Plate"); + h.state_mut().session.execute("layer.select", json!({"layers": [plate]})).unwrap(); + h.state_mut() + .session + .execute("mask.new", json!({"layer": plate, "vertices": [[100.0, 60.0], [500.0, 60.0], [500.0, 300.0], [100.0, 300.0]], "closed": true})) + .unwrap(); + h.state_mut().ui.tool = Tool::Selection; + h.run_steps(2); + let (p0, p1) = (screen(&h, [100.0, 60.0]), screen(&h, [500.0, 60.0])); + let shift_click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| { + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::SHIFT)); + h.step(); + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::SHIFT }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::SHIFT }); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + }; + click(&mut h, p0); + assert_eq!(h.state().session.state.selected_vertices.len(), 1, "plain click selects one vertex"); + shift_click(&mut h, p1); + assert_eq!(h.state().session.state.selected_vertices.len(), 2, "shift-click adds a second vertex"); + shift_click(&mut h, p1); + assert_eq!(h.state().session.state.selected_vertices.len(), 1, "shift-click again removes it"); +} + +/// Dragging a marquee from empty viewer area selects the mask vertices inside it, additive +/// with Shift, without moving the layer (#1004). +#[test] +fn mask_marquee_selects_vertices() { + let mut h = harness(); + let plate = layer_id(&h, "Plate"); + let bx = layer_id(&h, "Box"); + // A full-frame layer behind leaves no empty area to start a marquee from: remove it. + h.state_mut().session.execute("layer.select", json!({"layers": [plate]})).unwrap(); + h.state_mut().session.execute("edit.clear", json!({"layers": [plate]})).unwrap(); + assert_eq!(h.state().session.active_comp().unwrap().layers.len(), 1); + h.state_mut().session.execute("layer.select", json!({"layers": [bx]})).unwrap(); + h.state_mut() + .session + .execute("mask.new", json!({"layer": bx, "vertices": [[10.0, 10.0], [70.0, 10.0], [70.0, 70.0], [10.0, 70.0]], "closed": true})) + .unwrap(); + h.state_mut().ui.tool = Tool::Selection; + h.state_mut().session.execute("mask.selectVertices", json!({"vertices": []})).unwrap(); + h.run_steps(2); + // Mask vertices are layer-local; map vertex 0 to comp space (no rotation, uniform scale). + let (pp, aa) = { + let l = h.state().session.active_comp().unwrap().layer(LayerId(bx)).unwrap().clone(); + (l.props.prop("transform/position").unwrap().value.as_vec3(), l.props.prop("transform/anchor").unwrap().value.as_vec3()) + }; + let v0 = [10.0 - aa[0] + pp[0], 10.0 - aa[1] + pp[1]]; + let pos = |h: &Harness<'_, EffectcraftApp>| { + h.state().session.active_comp().unwrap().layer(LayerId(bx)).unwrap().props.prop("transform/position").unwrap().value.as_vec3() + }; + let before = pos(&h); + // Drag a box around the top-left vertex only, starting/ending on empty area. + let (from, to) = (screen(&h, [(v0[0] - 90.0) as f32, (v0[1] - 50.0) as f32]), screen(&h, [(v0[0] + 10.0) as f32, (v0[1] + 10.0) as f32])); + drag(&mut h, from, to); + let sel = &h.state().session.state.selected_vertices; + assert_eq!(sel.len(), 1, "marquee selects the enclosed vertex: {sel:?}"); + assert_eq!(sel[0].index, 0); + assert_eq!(pos(&h), before, "the layer does not move under a vertex marquee"); + // Shift-marquee adds the vertices it encloses. + let v1 = [70.0 - aa[0] + pp[0], 10.0 - aa[1] + pp[1]]; + let (from, to) = (screen(&h, [(v1[0] - 10.0) as f32, (v1[1] - 50.0) as f32]), screen(&h, [(v1[0] + 60.0) as f32, (v1[1] + 50.0) as f32])); + h.input_mut().events.push(Event::ModifiersChanged(egui::Modifiers::SHIFT)); + h.step(); + h.input_mut().events.push(Event::PointerMoved(from)); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::SHIFT }); + h.step(); + for i in 1..=8 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::SHIFT }); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(egui::Modifiers::NONE)); + h.run_steps(2); + let sel = &h.state().session.state.selected_vertices; + assert_eq!(sel.len(), 2, "shift-marquee adds: {sel:?}"); +} From db6e3c5cc6e5bc1ad124f043b2650f4691c57f0f Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 01:15:58 +1100 Subject: [PATCH 036/181] Compensate cameras and lights when parenting, preserving the world frame (#1010, #1020) setParent re-expressed Position for 3D layers but skipped cameras and lights entirely, so parenting a camera to a (default) null moved its eye by the null's world transform and the framing jumped. Cameras and lights now get the same parent-space Position fix, plus Point of Interest for two-node rigs (values, keys, spatial tangents and ease speeds). They have no Scale, so Orientation stays as authored. --- crates/engine/src/commands/layer.rs | 30 ++++++++++++++-- crates/engine/tests/parenting_3d_position.rs | 38 ++++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index d4cf1d90d..7d3e82cd5 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -647,6 +647,28 @@ impl ParentFix { } } } + // Cameras and lights aim with Point of Interest: move it by the same parent-space + // change, so a two-node rig keeps its world frame (#1010, #1020). + if let Some(pr) = tr.get_mut("poi") { + pr.value = map_position(&pr.value); + for key in &mut pr.keys { + key.value = map_position(&key.value); + for tangent in [&mut key.spatial_in, &mut key.spatial_out] { + let v = m.apply_vec(effectcraft_geom::Vec3::from(*tangent)); + *tangent = [v.x, v.y, v.z]; + } + } + if let Some(factor) = position_speed + .filter(|_| pr.spatial) + .filter(|factor| pr.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * *factor).is_finite())) + { + for key in &mut pr.keys { + for ease in key.in_ease.iter_mut().chain(&mut key.out_ease) { + ease.speed *= factor; + } + } + } + } if let Some((orientation, factor)) = self.pose_3d { if let Some(pr) = tr.get_mut("orientation") { pr.value = KV::Vec3(orientation); @@ -749,7 +771,8 @@ fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Opti Some(([x.to_degrees(), y.to_degrees(), z.to_degrees()], factor)) } -/// A parent change retains 2D transforms and the world-space anchor of 3D AV layers. Position is +/// A parent change retains 2D transforms and the world-space anchor of 3D layers (Position, +/// and Point of Interest for two-node cameras and lights). Position is /// re-expressed in the new parent's space (`None`: the composition), evaluated at the current /// time. pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Option) -> Vec { @@ -793,7 +816,10 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op }) }; if let Some(inv) = space_3d(parent).inverse().filter(|m| m.0.iter().flatten().all(|v| v.is_finite())) { - for layer in comp.layers.iter().filter(|l| ids.contains(&l.id) && l.is_3d() && !l.is_camera() && !l.is_light() && l.parent != parent) { + // Cameras and lights compensate like 3D layers (Position, and Point of Interest for + // two-node rigs), so parenting keeps the world frame (#1010, #1020). They have no + // Scale, so `pose_3d` below stays `None` and Orientation is kept as authored. + for layer in comp.layers.iter().filter(|l| ids.contains(&l.id) && l.is_3d() && l.parent != parent) { let m = inv * space_3d(layer.parent); if m.0.iter().flatten().all(|v| v.is_finite()) { let pose_3d = layer.transform().and_then(|tr| { diff --git a/crates/engine/tests/parenting_3d_position.rs b/crates/engine/tests/parenting_3d_position.rs index 8c06cf8ad..fcf268b0a 100644 --- a/crates/engine/tests/parenting_3d_position.rs +++ b/crates/engine/tests/parenting_3d_position.rs @@ -462,3 +462,41 @@ fn parenting_uniform_parent_preserves_eased_position_at_quarter_times_and_undo() } assert!(maximum_error < 1e-8, "3D uniform parent compensation must preserve eased Position at quarter and three-quarter times"); } + +/// Parenting a two-node camera to a default null keeps its world frame (eye, forward, down): +/// Position and Point of Interest are re-expressed in the null's space (#1010, #1020). +#[test] +fn parenting_camera_to_default_null_preserves_framing() { + use effectcraft_engine::render::three_d::camera::layer_frame; + let mut s = Session::default(); + command(&mut s, "comp.new", json!({"name":"Camera","width":640,"height":360,"duration":2,"frameRate":24})); + let cam = command(&mut s, "layer.newCamera", json!({"name":"Camera 1"}))["layer"].as_u64().unwrap(); + let null = command(&mut s, "layer.newNull", json!({"name":"Null 1"}))["layer"].as_u64().unwrap(); + let frame = |s: &Session| { + let cid = s.active_comp_id().unwrap(); + let c = s.project.comp(cid).unwrap(); + let ctx = EvalCtx::new(&s.project, cid, c, Tick::ZERO); + layer_frame(&ctx, c.layer(LayerId(cam)).unwrap()) + }; + let authored = |s: &Session, path: &str| { + let cid = s.active_comp_id().unwrap(); + s.project.comp(cid).unwrap().layer(LayerId(cam)).unwrap().props.prop(path).unwrap().value.as_vec3() + }; + let (eye0, fwd0, down0) = frame(&s); + let (pos0, poi0) = (authored(&s, "transform/position"), authored(&s, "transform/poi")); + let frame_error = |(a0, b0, c0): (Vec3, Vec3, Vec3), (a1, b1, c1): (Vec3, Vec3, Vec3)| { + [(a0 - a1).length(), (b0 - b1).length(), (c0 - c1).length()].into_iter().fold(0.0, f64::max) + }; + let undo = s.history.undo.len(); + parent(&mut s, cam, Some(null), true); + assert!(frame_error(frame(&s), (eye0, fwd0, down0)) < 1e-9, "the camera frame must not move"); + // Compensation rewrote the authored values, not just the parent pointer. + assert!((authored(&s, "transform/position")[0] - pos0[0]).abs() > 1.0, "position is re-expressed in the null's space"); + assert!((authored(&s, "transform/poi")[0] - poi0[0]).abs() > 1.0, "point of interest follows it"); + assert_eq!(s.history.undo.len(), undo + 1, "parent compensation is one undo action"); + // Unparenting restores the authored transform and the same frame. + parent(&mut s, cam, None, true); + assert!(frame_error(frame(&s), (eye0, fwd0, down0)) < 1e-9); + assert_eq!(authored(&s, "transform/position"), pos0, "unparenting restores position"); + assert_eq!(authored(&s, "transform/poi"), poi0, "unparenting restores point of interest"); +} From a96c85b0e5677daf8e744101605bb2d40f4177b3 Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:17:31 +0200 Subject: [PATCH 037/181] Timeline: expression row takes its property row's background Rows stripe by their index among the non-expression rows, so adding or removing an expression leaves every other row's stripe unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline.rs | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 72f104290..b5e1ffbce 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1769,7 +1769,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut hit_rows: Vec<(Rect, Row)> = vec![]; // The value fields of multi-dimension properties (per dimension), for the pick whip. let mut dim_hits: Vec<(u64, Vec)> = vec![]; - for (ri, row) in rows.iter().enumerate() { + // Rows stripe by their index among the rows that are not expression editors, so adding or + // removing an expression leaves every other row's stripe alone; an editor takes the index + // (and so the stripe) of its property's row, the one just above. + let mut striped = 0usize; + for row in rows.iter() { + let ri = if matches!(row.kind, RowKind::Expr { .. }) { + striped.saturating_sub(1) + } else { + striped += 1; + striped - 1 + }; // Outline widgets never spill into the time graph (narrow timelines). ui.set_clip_rect(left_clip); let hh = row_height(row, rh); @@ -2274,7 +2284,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let Some(prop) = layer.props.find(*uid) else { continue }; let Some(ex) = prop.expr.clone() else { continue }; let (buf_id, edit_id) = (expr_buf_id(*uid), egui::Id::new(("expr-edit", uid))); - lp.rect_filled(left, 0.0, t.row_alt); + // The same background as its property's row, selection included. + let sel_prop = app.session.state.selected_props.contains(&(layer.id, *uid)); + lp.rect_filled( + left, + 0.0, + if sel_prop { + t.row_selected + } else if ri % 2 == 0 { + t.row + } else { + t.row_alt + }, + ); let indent = cw.name + 6.0 + 14.0 * row.depth as f32; lp.text(pos2(indent + 12.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Expression:"), Tokens::ui(11.5), t.text_dim); lp.text(pos2(indent + 82.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(11.5), t.text); From 9d63ec19fcdf7822fbbd37876c586765a932765f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= <100806068+MatejKozlovsky@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:23:12 +0200 Subject: [PATCH 038/181] Export: one GPU frame in flight by default (#879) Three frames at once still filled an 8 GB card on the #879 project at 1080p (7.9 GB, the same as no cap); one frame peaked at 6.3 GB and two at 7.0 to 7.8 GB. One matches how the export scheduler in #898 renders image sequences (one GPU worker, synchronous readback). EFFECTCRAFT_GPU_INFLIGHT still raises it. --- crates/export/src/pipeline.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index e58ee849c..b654cc65f 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -15,9 +15,11 @@ use web_time::Instant; use crate::{Job, RenderQuality, out}; -/// GPU frames rendered at once by default, the same cap as the export scheduler's -/// `MAX_INFLIGHT` (3 hides readback and recording without stacking up working textures). -pub(crate) const GPU_INFLIGHT_DEFAULT: usize = 3; +/// GPU frames rendered at once by default: one, as the export scheduler's single GPU worker +/// renders image sequences (#898: frames in flight beyond one only with asynchronous YUV +/// readback). Measured on the #879 project at 1080p on an 8 GB card: 6.3 GB peak with one, +/// 7.0 to 7.8 GB with two, and the whole card with three, where main fell back to the CPU. +pub(crate) const GPU_INFLIGHT_DEFAULT: usize = 1; /// Overrides [`GPU_INFLIGHT_DEFAULT`]: a whole number of frames, at least 1. pub(crate) const GPU_INFLIGHT_ENV: &str = "EFFECTCRAFT_GPU_INFLIGHT"; @@ -164,7 +166,7 @@ impl<'a> Cx<'a> { } // #879: with a GPU accelerator every frame in flight holds its own working textures, so // a whole batch rendered at once can exhaust VRAM (or shared memory, which crashes - // instead of falling back). Cap the frames rendering at once; the CPU path is unchanged. + // instead of falling back). Render one GPU frame at a time by default; the CPU path is unchanged. let cap = self.job.accel.map(|_| gpu_inflight(std::env::var(GPU_INFLIGHT_ENV).ok().as_deref())); map_frames(ks, cap, |k| f(k, self.frame(comp, k))) } @@ -471,7 +473,7 @@ mod tests { #[test] fn gpu_inflight_override_is_a_positive_number_or_the_default() { assert_eq!(gpu_inflight(None), GPU_INFLIGHT_DEFAULT); - assert_eq!(gpu_inflight(Some("1")), 1); + assert_eq!(gpu_inflight(Some("3")), 3); assert_eq!(gpu_inflight(Some(" 5 ")), 5); for bad in ["0", "", "-2", "three", "2.5"] { assert_eq!(gpu_inflight(Some(bad)), GPU_INFLIGHT_DEFAULT, "{bad:?}"); From 55ef2d05db36e2be0470530292de2d9d1b5d95c0 Mon Sep 17 00:00:00 2001 From: Moein Saharkhiz <142007707+moecr7@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:26:35 -0400 Subject: [PATCH 039/181] #989: align pasted effect keyframes with the playhead --- contributors/people.toml | 4 + crates/engine/src/commands/effect.rs | 120 ++++++++++++- crates/engine/src/lib.rs | 4 +- crates/engine/src/tests_effects.rs | 250 ++++++++++++++++++++++++++- docs/agents.md | 3 + docs/gaps.md | 11 +- 6 files changed, 379 insertions(+), 13 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 6a5351215..45375ad91 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -64,3 +64,7 @@ sync_github_name = true [people.webdevtodayjason] display_name = "Jason Brashear" + +[people.moecr7] +real_name = "Moein Saharkhiz" +display_name = "onlymoein" diff --git a/crates/engine/src/commands/effect.rs b/crates/engine/src/commands/effect.rs index 3c597ebd2..e2ae1dd20 100644 --- a/crates/engine/src/commands/effect.rs +++ b/crates/engine/src/commands/effect.rs @@ -1,7 +1,8 @@ //! Effect menu and Effect Controls gestures. use effectcraft_project::build::Ids; -use effectcraft_project::{GroupKind, LayerId, PropGroup, Uid}; +use effectcraft_project::{GroupKind, Layer, LayerId, PropGroup, Uid}; +use effectcraft_time::Tick; use serde_json::{Value, json}; use super::{CommandSpec, b_p, bad, f_p, has_layers, layer_mut, layers_p, str_p}; @@ -300,12 +301,108 @@ fn effects_p(s: &Session, p: &Value, cmd: &str) -> Result> { Ok(sel) } -/// Edit ▸ Copy with effects selected: puts the effect instances on the effect clipboard. +fn effect_time_error(cmd: &str) -> EngineError { + bad(cmd, "an effect keyframe converts outside the supported time range") +} + +/// Validate before Layer's saturating conversions, so a bad stretch or time never silently +/// moves a copied key to another frame. Normal-speed layers keep their exact integer ticks. +fn effect_time(layer: &Layer, time: Tick, to_comp: bool, cmd: &str) -> Result { + if !layer.stretch.is_finite() || layer.stretch == 0.0 { + return Err(bad(cmd, "the layer has an invalid time stretch")); + } + let in_range = |t: i64| (Tick::MIN.0..=Tick::MAX.0).contains(&t); + if !in_range(time.0) { + return Err(effect_time_error(cmd)); + } + let offset = if to_comp { time.0 } else { time.0.checked_sub(layer.start_time.0).ok_or_else(|| effect_time_error(cmd))? }; + let scaled = if (layer.stretch - 100.0).abs() < 1e-9 { + offset + } else { + let scaled = if to_comp { offset as f64 * layer.stretch / 100.0 } else { offset as f64 * 100.0 / layer.stretch }; + if !scaled.is_finite() || scaled < Tick::MIN.0 as f64 || scaled > Tick::MAX.0 as f64 { + return Err(effect_time_error(cmd)); + } + scaled as i64 + }; + if !in_range(scaled) { + return Err(effect_time_error(cmd)); + } + let time = if to_comp { layer.start_time.0.checked_add(scaled).ok_or_else(|| effect_time_error(cmd))? } else { scaled }; + if !in_range(time) { + return Err(effect_time_error(cmd)); + } + Ok(Tick(time)) +} + +/// Change the key time coordinate system throughout an effect, including nested controls. +/// Ease speeds are per second in that coordinate system; reversed time swaps the key sides. +fn retime_effect(group: &mut PropGroup, scale: f64, cmd: &str, mut time: impl FnMut(Tick) -> Result) -> Result<()> { + let mut result = Ok(()); + group.walk_mut(&mut |pr| { + if result.is_err() { + return; + } + for key in &mut pr.keys { + let mapped = time(key.time).and_then(|t| { + if !scale.is_finite() || scale == 0.0 { + return Err(effect_time_error(cmd)); + } + // Spatial paths and non-numeric values ease along positive progress; + // numeric components instead store a signed value derivative. + let speed_scale = if pr.spatial || key.value.dims() == 0 { scale.abs() } else { scale }; + for ease in key.in_ease.iter_mut().chain(&mut key.out_ease) { + ease.speed /= speed_scale; + if !ease.speed.is_finite() { + return Err(bad(cmd, "an effect keyframe ease converts outside the supported range")); + } + } + if scale < 0.0 { + std::mem::swap(&mut key.in_interp, &mut key.out_interp); + std::mem::swap(&mut key.in_ease, &mut key.out_ease); + std::mem::swap(&mut key.spatial_in, &mut key.spatial_out); + } + Ok(t) + }); + match mapped { + Ok(t) => key.time = t, + Err(e) => { + result = Err(e); + return; + } + } + } + pr.keys.sort_by_key(|key| key.time); + if pr.keys.windows(2).any(|pair| pair[0].time == pair[1].time) { + result = Err(bad(cmd, "the layer's time stretch maps effect keyframes to the same tick")); + } + }); + result +} + +/// Edit ▸ Copy with effects selected: times on the clipboard are comp-time offsets from the +/// earliest copied key, shared by all copied effects and their parameters (#989). fn copy(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "effect.copy"; let fx = effects_p(s, p, "effect.copy")?; let comp = s.active_comp().ok_or(EngineError::NoComp)?; - let groups: Vec = - fx.iter().filter_map(|(l, u)| comp.layer(*l).and_then(|l| l.effects()).and_then(|e| e.groups().find(|g| g.uid == *u)).cloned()).collect(); + let mut groups = Vec::new(); + let mut earliest: Option = None; + for (lid, uid) in fx { + let layer = comp.layer(lid).ok_or(EngineError::NoComp)?; + let mut group = layer.effects().and_then(|fx| fx.groups().find(|g| g.uid == uid)).cloned().ok_or_else(|| bad(CMD, "the effect no longer exists"))?; + retime_effect(&mut group, layer.stretch / 100.0, CMD, |t| { + let t = effect_time(layer, t, true, CMD)?; + earliest = Some(earliest.map_or(t, |first| first.min(t))); + Ok(t) + })?; + groups.push(group); + } + if let Some(first) = earliest { + for group in &mut groups { + retime_effect(group, 1.0, CMD, |t| Ok(Tick(t.0.checked_sub(first.0).ok_or_else(|| effect_time_error(CMD))?)))?; + } + } s.state.clipboard.clear(); s.state.key_clipboard.clear(); s.state.contents_clipboard.clear(); @@ -326,8 +423,10 @@ pub(crate) fn unique_name(fx: &PropGroup, base: &str) -> String { } /// Edit ▸ Paste with effects on the clipboard: adds copies to every target layer, after the -/// layer's selected effect when it has one, else at the end of its stack. +/// layer's selected effect when it has one, else at the end of its stack. The earliest copied +/// key lands at the destination comp's CTI; all other keys keep their comp-time offsets. fn paste(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "effect.paste"; let clip = s.state.effect_clipboard.clone(); if clip.is_empty() { return Err(bad("effect.paste", "no effects on the clipboard")); @@ -337,6 +436,7 @@ fn paste(s: &mut Session, p: &Value) -> Result { return Err(bad("effect.paste", "select a layer to paste effects onto")); } let sel = selected_effects(s); + let cti = s.time_of(cid); let label = if clip.len() == 1 { format!("Paste {}", clip[0].name) } else { "Paste Effects".to_string() }; let uids = s.edit(&label, None, |proj, st| { let mut out = vec![]; @@ -344,6 +444,13 @@ fn paste(s: &mut Session, p: &Value) -> Result { let mut new_sel = vec![]; for lid in &ids { let l = layer_mut(proj, cid, *lid)?; + let mut groups = clip.clone(); + for group in &mut groups { + retime_effect(group, 100.0 / l.stretch, CMD, |offset| { + let t = Tick(cti.0.checked_add(offset.0).ok_or_else(|| effect_time_error(CMD))?); + effect_time(l, t, false, CMD) + })?; + } let Some(fx) = l.props.sub_mut("effects") else { continue }; let at = sel .iter() @@ -352,8 +459,7 @@ fn paste(s: &mut Session, p: &Value) -> Result { .max() .map(|i| i + 1) .unwrap_or(fx.children.len()); - for (k, g) in clip.iter().enumerate() { - let mut g = g.clone(); + for (k, mut g) in groups.into_iter().enumerate() { g.reassign_uids(&mut next); g.name = unique_name(fx, &g.name); out.push(g.uid); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a6243e44f..f6ab1382b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -220,8 +220,8 @@ pub struct EditorState { /// Paste sets the same properties of the selected layers. #[serde(skip)] pub value_clipboard: Vec<(String, effectcraft_keyframe::Value)>, - /// Copied effect instances (Edit ▸ Copy with effects selected); Paste adds them to the - /// selected layers. + /// Copied effect instances, with comp-time key offsets from the earliest copied key. + /// Paste adds them to the selected layers at the destination comp's CTI. #[serde(skip)] pub effect_clipboard: Vec, /// Copied shape items (Edit ▸ Copy with groups, paths, paints or path operations selected in diff --git a/crates/engine/src/tests_effects.rs b/crates/engine/src/tests_effects.rs index 82f510366..f3f9b24e1 100644 --- a/crates/engine/src/tests_effects.rs +++ b/crates/engine/src/tests_effects.rs @@ -1,8 +1,9 @@ //! Effect Controls operations: copy/paste, duplicate, reorder, remove (Delete), reset, and the //! Edit menu routing to them when effects are selected, all with undo. -use effectcraft_keyframe::Value as KV; -use effectcraft_project::{Layer, LayerId}; +use effectcraft_keyframe::{Ease, Interp, Value as KV}; +use effectcraft_project::{Layer, LayerId, PropGroup}; +use effectcraft_time::Tick; use serde_json::json; use crate::Session; @@ -32,6 +33,24 @@ fn select_effect(s: &mut Session, lid: u64, uid: u64) { s.state.selected_props = vec![(LayerId(lid), uid)]; } +fn effect_group(s: &Session, lid: u64, uid: u64) -> PropGroup { + layer(s, lid).effects().unwrap().groups().find(|g| g.uid == uid).unwrap().clone() +} + +fn animate_effect(s: &mut Session, lid: u64, uid: u64, path: &str, keys: &[(f64, f64)]) { + let prop = effect_group(s, lid, uid).prop(path).unwrap().uid; + for &(time, value) in keys { + s.execute("prop.addKey", json!({"layer": lid, "prop": prop, "time": time, "timeBase": "comp", "value": value})).unwrap(); + } +} + +fn set_layer_clock(s: &mut Session, lid: u64, start: f64, stretch: f64) { + let cid = s.active_comp_id().unwrap(); + let l = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(lid)).unwrap(); + l.start_time = Tick::from_seconds_f64(start); + l.stretch = stretch; +} + #[test] fn copy_paste_effects_between_layers_with_undo() { let (mut s, a, b) = comp_with_two_solids(); @@ -62,6 +81,233 @@ fn copy_paste_effects_between_layers_with_undo() { assert_eq!(fx_names(&s, a), vec!["Gaussian Blur"]); } +/// #989: copying an animated effect aligns its first key to the destination CTI. +#[test] +fn pasted_effect_keys_start_at_the_playhead() { + let (mut s, a, b) = comp_with_two_solids(); + let blur = apply(&mut s, a, "Gaussian Blur"); + let uid = layer(&s, a).effects().unwrap().groups().next().unwrap().get("blurriness").unwrap().uid; + for (time, value) in [(1.0, 10.0), (3.0, 30.0)] { + s.execute("prop.addKey", json!({"layer": a, "prop": uid, "time": time, "value": value})).unwrap(); + } + let source = layer(&s, a); + select_effect(&mut s, a, blur); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("time.set", json!({"time": 5.0})).unwrap(); + s.execute("layer.select", json!({"layers": [b]})).unwrap(); + let undo = s.history.undo.len(); + s.execute("edit.paste", json!({})).unwrap(); + let pasted = layer(&s, b); + let keys = &pasted.effects().unwrap().groups().next().unwrap().get("blurriness").unwrap().keys; + assert_eq!(keys.iter().map(|k| pasted.comp_time(k.time).seconds()).collect::>(), vec![5.0, 7.0]); + assert_eq!(keys.iter().map(|k| k.value.as_f64()).collect::>(), vec![10.0, 30.0]); + assert_eq!(layer(&s, a), source, "copy and paste do not edit the source"); + assert_eq!(s.history.undo.len(), undo + 1, "the whole paste is one undo step"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(fx_names(&s, b).is_empty()); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(layer(&s, b), pasted); +} + +#[test] +fn pasted_effects_share_one_anchor_including_nested_parameters() { + let (mut s, a, b) = comp_with_two_solids(); + let blur = apply(&mut s, a, "Gaussian Blur"); + let fractal = apply(&mut s, a, "Fractal"); + animate_effect(&mut s, a, blur, "blurriness", &[(1.0, 10.0), (3.0, 30.0)]); + animate_effect(&mut s, a, fractal, "mandelbrot/mandelbrotX", &[(2.0, 1.25), (4.0, 2.5)]); + animate_effect(&mut s, a, fractal, "mandelbrot/mandelbrotY", &[(3.0, 0.5), (5.0, 2.0)]); + let static_value = effect_group(&s, a, fractal).get("setChoice").unwrap().value.clone(); + s.state.selected_props = vec![(LayerId(a), blur), (LayerId(a), fractal)]; + s.execute("edit.copy", json!({})).unwrap(); + s.execute("time.set", json!({"time": 5.0})).unwrap(); + let reply = s.execute("effect.paste", json!({"layers": [b]})).unwrap(); + for (index, path, times) in + [(0, "blurriness", vec![5.0, 7.0]), (1, "mandelbrot/mandelbrotX", vec![6.0, 8.0]), (1, "mandelbrot/mandelbrotY", vec![7.0, 9.0])] + { + let group = effect_group(&s, b, reply["effects"][index].as_u64().unwrap()); + assert_eq!(group.prop(path).unwrap().keys.iter().map(|k| k.time.seconds()).collect::>(), times, "{path}"); + } + let group = effect_group(&s, b, reply["effects"][1].as_u64().unwrap()); + assert_eq!(group.get("setChoice").unwrap().value, static_value, "unanimated controls stay unchanged"); +} + +#[test] +fn pasted_effects_preserve_comp_timing_and_eased_values_on_stretched_layers() { + for (source_start, source_stretch) in [(2.0, 100.0), (1.0, 200.0), (8.0, -200.0)] { + for (target_start, target_stretch) in [(0.0, 100.0), (3.0, 200.0), (8.0, -100.0)] { + let (mut s, a, b) = comp_with_two_solids(); + set_layer_clock(&mut s, a, source_start, source_stretch); + set_layer_clock(&mut s, b, target_start, target_stretch); + let blur = apply(&mut s, a, "Gaussian Blur"); + animate_effect(&mut s, a, blur, "blurriness", &[(3.0, 10.0), (5.0, 30.0)]); + let uid = effect_group(&s, a, blur).get("blurriness").unwrap().uid; + let cid = s.active_comp_id().unwrap(); + let pr = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(a)).unwrap().props.find_mut(uid).unwrap(); + for key in &mut pr.keys { + key.in_interp = Interp::Bezier; + key.out_interp = Interp::Bezier; + key.in_ease = vec![Ease { speed: 7.0, influence: 0.25 }]; + key.out_ease = vec![Ease { speed: 3.0, influence: 0.6 }]; + key.label = 4; + } + let source = layer(&s, a); + let source_pr = source.props.find(uid).unwrap(); + s.execute("effect.copy", json!({"layer": a, "effect": blur})).unwrap(); + s.execute("time.set", json!({"time": 5.0})).unwrap(); + let pasted = s.execute("effect.paste", json!({"layers": [b]})).unwrap()["effects"][0].as_u64().unwrap(); + let target = layer(&s, b); + let target_group = target.effects().unwrap().groups().find(|g| g.uid == pasted).unwrap(); + let target_pr = target_group.get("blurriness").unwrap(); + let mut times: Vec = target_pr.keys.iter().map(|k| target.comp_time(k.time)).collect(); + times.sort(); + assert_eq!(times, vec![Tick::from_seconds_f64(5.0), Tick::from_seconds_f64(7.0)]); + for offset in [0.0, 0.25, 0.75, 1.5, 2.0] { + let expected = source_pr.value_at(source.layer_time(Tick::from_seconds_f64(3.0 + offset))).as_f64(); + let actual = target_pr.value_at(target.layer_time(Tick::from_seconds_f64(5.0 + offset))).as_f64(); + assert!((expected - actual).abs() < 1e-8, "source {source_stretch} %, target {target_stretch} %, t={offset}: {expected} != {actual}"); + } + assert!(target_pr.keys.iter().all(|k| k.label == 4)); + assert_eq!(layer(&s, a), source); + } + } +} + +#[test] +fn pasted_spatial_effect_keys_preserve_the_eased_path_on_reversed_layers() { + for (source_stretch, target_stretch) in [(200.0, 100.0), (-200.0, 100.0), (100.0, -200.0)] { + let (mut s, a, b) = comp_with_two_solids(); + set_layer_clock(&mut s, a, if source_stretch < 0.0 { 8.0 } else { 1.0 }, source_stretch); + set_layer_clock(&mut s, b, if target_stretch < 0.0 { 8.0 } else { 0.0 }, target_stretch); + let ramp = apply(&mut s, a, "Gradient Ramp"); + let uid = effect_group(&s, a, ramp).get("start").unwrap().uid; + for (time, value) in [(3.0, [50.0, 25.0]), (5.0, [180.0, 75.0])] { + s.execute("prop.addKey", json!({"layer": a, "prop": uid, "time": time, "timeBase": "comp", "value": value})).unwrap(); + } + let cid = s.active_comp_id().unwrap(); + let pr = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(a)).unwrap().props.find_mut(uid).unwrap(); + pr.spatial = true; + for key in &mut pr.keys { + key.in_interp = Interp::Bezier; + key.out_interp = Interp::Bezier; + key.in_ease = vec![Ease { speed: 15.0, influence: 0.25 }]; + key.out_ease = vec![Ease { speed: 30.0, influence: 0.6 }]; + key.spatial_auto = false; + key.spatial_in = [-10.0, -20.0, 0.0]; + key.spatial_out = [20.0, 5.0, 0.0]; + } + let source = layer(&s, a); + let source_pr = source.props.find(uid).unwrap(); + s.execute("effect.copy", json!({"layer": a, "effect": ramp})).unwrap(); + s.execute("time.set", json!({"time": 5.0})).unwrap(); + let pasted = s.execute("effect.paste", json!({"layers": [b]})).unwrap()["effects"][0].as_u64().unwrap(); + let target = layer(&s, b); + let group = target.effects().unwrap().groups().find(|g| g.uid == pasted).unwrap(); + let target_pr = group.get("start").unwrap(); + for offset in [0.0, 0.25, 0.75, 1.5, 2.0] { + let expected = source_pr.value_at(source.layer_time(Tick::from_seconds_f64(3.0 + offset))).components(); + let actual = target_pr.value_at(target.layer_time(Tick::from_seconds_f64(5.0 + offset))).components(); + assert_eq!(expected.len(), actual.len()); + assert!(expected.iter().zip(&actual).all(|(a, b)| (a - b).abs() < 0.05), "path at {offset}: {expected:?} != {actual:?}"); + } + } +} + +#[test] +fn effect_clipboard_is_a_snapshot_and_repeated_paste_uses_the_target_comps_cti() { + let (mut s, a, _) = comp_with_two_solids(); + let original_comp = s.active_comp_id().unwrap(); + let blur = apply(&mut s, a, "Gaussian Blur"); + animate_effect(&mut s, a, blur, "blurriness", &[(1.0, 10.0), (3.0, 30.0)]); + s.execute("effect.copy", json!({"layer": a, "effect": blur})).unwrap(); + let clipboard = s.state.effect_clipboard.clone(); + // Changing the source after Copy must not change what the clipboard will paste. + animate_effect(&mut s, a, blur, "blurriness", &[(4.0, 99.0)]); + s.execute("time.set", json!({"time": 9.0})).unwrap(); + let destination = s.execute("comp.new", json!({"name": "Destination", "frameRate": 24, "duration": 10})).unwrap()["comp"].as_u64().unwrap(); + let b = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("time.set", json!({"time": 4.0})).unwrap(); + s.open_comp(original_comp); + for (cti, expected) in [(4.0, vec![4.0, 6.0]), (2.0, vec![2.0, 4.0])] { + s.state.times.insert(effectcraft_project::ItemId(destination), Tick::from_seconds_f64(cti)); + let uid = s.execute("effect.paste", json!({"comp": destination, "layers": [b]})).unwrap()["effects"][0].as_u64().unwrap(); + let l = s.project.comp(effectcraft_project::ItemId(destination)).unwrap().layer(LayerId(b)).unwrap(); + let pr = l.effects().unwrap().groups().find(|g| g.uid == uid).unwrap().get("blurriness").unwrap(); + assert_eq!(pr.keys.iter().map(|k| l.comp_time(k.time).seconds()).collect::>(), expected); + assert_eq!(pr.keys.iter().map(|k| k.value.as_f64()).collect::>(), vec![10.0, 30.0]); + assert_eq!(s.state.effect_clipboard, clipboard); + } +} + +#[test] +fn invalid_effect_paste_timing_leaves_every_target_and_history_unchanged() { + for stretch in [0.0, f64::NAN, f64::INFINITY, 1e-300, 1e300] { + let (mut s, a, b) = comp_with_two_solids(); + let blur = apply(&mut s, a, "Gaussian Blur"); + animate_effect(&mut s, a, blur, "blurriness", &[(1.0, 10.0), (3.0, 30.0)]); + s.execute("effect.copy", json!({"layer": a, "effect": blur})).unwrap(); + set_layer_clock(&mut s, b, 0.0, stretch); + s.execute("time.set", json!({"time": 5.0})).unwrap(); + let project = s.project.clone(); + let clipboard = s.state.effect_clipboard.clone(); + let undo = s.history.undo.len(); + // A is valid and comes first; failure on B must roll back A's new effect too. + assert!(s.execute("effect.paste", json!({"layers": [a, b]})).is_err(), "{stretch}"); + assert!(std::sync::Arc::ptr_eq(&s.project, &project)); + assert_eq!(s.state.effect_clipboard, clipboard); + assert_eq!(s.history.undo.len(), undo); + } +} + +#[test] +fn invalid_effect_copy_keeps_the_previous_clipboard() { + let (mut s, a, _) = comp_with_two_solids(); + let blur = apply(&mut s, a, "Gaussian Blur"); + animate_effect(&mut s, a, blur, "blurriness", &[(1.0, 10.0), (3.0, 30.0)]); + s.execute("effect.copy", json!({"layer": a, "effect": blur})).unwrap(); + let clipboard = s.state.effect_clipboard.clone(); + set_layer_clock(&mut s, a, 0.0, 0.0); + assert!(s.execute("effect.copy", json!({"layer": a, "effect": blur})).is_err()); + assert_eq!(s.state.effect_clipboard, clipboard); +} + +#[test] +fn effect_paste_keeps_exact_ntsc_frame_offsets() { + let (mut s, a, b) = comp_with_two_solids(); + let cid = s.active_comp_id().unwrap(); + let frame = effectcraft_time::FrameRate::new(30000, 1001).frame_duration(); + let blur = apply(&mut s, a, "Gaussian Blur"); + let uid = effect_group(&s, a, blur).get("blurriness").unwrap().uid; + let pr = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(a)).unwrap().props.find_mut(uid).unwrap(); + pr.keys = vec![ + effectcraft_keyframe::Keyframe::new(frame.mul_ratio(11, 1), KV::Scalar(10.0)), + effectcraft_keyframe::Keyframe::new(frame.mul_ratio(43, 1), KV::Scalar(30.0)), + ]; + s.execute("effect.copy", json!({"layer": a, "effect": blur})).unwrap(); + let cti = frame.mul_ratio(71, 1); + s.state.times.insert(cid, cti); + let pasted = s.execute("effect.paste", json!({"layers": [b]})).unwrap()["effects"][0].as_u64().unwrap(); + let group = effect_group(&s, b, pasted); + assert_eq!(group.get("blurriness").unwrap().keys.iter().map(|k| k.time).collect::>(), vec![cti, cti + frame.mul_ratio(32, 1)]); +} + +#[test] +fn overflowing_effect_paste_times_return_an_error_without_an_edit() { + let (mut s, a, b) = comp_with_two_solids(); + let blur = apply(&mut s, a, "Gaussian Blur"); + animate_effect(&mut s, a, blur, "blurriness", &[(1.0, 10.0), (3.0, 30.0)]); + s.execute("effect.copy", json!({"layer": a, "effect": blur})).unwrap(); + let cid = s.active_comp_id().unwrap(); + for cti in [Tick::MAX, Tick(i64::MAX)] { + s.state.times.insert(cid, cti); + let project = s.project.clone(); + let undo = s.history.undo.len(); + assert!(s.execute("effect.paste", json!({"layers": [b]})).is_err()); + assert!(std::sync::Arc::ptr_eq(&s.project, &project)); + assert_eq!(s.history.undo.len(), undo); + } +} + #[test] fn duplicate_reorder_and_delete_selected_effect() { let (mut s, a, _) = comp_with_two_solids(); diff --git a/docs/agents.md b/docs/agents.md index 01738edd1..2cbe60ae1 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -250,6 +250,9 @@ build real projects through these interfaces; they are worked examples of everyt `keys.transform {…, merge, fromStart: true}` (values are the whole transform since the drag started). Keys of locked layers can't be selected. Copy / paste: `keys.copy`, then `keys.paste` at the current time (keys copied from several layers go to as many selected layers in order). + Whole effects: `effect.copy`, then `effect.paste {layers}` places the earliest copied effect + key at the destination comp's current time, preserving the offsets between all copied + effects and parameters, including on offset or time-stretched layers. `time.nextKey` / `time.previousKey` / `keys.selectAll` take `visible: [{layer, prop}]` to act only on those properties (the Timeline passes its revealed ones, as J / K and Ctrl+Alt+A do). * **Easing with Keyframe Velocity**: select keys by path, `keys.select {"keys":[{"layer":"Ring","path":"contents/trim/end","time":0}]}`, diff --git a/docs/gaps.md b/docs/gaps.md index e621ee876..00c274f5e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where EffectCraft falls short -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (re-measured against After Effects 2026 26.5; ranked gap list with estimates; numbers reconciled with target-app-parity.md) · **Target:** Adobe After Effects 2026 (26.5.0.89) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (regression evidence for animated effect paste timing, #989; estimates unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89) An honest assessment of how far EffectCraft is from being a real replacement for After Effects, and the work that closes the gap. This document is meant for contributors and agents choosing @@ -65,7 +65,7 @@ are grouped by area; the issue tracker has the rest. | 6 | **Linux and Windows interaction bugs** | #491 multi-layer edits, #488 rename arrows, #493, #492, #467 shortcuts, #268 drag and drop, #320 workspaces not persisting, #302 docking, #613 Direct3D 12 compositor, #596 GPU not detected | 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: 31 against 621** | After Effects ships 621 `.ffx` presets (Behaviors, Backgrounds, Shapes, Synthetics, Text, Transitions…); we ship 31 original presets (8 text-animator, 23 Transitions: wipes, clock and fan reveals, blinds, dissolves, card flips, CC wipes, fades; #757) and read only our `.ecpreset`. Missing: Behaviors, Backgrounds, 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** | #510, #513, #527 (first Intersect/Darken mask), #509 Roto Brush size, #303 Mesh Warp, #553 pen tools on motion paths | Masks are core compositing | 10–20 h | G2 · [target-app-parity.md](target-app-parity.md) | -| 9 | **Effect behaviour bugs** | #580, #526 Fractal reset, #514 Apply Color LUT, #463 Curl Noise, #369 Motion Tile output size, #473 Cryptomatte selection, #474 multi-layer EXR decoded twice, #534–#536 community fixes pending | Each one breaks a look someone built | 10–20 h | G1/G2 · [effects-parity.md](effects-parity.md) | +| 9 | **Effect behaviour bugs** | #580, #526 Fractal reset, #514 Apply Color LUT, #463 Curl Noise, #369 Motion Tile output size, #473 Cryptomatte selection, #474 multi-layer EXR decoded twice, #534–#536 community fixes pending; #989 effect paste timing has engine regression coverage (G2 below) | Each one breaks a look someone built | 10–20 h | G1/G2 · [effects-parity.md](effects-parity.md) | | 10 | **Colour pipeline differences** | #494 32-bpc blend modes clamp and apply opacity differently; #495 Working Gamma 2.4 (in, also for float footage: #750); #340 ProRes 4444 tagged bt709 but sRGB-encoded; ACES 2.0 / OCIO 2.5 configs (26.5) missing; #546 / #547 viewer colour | Wrong colour in delivery is a client-visible failure | 15–30 h | G1 · [file-format-parity.md](file-format-parity.md) | | 11 | **Text rendering and layout** | #453 CJK text in comps, #316 paragraph alignment inverted, #483 squeezed at 4:3, #417 soft at Fit, #583 font search, #550 toolbar font options | Text is in almost every motion graphic | 10–20 h | G2 · [ui-parity.md](ui-parity.md) | | 12 | **3D interaction and depth** | No 3D transform gizmos (#392, #347); Z position (#489); camera aperture (#260); reflections (#261); extruded surface materials beyond Front / Bevel / Side / Back Color (#315); 3D issue reopened (#307) | Advanced 3D is After Effects' growth area since 2023 | 20–35 h | G2 · [ui-parity.md](ui-parity.md) | @@ -106,6 +106,13 @@ The most important missing piece: it turns every other estimate here into a meas ### G2. Real-user reliability on every platform +- Regression coverage for [#989](https://github.com/storytold/effectcraft/issues/989): + `tests_effects` checks that whole-effect paste aligns the earliest copied key to the CTI, + keeps offsets across effects and nested parameters, preserves eased values on offset, + stretched and reversed layers, uses the destination comp's CTI and keeps exact NTSC frame + offsets. It also checks clipboard snapshots, repeated paste, undo/redo and atomic errors + for invalid or overflowing timing. These are engine regressions; no live After Effects + comparison is claimed. - Fix user issues as they come in, each with a regression test, and answer the reporter (#41–#47 and #63–#68 are handled, as are the Windows report that the font menus missed installed fonts and the reports of 6 October, #85, #88, #89, #93, #103, #106 and #117, and of 7 October, #198, From 41071f0bbf17f3b875a5ada01eb2800efdd7f998 Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:30:04 +0200 Subject: [PATCH 040/181] Timeline: a one-line expression row is as tall as a normal row Drop the 6 px added to expression editor rows (and the matching offset in the drag-to-resize maths), so the row is a whole number of row heights. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 72f104290..5bb85508a 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -89,7 +89,7 @@ fn stripes(p: &egui::Painter, r: Rect, label: Color32) { /// Row height (expression editors grow with their text). fn row_height(row: &Row, rh: f32) -> f32 { match row.kind { - RowKind::Expr { lines, .. } => rh * lines as f32 + 6.0, + RowKind::Expr { lines, .. } => rh * lines as f32, RowKind::Waveform { .. } => rh * 3.0, _ => rh, } @@ -2391,7 +2391,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ctx.set_cursor_icon(egui::CursorIcon::ResizeVertical); } if let Some(py) = eresp.interact_pointer_pos().filter(|_| eresp.dragged()).map(|p| p.y) { - let n = ((py - r.min.y - 6.0) / rh).round().clamp(1.0, EXPR_MAX_LINES as f32); + let n = ((py - r.min.y) / rh).round().clamp(1.0, EXPR_MAX_LINES as f32); app.ui.timeline.expr_lines.insert(*uid, n as usize); } let commit = resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && (i.modifiers.command || i.modifiers.ctrl)); From de26297c368f4caa90b94a54f5f42044b9514396 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 01:34:58 +1100 Subject: [PATCH 041/181] Sample footage pixels in script expressions (#1000) A script reading an expression that calls sampleImage on a footage layer got [0,0,0,0]: the script evaluator built its EvalCtx with EvalCtx::new, which has no footage source, so the renderer substituted transparent frames (solids and shapes stayed correct because they are procedural). Pass the session's footage source, as the app's renderer and diagnostics already do. --- crates/script/src/model.rs | 3 +++ crates/script/src/tests.rs | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index 4445fbbf2..f912f1116 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -468,6 +468,9 @@ fn value(s: &Session, a: &J) -> R { } else { let mut ctx = EvalCtx::new(&s.project, cid, c, t); ctx.expr = s.expr.as_deref(); + // Sample expressions (`sampleImage`) read footage through the session's source; without + // it footage layers sample as transparent (#1000). + ctx.footage = Some(s.footage.as_ref()); ctx.value(l, p) }; Ok(js_value(c, &v)) diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 303bd820a..0ef923f62 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -731,6 +731,42 @@ fn import_options_sequence_picks_one_still_or_the_run() { let _ = std::fs::remove_dir_all(&dir); } +/// A flat green footage source, so `sampleImage` on a footage layer has known pixels. +struct Flat; +impl effectcraft_engine::render::FootageSource for Flat { + fn frame( + &self, + _item: effectcraft_engine::project::ItemId, + f: &effectcraft_engine::project::Footage, + _t: effectcraft_engine::time::Tick, + ) -> Option> { + Some(Arc::new(effectcraft_engine::render::Image::filled(f.width.max(1), f.height.max(1), [0.0, 1.0, 0.0, 1.0]))) + } +} + +/// `sampleImage` on a footage layer reads the footage's pixels from a script, not transparent +/// black: the expression evaluator needs the session's footage source (#1000). +#[test] +fn sample_image_reads_footage_pixels_in_scripts() { + let mut s = session(); + s.footage = Arc::new(Flat); + s.importer = Some(Arc::new(Stills)); + let out = ok( + &mut s, + r#" + var comp = app.project.items.addComp("Scene", 640, 360, 1, 1, 25); + comp.openInViewer(); + var img = comp.layers.add(app.project.importFile(new ImportOptions(new File("/tmp/ec-flat.png")))); + img.name = "Img"; + var cc = comp.layers.addNull().property("ADBE Effect Parade").addProperty("ADBE Color Control").property(1); + cc.expression = 'thisComp.layer("Img").sampleImage([32, 16], [0.5, 0.5], true, time);'; + var v = cc.valueAtTime(0, false); + [v[0], v[1], v[2], v[3], cc.expressionError]; + "#, + ); + assert_eq!(out.result, json!([0, 1, 0, 1, ""]), "green footage sampled, not transparent black"); +} + #[test] fn binary_file_io_preserves_all_byte_values() { let dir = std::env::temp_dir().join(format!("ec-script-binary-{}", std::process::id())); From f96663b1f58351e128cac73f5b14a1d58b1f040f Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:40:35 +0200 Subject: [PATCH 042/181] Timeline: Cmd/Alt-click a group twirl opens its whole tree Cmd-click (or Alt-click) the arrow of Effects, Contents, Masks or any other group in the Timeline twirls every group under it open, or closed, with it, as the layer's own arrow already did. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline.rs | 32 +++++++++++++++---- crates/ui-egui/tests/ui_viewer.rs | 46 +++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 72f104290..afa656ac9 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2177,7 +2177,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if *has_children { let tw = Rect::from_center_size(pos2(indent, cy), vec2(13.0, 13.0)); if widgets::twirl(ui, tw, *open, egui::Id::new(("gtw", uid)), &t).clicked() { - ui_actions.push(UiAct::ToggleGroup(*uid)); + // Alt/Cmd-click twirls the whole tree under the group (as on a layer). + ui_actions.push(UiAct::ToggleGroup(layer.id.0, *uid, ui.input(|i| i.modifiers.alt || i.modifiers.command))); } app.auto.add(&format!("timeline.group.{uid}.twirl"), tw, name); } @@ -2246,7 +2247,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false}))); actions.push(("window.panel".into(), json!({"panel": "EffectControls"}))); } else { - ui_actions.push(UiAct::ToggleGroup(*uid)); + ui_actions.push(UiAct::ToggleGroup(layer.id.0, *uid, false)); } } } @@ -3103,9 +3104,22 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } - UiAct::ToggleGroup(uid) => { - if !app.ui.timeline.open_groups.remove(&uid) { - app.ui.timeline.open_groups.insert(uid); + UiAct::ToggleGroup(layer, uid, all) => { + let tl = &mut app.ui.timeline; + let open = !tl.open_groups.remove(&uid); + if open { + tl.open_groups.insert(uid); + } + if all && let Some(g) = comp.layer(LayerId(layer)).and_then(|l| find_group(&l.props, uid)) { + let mut uids = vec![]; + walk_groups(g, &mut uids); + for u in uids { + if open { + tl.open_groups.insert(u); + } else { + tl.open_groups.remove(&u); + } + } } } UiAct::SetTime(tt) => app.session.set_time(tt), @@ -3229,7 +3243,8 @@ fn layer_cells( enum UiAct { ToggleLayer(u64, bool), - ToggleGroup(u64), + /// Layer, group uid, and whether the whole tree under the group follows. + ToggleGroup(u64, u64, bool), SetTime(Tick), EndMerge, /// Composition Settings on its 3D Renderer tab. @@ -3243,6 +3258,11 @@ fn walk_groups(g: &PropGroup, out: &mut Vec) { } } +/// The group `uid` anywhere under `g`. +fn find_group(g: &PropGroup, uid: u64) -> Option<&PropGroup> { + g.groups().find_map(|sg| if sg.uid == uid { Some(sg) } else { find_group(sg, uid) }) +} + /// A stroke's Dashes "+" / "−" buttons (add or remove a Dash/Gap pair). #[allow(clippy::too_many_arguments)] fn dash_buttons( diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index 07036ca81..bfe4a3f67 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -1570,6 +1570,52 @@ fn timeline_twirl_arrows_open_layers_and_groups() { assert!(!shown(&h, prop_uid(&h, "Box", "transform/position"))); } +#[test] +fn command_clicking_a_group_twirl_opens_its_whole_tree() { + // Cmd-click (or Alt-click) the arrow of Effects, Contents, Masks…: every group under it + // 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(); + fn walk(g: &effectcraft_engine::project::PropGroup, out: &mut Vec) { + for sg in g.groups() { + out.push(sg.uid); + walk(sg, out); + } + } + let (effects, inner, transform) = { + let comp = h.state().session.active_comp().unwrap().clone(); + let props = &comp.layer(LayerId(id)).unwrap().props; + let fx = props.sub("effects").unwrap(); + let mut inner = vec![]; + walk(fx, &mut inner); + (fx.uid, inner, props.sub("transform").unwrap().uid) + }; + assert!(inner.len() > 1, "the effect has twirl-down groups of its own"); + let at = rect(&h, &format!("timeline.layer.{id}.twirl")).center(); + click(&mut h, at); + let at = rect(&h, &format!("timeline.group.{effects}.twirl")).center(); + let cmd_click = |h: &mut Harness<'_, EffectcraftApp>| { + h.input_mut().events.push(Event::ModifiersChanged(egui::Modifiers::COMMAND)); + h.input_mut().events.push(Event::PointerMoved(at)); + h.step(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::COMMAND }); + h.step(); + } + h.input_mut().events.push(Event::ModifiersChanged(Default::default())); + h.step(); + }; + cmd_click(&mut h); + let open = h.state().ui.timeline.open_groups.clone(); + assert!(open.contains(&effects), "Effects twirls open"); + assert!(inner.iter().all(|u| open.contains(u)), "and so does everything under it"); + assert!(!open.contains(&transform), "other groups stay as they were"); + cmd_click(&mut h); + let open = h.state().ui.timeline.open_groups.clone(); + assert!(!open.contains(&effects) && !inner.iter().any(|u| open.contains(u)), "Cmd-click again closes the tree"); +} + #[test] fn timeline_rename_commits_on_enter_and_on_click_away() { // The rename field (double-click a name, or Enter on a selected layer) used to grab the From 3e900db97465dbba3c3ec3cd9c44f1fe91724ee6 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 01:47:19 +1100 Subject: [PATCH 043/181] Create Shapes/Masks from Text in the layer context menu (#872) The engine's layer.create has shapesFromText and masksFromText, but the timeline and Composition context menus only offered Create Shapes from Vector Layer, so text could not be converted to editable outlines from the UI. Gate the two text conversions on a text layer (LayerMenuCaps replaces the lone vector flag), beside the vector one. --- crates/ui-egui/src/panels/timeline.rs | 38 +++++++++++++--- crates/ui-egui/src/panels/viewer.rs | 15 ++++--- crates/ui-egui/tests/ui_workflow_selection.rs | 44 +++++++++++++++++++ 3 files changed, 86 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 72f104290..068ec00f8 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1905,7 +1905,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { None => start_rename(&ctx, layer.id.0, &layer.name), } } - layer_context_menu(&row_resp, &comp, layer, &selected, vector_footage_layer(&app.session.project, layer), &mut actions); + layer_context_menu(&row_resp, &comp, layer, &selected, LayerMenuCaps::of(&app.session.project, layer), &mut actions); // Switches. let sws: [(Icon, bool, &str, bool); 8] = [ (Icon::Shy, sw.shy, "shy", true), @@ -2124,7 +2124,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { set_anchor(&ctx, cid, layer.id); } } - layer_context_menu(&body, &comp, layer, &selected, vector_footage_layer(&app.session.project, layer), &mut actions); + layer_context_menu(&body, &comp, layer, &selected, LayerMenuCaps::of(&app.session.project, layer), &mut actions); let drag_key = format!("bar-{}", layer.id.0); for (resp, kind) in [(&body, "move"), (&lin, "in"), (&lout, "out")] { if resp.dragged() { @@ -3516,20 +3516,42 @@ fn collapse_applies(layer: &Layer, project: &effectcraft_engine::project::Projec } } +/// Which Create ▸ conversions the layer context menu offers, from the targeted layer's kind. +#[derive(Clone, Copy, Default)] +pub(crate) struct LayerMenuCaps { + /// SVG / PDF / AI / EPS footage: Create Shapes from Vector Layer. + pub vector: bool, + /// A text layer: Create Shapes from Text / Create Masks from Text. + pub text: bool, +} + +impl LayerMenuCaps { + pub(crate) fn of(project: &effectcraft_engine::project::Project, layer: &Layer) -> Self { + Self { vector: vector_footage_layer(project, layer), text: matches!(layer.source, LayerSource::Text) } + } +} + /// A layer row's or bar's context menu. As in After Effects, right-clicking a layer that is part /// of the selection keeps the selection and the menu acts on all of it; right-clicking an /// unselected layer selects only that layer. -fn layer_context_menu(resp: &egui::Response, comp: &Comp, layer: &Layer, selected: &[LayerId], vector: bool, actions: &mut Vec<(String, serde_json::Value)>) { +fn layer_context_menu( + resp: &egui::Response, + comp: &Comp, + layer: &Layer, + selected: &[LayerId], + caps: LayerMenuCaps, + actions: &mut Vec<(String, serde_json::Value)>, +) { let in_selection = selected.contains(&layer.id); if resp.secondary_clicked() && !in_selection && !layer.switches.locked { actions.push(("layer.select".into(), json!({"layers": [layer.id.0]}))); } let targets: Vec = if in_selection { comp.layers.iter().filter(|l| selected.contains(&l.id)).map(|l| l.id.0).collect() } else { vec![layer.id.0] }; - crate::widgets::context_menu(resp, |ui| layer_menu(ui, &targets, vector, actions)); + crate::widgets::context_menu(resp, |ui| layer_menu(ui, &targets, caps, actions)); } /// Shared layer actions for Timeline rows and Composition context menus. -pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], vector: bool, actions: &mut Vec<(String, serde_json::Value)>) { +pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], caps: LayerMenuCaps, actions: &mut Vec<(String, serde_json::Value)>) { let Some(&id) = layers.first() else { return }; let mut item = |ui: &mut egui::Ui, label: &str, cmd: &str, params: serde_json::Value| { if ui.button(label).clicked() { @@ -3548,9 +3570,13 @@ pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], vector: bool, action item(ui, "Adjustment Layer", "layer.newAdjustment", json!({})); }); item(ui, "Layer Settings…", "layer.settings", json!({})); - if vector { + if caps.vector { item(ui, "Create Shapes from Vector Layer", "layer.create", json!({"op": "shapesFromVector", "layers": layers})); } + if caps.text { + item(ui, "Create Shapes from Text", "layer.create", json!({"op": "shapesFromText", "layers": layers})); + item(ui, "Create Masks from Text", "layer.create", json!({"op": "masksFromText", "layers": layers})); + } ui.separator(); ui.menu_button(crate::i18n::tr("Masks"), |ui| { item(ui, "New Mask", "layer.addMask", json!({"layer": id})); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index cbb842d91..d7fffe59a 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1223,11 +1223,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if context_layers.is_empty() { ui.label(crate::i18n::tr("No unlocked layer selected")); } else { - let vector = context_layers - .iter() - .filter_map(|id| comp.layer(effectcraft_engine::project::LayerId(*id))) - .any(|layer| super::timeline::vector_footage_layer(&app.session.project, layer)); - super::timeline::layer_menu(ui, &context_layers, vector, &mut context_actions); + let caps = context_layers.iter().filter_map(|id| comp.layer(effectcraft_engine::project::LayerId(*id))).fold( + super::timeline::LayerMenuCaps::default(), + |mut caps, layer| { + let c = super::timeline::LayerMenuCaps::of(&app.session.project, layer); + caps.vector |= c.vector; + caps.text |= c.text; + caps + }, + ); + super::timeline::layer_menu(ui, &context_layers, caps, &mut context_actions); } ui.separator(); if ui.button(copy_frame).clicked() { diff --git a/crates/ui-egui/tests/ui_workflow_selection.rs b/crates/ui-egui/tests/ui_workflow_selection.rs index 697ff2a26..93af3a35a 100644 --- a/crates/ui-egui/tests/ui_workflow_selection.rs +++ b/crates/ui-egui/tests/ui_workflow_selection.rs @@ -508,3 +508,47 @@ fn project_context_move_to_root_acts_on_the_whole_selection() { assert_eq!(project.item(effectcraft_engine::project::ItemId(*id)).unwrap().parent, None); } } + +/// A text layer's timeline context menu offers Create Shapes from Text and Create Masks from +/// Text (After Effects), which convert the layer; a non-text layer does not show them (#872). +#[test] +fn timeline_context_menu_creates_shapes_and_masks_from_text() { + use effectcraft_engine::project::LayerSource; + let mut s = Session::default(); + s.execute("comp.new", json!({"name":"Type","width":320,"height":180,"duration":4})).unwrap(); + s.execute("layer.newText", json!({"text":"Abc"})).unwrap(); + s.execute("layer.newSolid", json!({"name":"Plate","width":40,"height":40,"color":"#406080"})).unwrap(); + s.execute("edit.deselectAll", json!({})).unwrap(); + let text = s.active_comp().unwrap().layers.iter().find(|l| matches!(l.source, LayerSource::Text)).unwrap().id.0; + let plate = s.active_comp().unwrap().layers.iter().find(|l| l.name == "Plate").unwrap().id.0; + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + let row = |h: &Harness<'_, EffectcraftApp>, id: u64| { + let r = rect(h, &format!("timeline.layer.{id}.row")); + pos2(r.min.x + 210.0, r.center().y) + }; + let right_click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| { + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + }; + // The text layer offers both conversions; Create Shapes from Text works. + h.state_mut().session.execute("layer.select", json!({"layers": [text]})).unwrap(); + h.run_steps(3); + let at = row(&h, text); + right_click(&mut h, at); + h.get_by_label("Create Shapes from Text").click(); + h.run_steps(3); + let shapes = h.state().session.active_comp().unwrap().layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)).count(); + assert_eq!(shapes, 1, "a shape layer was created from the text"); + // A non-text layer does not show the text conversions. + h.state_mut().session.execute("layer.select", json!({"layers": [plate]})).unwrap(); + h.run_steps(3); + let at = row(&h, plate); + right_click(&mut h, at); + assert!(h.query_by_label("Create Shapes from Text").is_none(), "no text conversion for a solid"); + assert!(h.query_by_label("Create Masks from Text").is_none(), "no text conversion for a solid"); +} From f9808423d62cb47f547f05303eade83453513be8 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 01:49:06 +1100 Subject: [PATCH 044/181] Cover keyframe interpolation and ease on effect parameters (#994) setInterpolationTypeAtKey and setTemporalEaseAtKey act on effect parameters (a Slider Control's slider and a 3D Point Control), not just transform properties. Verified on current main; lock it in with a script regression test driving the per-key methods through the property object. --- crates/script/src/tests.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 303bd820a..6ec8967c2 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -1010,3 +1010,30 @@ fn scripted_text_range_selector_resolves_and_is_the_only_selector() { ); assert_eq!(o.result, json!([1, "ADBE Text Selector", 50, 100, "ADBE Text Percent Offset"])); } + +/// `setInterpolationTypeAtKey` and `setTemporalEaseAtKey` (`keys.interpolation` / `keys.velocity`) +/// act on effect parameters too, not just transform properties (#994). +#[test] +fn effect_parameter_interpolation_and_ease() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("E", 100, 100, 1, 5, 25); + var l = c.layers.addNull(); + var slider = l.property("ADBE Effect Parade").addProperty("ADBE Slider Control").property(1); + slider.setValuesAtTimes([0, 1, 2], [0, 50, 100]); + slider.setInterpolationTypeAtKey(1, KeyframeInterpolationType.BEZIER, KeyframeInterpolationType.BEZIER); + slider.setTemporalEaseAtKey(2, [new KeyframeEase(0, 16.67)], [new KeyframeEase(0, 33.33)]); + var point = l.property("ADBE Effect Parade").addProperty("ADBE Point3D Control").property(1); + point.setValuesAtTimes([0, 1], [[0, 0, 0], [50, 50, 50]]); + point.setInterpolationTypeAtKey(1, KeyframeInterpolationType.BEZIER, KeyframeInterpolationType.BEZIER); + [slider.keyInInterpolationType(1) == KeyframeInterpolationType.BEZIER, + slider.keyOutInterpolationType(1) == KeyframeInterpolationType.BEZIER, + slider.keyInTemporalEase(2)[0].influence, + slider.keyOutTemporalEase(2)[0].influence, + point.keyInInterpolationType(1) == KeyframeInterpolationType.BEZIER]; + "#, + ); + assert_eq!(o.result, json!([true, true, 16.67, 33.33, true])); +} From 2669e1a1deb4d24edb002c24be47e1cd8307c31a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= <100806068+MatejKozlovsky@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:51:36 +0200 Subject: [PATCH 045/181] Export: cap GPU renders only, for GPU-accelerated projects (#879 review) - The cap applied whenever an accelerator was attached, so Software Only projects (which render on the CPU) rendered one frame at a time. It now needs the project's GPU acceleration too, as #898 gates it. - Only the render is capped: each window of max(cap, threads) frames renders in chunks of the cap, then encodes in parallel, holding at most one window of images. - An invalid EFFECTCRAFT_GPU_INFLIGHT (0, negative, not a number, too large) logs a warning before using the default. - The default stays one frame: the GPU backend reports no free or total device memory portably, so it is not sized from VRAM. --- crates/export/src/pipeline.rs | 190 ++++++++++++++++++++++++++++------ 1 file changed, 156 insertions(+), 34 deletions(-) diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index b654cc65f..a0093f4c7 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -19,34 +19,57 @@ use crate::{Job, RenderQuality, out}; /// renders image sequences (#898: frames in flight beyond one only with asynchronous YUV /// readback). Measured on the #879 project at 1080p on an 8 GB card: 6.3 GB peak with one, /// 7.0 to 7.8 GB with two, and the whole card with three, where main fell back to the CPU. +/// Not sized from free VRAM: the GPU backend reports no free or total device memory on every +/// platform, so a portable estimate is not available here. pub(crate) const GPU_INFLIGHT_DEFAULT: usize = 1; /// Overrides [`GPU_INFLIGHT_DEFAULT`]: a whole number of frames, at least 1. pub(crate) const GPU_INFLIGHT_ENV: &str = "EFFECTCRAFT_GPU_INFLIGHT"; /// GPU frames in flight from the `EFFECTCRAFT_GPU_INFLIGHT` value: a positive whole number, or -/// the default when it is unset, 0 or not a number. +/// the default (with a warning when a value was given) when it is 0, negative, not a number or +/// too large. pub(crate) fn gpu_inflight(var: Option<&str>) -> usize { - var.and_then(|v| v.trim().parse::().ok()).filter(|&n| n >= 1).unwrap_or(GPU_INFLIGHT_DEFAULT) -} - -/// `f` over the frames `ks` in parallel, results in `ks` order. With `cap`, at most that many -/// run at once: the frames go in consecutive chunks of `cap`, each rendered in parallel. A -/// chunk holds no lock or slot, so a failing or panicking frame cannot leak one, and nested -/// parallelism inside `f` (or a single thread) cannot deadlock. -pub(crate) fn map_frames(ks: Vec, cap: Option, f: impl Fn(u64) -> T + Sync + Send) -> Vec { - use rayon::prelude::*; - match cap { - None => ks.into_par_iter().map(f).collect(), - Some(n) => { - let mut out = Vec::with_capacity(ks.len()); - for chunk in ks.chunks(n.max(1)) { - out.par_extend(chunk.par_iter().map(|&k| f(k))); - } - out + let Some(v) = var else { return GPU_INFLIGHT_DEFAULT }; + match v.trim().parse::() { + Ok(n) if n >= 1 => n, + _ => { + log::warn!(target: "effectcraft_export", "{GPU_INFLIGHT_ENV}={v:?} is not a whole number of frames of at least 1: using {GPU_INFLIGHT_DEFAULT}"); + GPU_INFLIGHT_DEFAULT } } } +/// `render` then `encode` over the frames `ks`, results in `ks` order. +/// +/// Without `cap` every frame renders and encodes in one parallel batch (the CPU path, as +/// before). With `cap` (GPU frames in flight), the frames go in windows of +/// `max(cap, threads)`: each window renders in consecutive chunks of `cap` (at most `cap` +/// renders at once), then encodes in parallel, so only the render is serialised and at most +/// one window of rendered images is held in memory. Chunks hold no lock or slot, so a failing +/// or panicking frame cannot leak one, and nested parallelism (or a single thread) cannot +/// deadlock. +pub(crate) fn map_frames( + ks: Vec, + cap: Option, + render: impl Fn(u64) -> I + Sync + Send, + encode: impl Fn(u64, I) -> T + Sync + Send, +) -> Vec { + use rayon::prelude::*; + let Some(n) = cap.map(|n| n.max(1)) else { + return ks.into_par_iter().map(|k| encode(k, render(k))).collect(); + }; + let window = n.max(rayon::current_num_threads()); + let mut out = Vec::with_capacity(ks.len()); + for win in ks.chunks(window) { + let mut imgs = Vec::with_capacity(win.len()); + for chunk in win.chunks(n) { + imgs.par_extend(chunk.par_iter().map(|&k| render(k))); + } + out.par_extend(win.par_iter().copied().zip(imgs.into_par_iter()).map(|(k, img)| encode(k, img))); + } + out +} + /// A job being exported: the job, the project with the Render Settings overrides applied, the /// job's layer cache and per-frame timing for the render log. pub(crate) struct Cx<'a> { @@ -164,11 +187,15 @@ impl<'a> Cx<'a> { } return out; } - // #879: with a GPU accelerator every frame in flight holds its own working textures, so - // a whole batch rendered at once can exhaust VRAM (or shared memory, which crashes - // instead of falling back). Render one GPU frame at a time by default; the CPU path is unchanged. - let cap = self.job.accel.map(|_| gpu_inflight(std::env::var(GPU_INFLIGHT_ENV).ok().as_deref())); - map_frames(ks, cap, |k| f(k, self.frame(comp, k))) + map_frames(ks, self.gpu_cap(), |k| self.frame(comp, k), f) + } + + /// GPU frames rendered at once (#879), decided once per batch: with a GPU accelerator every + /// frame in flight holds its own working textures, so a whole batch rendered at once can + /// exhaust VRAM (or shared memory, which crashes instead of falling back). `None` when the + /// frames render on the CPU: no accelerator, or a Software Only project. + fn gpu_cap(&self) -> Option { + (self.job.accel.is_some() && self.project.settings.gpu_acceleration).then(|| gpu_inflight(std::env::var(GPU_INFLIGHT_ENV).ok().as_deref())) } /// Output frame `i`: sampled (fields, pulldown), at the colour depth, cropped and resized. @@ -408,13 +435,21 @@ mod tests { fn run_capped(n: u64, cap: Option) -> (Vec, usize) { use std::sync::atomic::{AtomicUsize, Ordering}; let (now, max) = (AtomicUsize::new(0), AtomicUsize::new(0)); - let out = map_frames((0..n).collect(), cap, |k| { - let c = now.fetch_add(1, Ordering::SeqCst) + 1; - max.fetch_max(c, Ordering::SeqCst); - std::thread::sleep(std::time::Duration::from_millis(2)); - now.fetch_sub(1, Ordering::SeqCst); - k * 10 - }); + let out = map_frames( + (0..n).collect(), + cap, + |k| { + let c = now.fetch_add(1, Ordering::SeqCst) + 1; + max.fetch_max(c, Ordering::SeqCst); + std::thread::sleep(std::time::Duration::from_millis(2)); + now.fetch_sub(1, Ordering::SeqCst); + k + }, + |k, img| { + assert_eq!(k, img); + k * 10 + }, + ); (out, max.load(Ordering::SeqCst)) } @@ -454,28 +489,115 @@ mod tests { assert_eq!(out.len(), 10); assert_eq!(max, 1); // Nested parallelism inside a frame (as the renderer has) on one thread. - let out = pool.install(|| map_frames((0..6).collect(), Some(2), |k| (0..4u64).into_par_iter().map(|i| i + k).sum::())); + let out = pool.install(|| map_frames((0..6).collect(), Some(2), |k| (0..4u64).into_par_iter().map(|i| i + k).sum::(), |_, v| v)); assert_eq!(out, (0..6).map(|k| 6 + 4 * k).collect::>()); } #[test] fn a_failing_frame_does_not_hold_up_the_rest() { - let out = map_frames((0..9).collect(), Some(1), |k| if k == 3 { Err(k) } else { Ok(k) }); + let out = map_frames((0..9).collect(), Some(1), |k| if k == 3 { Err(k) } else { Ok(k) }, |_, r| r); assert_eq!(out.iter().filter(|r| r.is_err()).count(), 1); assert_eq!(out.len(), 9); assert_eq!(out[8], Ok(8)); // A panicking frame propagates once and leaves no slot behind: the next batch runs. - let r = std::panic::catch_unwind(|| map_frames((0..6).collect(), Some(2), |k| if k == 2 { panic!("frame {k}") } else { k })); + let r = std::panic::catch_unwind(|| map_frames((0..6).collect(), Some(2), |k| if k == 2 { panic!("frame {k}") } else { k }, |_, k| k)); assert!(r.is_err()); assert_eq!(run_capped(6, Some(2)).0.len(), 6); } + #[test] + fn encoding_is_not_capped_with_the_render() { + // Only the GPU render is serialised: a window of frames encodes in parallel. + use std::sync::atomic::{AtomicUsize, Ordering}; + let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build().unwrap(); + let (now, max) = (AtomicUsize::new(0), AtomicUsize::new(0)); + let out = pool.install(|| { + map_frames( + (0..8).collect(), + Some(1), + |k| k, + |k, _| { + let c = now.fetch_add(1, Ordering::SeqCst) + 1; + max.fetch_max(c, Ordering::SeqCst); + std::thread::sleep(std::time::Duration::from_millis(20)); + now.fetch_sub(1, Ordering::SeqCst); + k + }, + ) + }); + assert_eq!(out, (0..8).collect::>()); + assert!(max.load(Ordering::SeqCst) > 1, "encodes ran one at a time"); + } + + /// An accelerator that renders nothing itself (the tests only check whether frames are capped). + struct NoAccel; + impl effectcraft_render::Accelerator for NoAccel { + fn name(&self) -> String { + "none".into() + } + fn comp_frame(&self, _r: &Renderer, _comp: effectcraft_project::ItemId, _t: Tick) -> Option { + None + } + fn supports_effect(&self, _id: &str) -> bool { + false + } + fn effects(&self, _chain: &[effectcraft_render::FxStep], _buf: &effectcraft_effects::Buf, _levels: Option) -> Option { + None + } + } + + #[test] + fn software_only_projects_are_not_capped() { + // Review of #879: an accelerator alone capped Software Only projects, which render on the CPU. + use effectcraft_project::render_queue::{OutputFormat, OutputModule, RenderSettings}; + use effectcraft_render::NoFootage; + let (settings, output) = (RenderSettings::default(), OutputModule::for_format(OutputFormat::PngSequence)); + let accel = NoAccel; + for (gpu, want) in [(false, None), (true, Some(GPU_INFLIGHT_DEFAULT))] { + let mut p = Project::default(); + p.settings.gpu_acceleration = gpu; + let job = Job { + project: &p, + footage: &NoFootage, + expr: None, + accel: Some(&accel), + comp: effectcraft_project::ItemId(0), + settings: &settings, + output: &output, + path: "/c/f_[#####].png", + sink: None, + nested_switches: true, + options: Default::default(), + }; + if std::env::var_os(GPU_INFLIGHT_ENV).is_none() { + assert_eq!(Cx::new(&job).gpu_cap(), want, "gpu acceleration {gpu}"); + } else { + assert_eq!(Cx::new(&job).gpu_cap().is_some(), gpu); + } + } + let p = Project::default(); + let job = Job { + project: &p, + footage: &NoFootage, + expr: None, + accel: None, + comp: effectcraft_project::ItemId(0), + settings: &settings, + output: &output, + path: "/c/f_[#####].png", + sink: None, + nested_switches: true, + options: Default::default(), + }; + assert_eq!(Cx::new(&job).gpu_cap(), None, "no accelerator"); + } + #[test] fn gpu_inflight_override_is_a_positive_number_or_the_default() { assert_eq!(gpu_inflight(None), GPU_INFLIGHT_DEFAULT); assert_eq!(gpu_inflight(Some("3")), 3); assert_eq!(gpu_inflight(Some(" 5 ")), 5); - for bad in ["0", "", "-2", "three", "2.5"] { + for bad in ["0", "", "-2", "three", "2.5", "99999999999999999999999"] { assert_eq!(gpu_inflight(Some(bad)), GPU_INFLIGHT_DEFAULT, "{bad:?}"); } } From aa6c47395cf9ed7a194f64d303b5fa20d8e9c7f5 Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:52:10 +0200 Subject: [PATCH 046/181] Cmd/Alt-click twirls: Waveform row, Effect Controls, Project folders Cmd-click (or Alt-click) a twirl arrow now takes the whole tree with it in three more places: Audio's Waveform row in the Timeline, an effect or effect group in Effect Controls, and a folder in the Project panel. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/effect_controls.rs | 33 ++++++++++++++------ crates/ui-egui/src/panels/project.rs | 19 ++++++++--- crates/ui-egui/src/panels/timeline.rs | 19 +++++++++++ crates/ui-egui/tests/ui_effect_controls.rs | 28 +++++++++++++++++ crates/ui-egui/tests/ui_project_rows.rs | 25 +++++++++++++++ 5 files changed, 110 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index d8de61d83..9b70265d8 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -646,6 +646,27 @@ impl<'a> FxScope<'a> { } } +/// Twirl `g` closed (it was `open`) or open; with `all` (Alt/Cmd-click) every group under it follows. +fn toggle_closed(app: &mut EffectcraftApp, g: &PropGroup, open: bool, all: bool) { + fn walk(g: &PropGroup, out: &mut Vec) { + for sg in g.groups() { + out.push(sg.uid); + walk(sg, out); + } + } + let mut uids = vec![g.uid]; + if all { + walk(g, &mut uids); + } + for u in uids { + if open { + app.ui.fx_closed.insert(u); + } else { + app.ui.fx_closed.remove(&u); + } + } +} + #[allow(clippy::too_many_arguments)] fn group_rows( app: &mut EffectcraftApp, @@ -705,11 +726,7 @@ fn group_rows( app.auto.add(&format!("effectControls.row.{}", sg.uid), r, &sg.name); app.auto.add(&format!("effectControls.group.{}.twirl", sg.uid), tw, &sg.name); if widgets::twirl(ui, tw, open, egui::Id::new(("ec-g", sg.uid)), &t).clicked() { - if open { - app.ui.fx_closed.insert(sg.uid); - } else { - app.ui.fx_closed.remove(&sg.uid); - } + toggle_closed(app, sg, open, ui.input(|i| i.modifiers.alt || i.modifiers.command)); } let name = p.text(pos2(tw.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, &sg.name, Tokens::ui(12.0), t.text); app.auto.add(&format!("effectControls.group.{}.name", sg.uid), name, &sg.name); @@ -1114,11 +1131,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add(&format!("effectControls.effect.{}.fx", g.uid), fxr, &g.name); let tw = Rect::from_center_size(pos2(r.min.x + 32.0, r.center().y), vec2(12.0, 12.0)); if widgets::twirl(ui, tw, open, egui::Id::new(("ec-tw", g.uid)), &t).clicked() { - if open { - app.ui.fx_closed.insert(g.uid); - } else { - app.ui.fx_closed.remove(&g.uid); - } + toggle_closed(app, g, open, ui.input(|i| i.modifiers.alt || i.modifiers.command)); } app.auto.add(&format!("effectControls.effect.{}.twirl", g.uid), tw, &g.name); let rename_id = egui::Id::new(("ec-rename", g.uid)); diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index e18f76bc9..1fa132457 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -579,10 +579,21 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name) }); if tresp.clicked() { - if open { - app.ui.project_open_folders.remove(&id.0); - } else { - app.ui.project_open_folders.insert(id.0); + // Alt/Cmd-click twirls every folder inside it too. + let mut ids = vec![*id]; + if ui.input(|i| i.modifiers.alt || i.modifiers.command) { + let mut at = 0; + while let Some(f) = ids.get(at).copied() { + ids.extend(app.session.project.children(Some(f)).into_iter().filter(|c| c.is_folder()).map(|c| c.id)); + at += 1; + } + } + for f in ids { + if open { + app.ui.project_open_folders.remove(&f.0); + } else { + app.ui.project_open_folders.insert(f.0); + } } } app.auto.add(&format!("project.item.{}.twirl", id.0), tw, &it.name); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index afa656ac9..258ca1eca 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3252,6 +3252,10 @@ enum UiAct { } fn walk_groups(g: &PropGroup, out: &mut Vec) { + // Audio's Waveform row is not in the property tree; it twirls along. + if g.match_id == "audio" { + out.push(WAVE_BIT | g.uid); + } for sg in g.groups() { out.push(sg.uid); walk_groups(sg, out); @@ -3804,6 +3808,21 @@ fn value_editor( mod tests { use super::*; + /// Alt/Cmd-click twirls Audio's Waveform row (not in the property tree) with the rest. + #[test] + fn the_waveform_row_twirls_with_its_tree() { + let mut root = PropGroup::new(1, "", ""); + let mut audio = PropGroup::new(2, "audio", "Audio"); + audio.children.push(PropGroup::new(3, "x", "X").into()); + root.children.push(audio.into()); + let mut uids = vec![]; + walk_groups(&root, &mut uids); + assert_eq!(uids, vec![2, WAVE_BIT | 2, 3]); + let mut uids = vec![]; + walk_groups(find_group(&root, 2).unwrap(), &mut uids); + assert_eq!(uids, vec![WAVE_BIT | 2, 3]); + } + /// #710: with Time Display set to Frames the ruler labels are frame numbers (as under the /// CTI, from Start Numbering Frames At); otherwise seconds:frames. #[test] diff --git a/crates/ui-egui/tests/ui_effect_controls.rs b/crates/ui-egui/tests/ui_effect_controls.rs index 3251aed0b..380ae1f70 100644 --- a/crates/ui-egui/tests/ui_effect_controls.rs +++ b/crates/ui-egui/tests/ui_effect_controls.rs @@ -220,6 +220,34 @@ fn keyer_eyedropper_picks_the_screen_from_the_effect_input() { assert!(img.get(160, 90)[3] < 0.01, "{:?}", img.get(160, 90)); } +#[test] +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(); + // 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(); + h.state_mut().ui.fx_closed.extend(others.iter().copied()); + h.run_steps(3); + let inner: Vec = l.effects().unwrap().groups().find(|g| g.uid == fx).unwrap().groups().map(|g| g.uid).collect(); + assert!(!inner.is_empty(), "the effect has twirl-down groups"); + let cmd_click = |h: &mut Harness<'_, EffectcraftApp>| { + let at = rect_of(h, &format!("effectControls.effect.{fx}.twirl")).center(); + h.event(egui::Event::ModifiersChanged(egui::Modifiers::COMMAND)); + click(h, at); + h.event(egui::Event::ModifiersChanged(Default::default())); + h.step(); + }; + cmd_click(&mut h); + let closed = h.state().ui.fx_closed.clone(); + assert!(closed.contains(&fx) && inner.iter().all(|u| closed.contains(u)), "the effect and its groups twirl closed"); + cmd_click(&mut h); + let closed = h.state().ui.fx_closed.clone(); + assert!(!closed.contains(&fx) && !inner.iter().any(|u| closed.contains(u)), "and open again together"); + assert!(others.iter().all(|u| closed.contains(u)), "other effects stay as they were"); +} + fn rect_of(h: &Harness<'_, EffectcraftApp>, id: &str) -> egui::Rect { let e = h.state().auto.previous.iter().chain(h.state().auto.elements.iter()).find(|e| e.id == id).unwrap_or_else(|| panic!("no {id}")).clone(); egui::Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])) diff --git a/crates/ui-egui/tests/ui_project_rows.rs b/crates/ui-egui/tests/ui_project_rows.rs index 7d0ebf667..843e14b4e 100644 --- a/crates/ui-egui/tests/ui_project_rows.rs +++ b/crates/ui-egui/tests/ui_project_rows.rs @@ -59,6 +59,31 @@ fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { click_at(h, p); } +#[test] +fn command_clicking_a_folder_twirl_opens_the_folders_inside_it() { + let (mut h, folder, _, _) = harness(); + let s = &mut h.state_mut().session; + let inner = s.execute("project.newFolder", json!({"name": "Inner"})).unwrap()["item"].as_u64().unwrap(); + let deep = s.execute("project.newFolder", json!({"name": "Deep"})).unwrap()["item"].as_u64().unwrap(); + s.execute("project.move", json!({"items": [inner], "folder": folder.0})).unwrap(); + s.execute("project.move", json!({"items": [deep], "folder": inner})).unwrap(); + s.execute("project.select", json!({"items": []})).unwrap(); + h.state_mut().ui.project_open_folders.clear(); + h.run_steps(3); + let p = center(&h, &format!("project.item.{}.twirl", folder.0)); + let cmd_click = |h: &mut Harness<'_, EffectcraftApp>| { + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND)); + click_at(h, p); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.step(); + }; + cmd_click(&mut h); + let open = h.state().ui.project_open_folders.clone(); + assert!([folder.0, inner, deep].iter().all(|f| open.contains(f)), "the folder and every folder inside it open"); + cmd_click(&mut h); + assert!(h.state().ui.project_open_folders.is_empty(), "and close again together"); +} + #[test] fn folder_twirl_opens_and_closes_the_folder_on_one_click() { let (mut h, folder, comp, _) = harness(); From a50e49df73984f3cd5c986d3a764fe45b2caccea Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 17:13:14 +0200 Subject: [PATCH 047/181] Interpret Footage: looped footage lasts its loops on new and re-timed layers (fixes #1045) --- crates/engine/src/commands/file_more.rs | 4 ++-- crates/engine/src/commands/layer.rs | 4 ++-- crates/engine/src/tests_menu_cmds.rs | 16 ++++++++++++++++ crates/project/src/lib.rs | 7 ++++++- 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index ef26d59b9..12f673fc4 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -771,9 +771,9 @@ fn interpret(s: &mut Session, p: &Value) -> Result { s.edit("Interpret Footage", None, |proj, _| { for (i, g) in items.iter().zip(&guesses) { let Some(ItemKind::Footage(f)) = proj.item_mut(*i).map(|x| &mut x.kind) else { continue }; - let old = f.duration; + let old = f.looped_duration(); it.apply(f, *g); - let new = f.duration; + let new = f.looped_duration(); if new == old { continue; } diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index d4cf1d90d..b83408000 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -283,7 +283,7 @@ fn add_item(s: &mut Session, p: &Value) -> Result { &serde_json::json!({"comp": cid.0, "item": item.0, "time": f_p(p, "time"), "index": p.get("index"), "position": p.get("position")}), ); } - ItemKind::Footage(f) => (LayerSource::Footage { item }, (f.width, f.height), Some(f.duration)), + ItemKind::Footage(f) => (LayerSource::Footage { item }, (f.width, f.height), Some(f.looped_duration())), ItemKind::Solid(so) => (LayerSource::Solid { item }, (so.width, so.height), None), ItemKind::Folder => return Err(bad("layer.addItem", "folders can't be layers")), }; @@ -972,7 +972,7 @@ fn slip(s: &mut Session, p: &Value) -> Result { } let dur = match &l.source { LayerSource::Footage { item } => match s.project.item(*item).map(|i| &i.kind) { - Some(ItemKind::Footage(f)) if f.kind != effectcraft_project::FootageKind::Still => Some(Tick(f.duration.0 * f.loop_count.max(1) as i64)), + Some(ItemKind::Footage(f)) if f.kind != effectcraft_project::FootageKind::Still => Some(f.looped_duration()), _ => None, }, LayerSource::Comp { item } => s.project.comp(*item).map(|c| c.duration), diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index cb83c1639..0459e012e 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -1464,3 +1464,19 @@ fn interpret_footage_loop_from_form_float() { _ => panic!(), } } + +/// Looped video footage lasts its loops: a 2 s clip looped 3 times is a 6 s layer, and a layer +/// that ran to the clip's end is re-timed when the loop count changes (#1045). +#[test] +fn interpret_footage_loop_extends_video_layers() { + let mut s = comp(); + let p = s.execute("file.importPlaceholder", json!({"name": "Clip", "width": 320, "height": 240, "duration": 2})).unwrap()["item"].as_u64().unwrap(); + let before = s.execute("layer.addItem", json!({"item": p})).unwrap()["layer"].as_u64().unwrap(); + s.state.project_selection = vec![effectcraft_project::ItemId(p)]; + s.execute("file.interpretFootage", json!({"loop": 3.0})).unwrap(); + let after = s.execute("layer.addItem", json!({"item": p})).unwrap()["layer"].as_u64().unwrap(); + let out = |s: &Session, l: u64| s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap().out_point.seconds(); + assert_eq!(out(&s, after), 6.0, "a new layer plays every loop"); + assert_eq!(out(&s, before), 6.0, "a layer that ran to the clip's end still does"); + assert_eq!(s.project.item(effectcraft_project::ItemId(p)).unwrap().duration(), Some(Tick::from_seconds_f64(6.0))); +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index fee3b8ce7..072ae7fdd 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1158,6 +1158,11 @@ impl Footage { self.linear_light && !self.preserve_rgb } + /// How long the footage plays: its duration times Interpret Footage ▸ Loop. + pub fn looped_duration(&self) -> Tick { + Tick(self.duration.0.saturating_mul(self.loop_count.max(1) as i64)) + } + /// Source time as the footage's timecode: its frame `frame` from Start Timecode /// (`0:00:41:20`), as the Footage panel shows it. pub fn timecode(&self, frame: i64) -> String { @@ -1281,7 +1286,7 @@ impl Item { pub fn duration(&self) -> Option { match &self.kind { ItemKind::Comp(c) => Some(c.duration), - ItemKind::Footage(f) if f.kind != FootageKind::Still => Some(f.duration), + ItemKind::Footage(f) if f.kind != FootageKind::Still => Some(f.looped_duration()), _ => None, } } From 92e3939ca76f14046af043c50edf0be59c98472f Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:34:56 +0200 Subject: [PATCH 048/181] Fast Previews and Show Channel: one command per mode, with default keys Both were a single command taking the mode as a parameter, with no menu entry, so nothing could carry a default key. Each mode is now its own command: view.fastPreviews.off / adaptive / draft / fastDraft / wireframe (Cmd+Alt+1-5) and view.channel.red / green / blue / alpha (Alt+1-4), redColorized / greenColorized / blueColorized (Alt+Shift+1-3) and rgbStraight (Alt+Shift+4). A channel's key again returns to RGB. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/viewer_cmds.rs | 30 +++++++++++++++++++ crates/engine/src/tests_settings.rs | 35 +++++++++++++++++++++++ docs/control-protocol.md | 4 ++- 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/commands/viewer_cmds.rs b/crates/engine/src/commands/viewer_cmds.rs index 263f7191f..aac24267b 100644 --- a/crates/engine/src/commands/viewer_cmds.rs +++ b/crates/engine/src/commands/viewer_cmds.rs @@ -173,6 +173,20 @@ fn show_snapshot(s: &mut Session, p: &Value) -> Result { Ok(json!(s.state.viewer.show_snapshot)) } +/// A `view.fastPreviews.*` command choosing one Fast Previews mode. +macro_rules! fast { + ($id:literal, $label:literal, $sc:literal, $mode:literal) => { + cmd!($id, $label, [], Some($sc), "{}", always, |s, _| fast_previews(s, &json!({"mode": $mode}))) + }; +} + +/// A `view.channel.*` command showing one channel (again: back to RGB), in grayscale or colorized. +macro_rules! chan { + ($id:literal, $label:literal, $sc:literal, $channel:literal, $colorized:literal) => { + cmd!($id, $label, [], Some($sc), "{}", always, |s, _| channel(s, &json!({"channel": $channel, "colorized": $colorized, "toggle": true}))) + }; +} + fn fast_previews(s: &mut Session, p: &Value) -> Result { let name = str_p(p, "mode").unwrap_or("off"); let m = @@ -504,6 +518,22 @@ pub fn specs() -> Vec { cmd!("view.takeSnapshot", "Take Snapshot", [], Some("Shift+F5"), "{comp?, scale?: 0.05..1}", has_comp, take_snapshot), cmd!("view.showSnapshot", "Show Snapshot", [], None, "{value?}", has_snapshot, show_snapshot), cmd!("view.fastPreviewMode", "Fast Previews", [], None, "{mode: off|adaptive|draft|fastDraft|wireframe}", always, fast_previews), + fast!("view.fastPreviews.off", "Fast Previews: Off (Final Quality)", "Cmd+Alt+1", "off"), + fast!("view.fastPreviews.adaptive", "Fast Previews: Adaptive Resolution", "Cmd+Alt+2", "adaptive"), + fast!("view.fastPreviews.draft", "Fast Previews: Draft", "Cmd+Alt+3", "draft"), + fast!("view.fastPreviews.fastDraft", "Fast Previews: Fast Draft", "Cmd+Alt+4", "fastDraft"), + fast!("view.fastPreviews.wireframe", "Fast Previews: Wireframe", "Cmd+Alt+5", "wireframe"), + chan!("view.channel.red", "Show Channel: Red", "Alt+1", "red", false), + chan!("view.channel.green", "Show Channel: Green", "Alt+2", "green", false), + chan!("view.channel.blue", "Show Channel: Blue", "Alt+3", "blue", false), + chan!("view.channel.alpha", "Show Channel: Alpha", "Alt+4", "alpha", false), + chan!("view.channel.redColorized", "Show Channel: Red, Colorized", "Alt+Shift+1", "red", true), + chan!("view.channel.greenColorized", "Show Channel: Green, Colorized", "Alt+Shift+2", "green", true), + chan!("view.channel.blueColorized", "Show Channel: Blue, Colorized", "Alt+Shift+3", "blue", true), + cmd!("view.channel.rgbStraight", "Show Channel: RGB Straight", [], Some("Alt+Shift+4"), "{}", always, |s, _| channel( + s, + &json!({"channel": "rgbStraight", "toggle": true}) + )), cmd!("view.moveGuide", "Move Guide", [], None, "{comp?, index, position (comp px), merge?}", has_comp, move_guide), cmd!("view.removeGuide", "Remove Guide", [], None, "{comp?, index}", has_comp, remove_guide), cmd!( diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs index 02c2a00b3..f072d4cc6 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests_settings.rs @@ -375,6 +375,41 @@ fn defaults_have_no_conflicts_and_match_the_menus() { assert!(t.bindables.len() > 500); } +/// Each Fast Previews and Show Channel mode is a command with a default key. +#[test] +fn fast_previews_and_show_channel_modes_have_default_keys() { + let s = Session::default(); + let t = s.shortcuts(); + for (id, key) in [ + ("view.fastPreviews.off", "Cmd+Alt+1"), + ("view.fastPreviews.adaptive", "Cmd+Alt+2"), + ("view.fastPreviews.draft", "Cmd+Alt+3"), + ("view.fastPreviews.fastDraft", "Cmd+Alt+4"), + ("view.fastPreviews.wireframe", "Cmd+Alt+5"), + ("view.channel.red", "Alt+1"), + ("view.channel.green", "Alt+2"), + ("view.channel.blue", "Alt+3"), + ("view.channel.alpha", "Alt+4"), + ("view.channel.redColorized", "Alt+Shift+1"), + ("view.channel.greenColorized", "Alt+Shift+2"), + ("view.channel.blueColorized", "Alt+Shift+3"), + ("view.channel.rgbStraight", "Alt+Shift+4"), + ] { + assert_eq!(t.shortcut_of(id, &json!(null)), Some(key), "{id}"); + } + assert!(t.all_conflicts().is_empty(), "{:?}", t.all_conflicts()); + // The commands set their mode; a channel's key again is RGB. + let mut s = Session::default(); + s.execute("view.fastPreviews.fastDraft", json!({})).unwrap(); + assert_eq!(s.state.viewer.fast_previews.id(), "fastDraft"); + s.execute("view.channel.redColorized", json!({})).unwrap(); + assert_eq!((s.state.viewer.channel.id(), s.state.viewer.colorized), ("red", true)); + s.execute("view.channel.green", json!({})).unwrap(); + assert_eq!((s.state.viewer.channel.id(), s.state.viewer.colorized), ("green", false)); + s.execute("view.channel.green", json!({})).unwrap(); + assert_eq!(s.state.viewer.channel.id(), "rgb"); +} + /// Cmd+Left / Cmd+Right Arrow step a frame like Page Up / Page Down (Shift: 10 frames). #[test] fn cmd_arrows_step_frames_like_page_keys() { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f54879817..75ef0e760 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -134,7 +134,9 @@ Viewer state that agents drive headless too: `view.snapping`, `view.snappingOpti `header.snappingOptions`: After Effects' Snap Edges Extended and Snap to Features in Collapsed Compositions and Text Layers), `view.channel {channel, colorized?}`, `view.exposure {stops | delta}`, `view.resetExposure`, `view.takeSnapshot`, `view.showSnapshot`, -`view.fastPreviewMode {mode}`, `view.res.full|half|third|quarter|auto {comp?}` and +`view.fastPreviewMode {mode}` (one mode each, for shortcuts: +`view.fastPreviews.off|adaptive|draft|fastDraft|wireframe`, and for the channels +`view.channel.red|green|blue|alpha|redColorized|greenColorized|blueColorized|rgbStraight`), `view.res.full|half|third|quarter|auto {comp?}` and `view.res.custom {factor, comp?}` (the comp's Resolution, saved with the project but no undo step; `ui.set {viewer: {res}}` sets the active comp's, and `ui.inspect` reports it as `ui.viewer.res`), `view.setRegionOfInterest {rect}`, `view.addGuide`, From 767120f6cae6b3a6e87552143051135c9800d2dd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 12 Oct 2026 00:31:58 -0700 Subject: [PATCH 049/181] EPS: arc and arcn with huge angles no longer hang the import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit arc added 2π to its sweep in a loop until the sweep was no longer negative, and arcn subtracted 2π the same way. For an angle of 10^18 degrees the loop took about 3e15 passes, and from 2^56 radians (about 4.1e18 degrees) up, adding 2π left the sweep unchanged and the loop never ended. A sweep that needed no loop, as in 0 10^18 arc, was passed unchanged to kurbo, which divides it into more than 1e16 curves (about four per turn at a radius of 10), and arc appended each curve to the path. Importing an EPS file probes it with effectcraft_pdf::parse, which has no time limit, so importing such a file did not finish. As in storytold/vectorcraft#1062, every arc now starts at the first angle's remainder after whole turns, and when the second angle is below the first (arc) or above it (arcn), the sweep is worked out from the two angles' remainders. A sweep of up to 1,024 turns is drawn whole. For a longer one, the import records a skip note ("arc (too many turns)") and leaves out whole pairs of turns, so that between 1,022 and 1,024 turns remain. The arc still ends at the second angle, and an even-odd fill covers the same area as the full sweep. Arcs of up to 1,024 turns whose angles are within about 10^6 degrees are unchanged apart from rounding, except some whose angles are a whole number of turns apart or within rounding of that. For larger angles, the old conversion to radians lost precision: at 10^15 degrees, a quarter-turn arc swept 1.5723 radians instead of 1.5708. When raising (arc) or lowering (arcn) the second angle by whole turns makes it equal to the first, as in 7 -353 arc or 2 362 arcn, rounding sometimes made the old loop add a turn and draw a full circle. Those arcs are now empty, as the PostScript Language Reference defines them. When the raised or lowered angle falls just short of the first instead, the reference defines a full circle, and each version leaves some of these arcs empty: the old code drew nothing for 125.00000000000001 125 arc, which the new code draws as a full circle, and the new code draws nothing for 1.0000000000000002 -719 arc, which the old code drew as a full circle. arcs_with_huge_angles_end_at_their_second_angle parses each program on a thread of its own and fails when no result arrives within 20 seconds. --- crates/pdf/src/ps.rs | 44 ++++++++++++++++++------ crates/pdf/src/tests_import.rs | 63 ++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 11 deletions(-) diff --git a/crates/pdf/src/ps.rs b/crates/pdf/src/ps.rs index 3c011dc4f..65e352cb2 100644 --- a/crates/pdf/src/ps.rs +++ b/crates/pdf/src/ps.rs @@ -343,6 +343,15 @@ fn matrix_val(m: Affine) -> V { array(m.as_coeffs().iter().map(|c| V::Num(*c)).collect()) } +/// The angle from `a` counterclockwise to `b` (degrees, 0 to 360), worked out from what is left +/// of each after whole turns. +fn ccw_turn(a: f64, b: f64) -> f64 { + (b % 360.0 - a % 360.0).rem_euclid(360.0) +} + +/// The most turns one `arc` or `arcn` draws. +const MAX_ARC_TURNS: f64 = 1024.0; + /// The deepest procedure nesting run (deeper recursion stops the program). const MAX_CALL_DEPTH: usize = 256; @@ -502,20 +511,33 @@ impl Interp { } fn arc(&mut self, neg: bool) { - let a2 = self.popn().to_radians(); - let a1 = self.popn().to_radians(); + let a2 = self.popn(); + let a1 = self.popn(); let r = self.popn(); let c = self.user_point(); - let mut sweep = a2 - a1; - if neg { - while sweep > 0.0 { - sweep -= std::f64::consts::TAU; - } + // `arc` raises `a2` by multiples of 360 until it is at least `a1`, and `arcn` lowers it + // until it is at most `a1` (PostScript Language Reference §8.2, `arc` and `arcn`). Those + // sweeps, and the first endpoint, are worked out from what is left of each angle after + // whole turns. + let sweep = if !neg && a2 < a1 { + ccw_turn(a1, a2) + } else if neg && a2 > a1 { + -ccw_turn(a2, a1) } else { - while sweep < 0.0 { - sweep += std::f64::consts::TAU; - } - } + a2 - a1 + }; + // A sweep of more than `MAX_ARC_TURNS` turns is shortened by whole pairs of turns to + // between `MAX_ARC_TURNS - 2` and `MAX_ARC_TURNS` turns, with a skip note. The arc still + // ends at `a2`, and an even-odd fill or clip of it covers the same area as the full sweep. + let max = MAX_ARC_TURNS * 360.0; + let sweep = if sweep.abs() > max { + self.b.skip("arc (too many turns)"); + let s = sweep.signum(); + s * (max - 720.0 + (s * (a2 % 720.0 - a1 % 720.0)).rem_euclid(720.0)) + } else { + sweep + }; + let (a1, sweep) = ((a1 % 360.0).to_radians(), sweep.to_radians()); let start = Point::new(c.x + r * a1.cos(), c.y + r * a1.sin()); if self.gs.cur.is_some() { self.line_to(start); diff --git a/crates/pdf/src/tests_import.rs b/crates/pdf/src/tests_import.rs index 8314c7d13..532e32cb9 100644 --- a/crates/pdf/src/tests_import.rs +++ b/crates/pdf/src/tests_import.rs @@ -296,6 +296,69 @@ fn truncated_and_corrupt_eps_never_panic() { } } +/// Parses `body` after the header [`eps`] adds, on a thread of its own, and fails the test when +/// parsing takes more than 20 s. +fn eps_soon(body: &str) -> Doc { + let mut b = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 100\n".to_vec(); + b.extend_from_slice(body.as_bytes()); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || tx.send(parse(&b)).ok()); + rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("parsing {body} did not finish")).unwrap() +} + +#[test] +fn arcs_with_huge_angles_end_at_their_second_angle() { + use kurbo::{Point, Rect, Shape as _}; + // `arc` raises its second angle by multiples of 360 until it is at least the first, and + // `arcn` lowers it until it is at most the first (PostScript Language Reference §8.2), + // however large the angles: the arc starts at the first angle's remainder after whole turns + // and ends at the second angle, the new current point. 10^18 and 10^308 are written in + // digits: the tokenizer reads no exponents. + let (e18, e308) = (format!("1{}", "0".repeat(18)), format!("1{}", "0".repeat(308))); + // `at(deg)` is the point at `deg` degrees on the circle of radius 10 around (50, 50), in + // document pixels. + let at = |deg: f64| Point::new(50.0 + 10.0 * deg.to_radians().cos(), 50.0 - 10.0 * deg.to_radians().sin()); + let near = |a: Rect, b: Rect| [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() < 0.05); + // Each program is listed with the remainders of its first and second angles after whole + // turns, and whether its sweep is more than 1,024 turns (a skip note is recorded, and the arc + // still goes around the whole circle). + for (body, first, second, whole) in [ + (format!("50 50 10 {e18} 0 arc"), 280.0, 0.0, false), + (format!("50 50 10 {e308} 0 arc"), 296.0, 0.0, false), + (format!("50 50 10 0 {e18} arcn"), 0.0, 280.0, false), + (format!("50 50 10 0 {e308} arcn"), 0.0, 296.0, false), + (format!("50 50 10 0 {e18} arc"), 0.0, 280.0, true), + (format!("50 50 10 0 {e308} arc"), 0.0, 296.0, true), + (format!("50 50 10 {e18} 0 arcn"), 280.0, 0.0, true), + (format!("50 50 10 {e308} 0 arcn"), 296.0, 0.0, true), + ] { + let doc = eps_soon(&format!("newpath {body} currentpoint stroke 1 1 rectfill")); + let skipped: &[&str] = if whole { &["arc (too many turns)"] } else { &[] }; + assert_eq!(doc.skipped, skipped, "{body}"); + let shapes = doc.flatten(); + assert_eq!(shapes.len(), 2, "{body}"); + let bounds = |i: usize| (shapes[i].0 * shapes[i].2.geom.to_path()).bounding_box(); + // An arc within one quarter of the circle is spanned by its endpoints. + let (a, b) = (at(first), at(second)); + let arc = if whole { Rect::new(40.0, 40.0, 60.0, 60.0) } else { Rect::from_points(a, b) }; + assert!(near(bounds(0), arc), "{body}: {:?}", bounds(0)); + // The second shape is the square drawn at the current point. + assert!(near(bounds(1), Rect::new(b.x, b.y - 1.0, b.x + 1.0, b.y)), "{body}: {:?}", bounds(1)); + } + // Sweeps of up to 1,024 turns are drawn whole: five turns counterclockwise and three + // clockwise wind around the disk twice, and a nonzero fill covers it. + let doc = eps_soon("50 50 10 0 1800 arc 50 50 10 0 -1080 arcn fill"); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + let img = render(&doc); + assert!(img.get(50, 50)[3] > 0.99, "{:?}", img.get(50, 50)); + // A longer sweep keeps the parity of its turns: 5 × 10^18 degrees is an even number of whole + // turns and 320 degrees, so an even-odd fill covers the center of the disk. + let doc = eps_soon("50 50 10 0 5000000000000000000 arc eofill"); + assert_eq!(doc.skipped, ["arc (too many turns)"]); + let img = render(&doc); + assert!(img.get(50, 50)[3] > 0.99, "{:?}", img.get(50, 50)); +} + #[test] fn closing_an_empty_path_is_a_no_op() { // "s", "b" and "b*" closed the path without a current point (kurbo asserts on that). From 6f5bd0c27298055fa84abf0bbca31d219e5517ed Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:35:27 +0200 Subject: [PATCH 050/181] Default keys for ten commands that had none Title/Action Safe ('), Zoom to Fit Comp (Shift+;), Custom resolution (Cmd+Alt+J), Purge Snapshot (Cmd+Shift+F5), Bilinear / Bicubic sampling (Alt+B, Alt+Shift+B), Set Proxy (Cmd+Alt+P), most recent project (Cmd+Alt+Shift+P), last animation preset (Cmd+Alt+Shift+F) and Paste Reversed Keyframes (Cmd+Shift+V). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/app_more.rs | 10 +++++++++- crates/engine/src/commands/edit.rs | 10 +++++++++- crates/engine/src/commands/proxy.rs | 2 +- crates/engine/src/commands/settings.rs | 2 +- crates/engine/src/commands/view.rs | 10 +++++++++- crates/engine/src/menus.rs | 6 +++--- crates/engine/src/tests_settings.rs | 20 ++++++++++++++++++++ crates/ui-egui/src/menus.rs | 4 ++-- 8 files changed, 54 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/commands/app_more.rs b/crates/engine/src/commands/app_more.rs index 9f6b7c99c..2a0056900 100644 --- a/crates/engine/src/commands/app_more.rs +++ b/crates/engine/src/commands/app_more.rs @@ -582,7 +582,15 @@ pub fn specs() -> Vec { cmd!("edit.history", "History", [], None, "{steps?: n (undo n steps), redo?: n}", has_history, history), cmd!("file.importRecent", "Import Recent Footage", [], None, "{index? | path?}", has_recent_footage, import_recent), cmd!("file.clearRecentFootage", "Clear Recent Footage", ["File", "Import Recent Footage"], None, "{}", has_recent_footage, clear_recent_footage), - cmd!("anim.applyRecentPreset", "Recent Animation Presets", [], None, "{index? | path?, layers?}", has_recent_presets, apply_recent_preset), + cmd!( + "anim.applyRecentPreset", + "Recent Animation Presets", + [], + Some("Cmd+Alt+Shift+F"), + "{index? | path?, layers?}", + has_recent_presets, + apply_recent_preset + ), cmd!( "anim.clearRecentPresets", "Clear Recent Presets", diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 7f134c6b9..d28f34129 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -863,7 +863,15 @@ pub fn specs() -> Vec { has_comp, paste_svg ), - cmd!("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", ["Edit"], None, "{layers?, prop?|path?, time?}", has_key_clip, paste_reversed), + cmd!( + "edit.pasteReversedKeyframes", + "Paste Reversed Keyframes", + ["Edit"], + Some("Cmd+Shift+V"), + "{layers?, prop?|path?, time?}", + has_key_clip, + paste_reversed + ), cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{layers?}", layers_or_keys, delete), cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{layers?} (effects or shape items when they are selected)", has_layers, duplicate), cmd!("edit.splitLayer", "Split Layer", ["Edit"], Some("Cmd+Shift+D"), "{layers?}", has_layers, split), diff --git a/crates/engine/src/commands/proxy.rs b/crates/engine/src/commands/proxy.rs index f05e97656..b7d644b94 100644 --- a/crates/engine/src/commands/proxy.rs +++ b/crates/engine/src/commands/proxy.rs @@ -221,7 +221,7 @@ pub fn specs() -> Vec { has_comp_selection, create_proxy ), - cmd!("file.setProxy", "File...", ["File", "Set Proxy"], None, "{path, items?|item?}", has_targets, set_proxy), + cmd!("file.setProxy", "File...", ["File", "Set Proxy"], Some("Cmd+Alt+P"), "{path, items?|item?}", has_targets, set_proxy), cmd!("file.setProxyNone", "None", ["File", "Set Proxy"], None, "{items?|item?}", has_proxy, set_proxy_none), cmd!("file.useProxy", "Use Proxy", [], None, "{items?|item?, on?}", has_proxy, use_proxy), cmd!( diff --git a/crates/engine/src/commands/settings.rs b/crates/engine/src/commands/settings.rs index 1a5789e28..a6621c3f4 100644 --- a/crates/engine/src/commands/settings.rs +++ b/crates/engine/src/commands/settings.rs @@ -283,7 +283,7 @@ pub fn specs() -> Vec { cmd!("shortcuts.export", "Export Keyboard Shortcuts", [], None, "{preset?, path?}", always, sc_export), cmd!("shortcuts.import", "Import Keyboard Shortcuts", [], None, "{path? | preset?: exported document, name?}", always, sc_import), query!("shortcuts.conflicts", "Keyboard Shortcut Conflicts", "{}", sc_conflicts), - cmd!("file.openRecent", "Open Recent", [], None, "{index? | path?}", has_recent, open_recent), + cmd!("file.openRecent", "Open Recent", [], Some("Cmd+Alt+Shift+P"), "{index? | path?}", has_recent, open_recent), cmd!("file.clearRecent", "Clear Recent Projects", ["File", "Open Recent"], None, "{}", has_recent, clear_recent), cmd!("file.autoSave", "Auto-Save Now", [], None, "{}", always, auto_save), query!("file.recoveryInfo", "Auto-Save Status", "{}", recovery_info), diff --git a/crates/engine/src/commands/view.rs b/crates/engine/src/commands/view.rs index abf924169..a23bf618c 100644 --- a/crates/engine/src/commands/view.rs +++ b/crates/engine/src/commands/view.rs @@ -136,7 +136,15 @@ pub fn specs() -> Vec { res!("view.res.half", "Half", ["View", "Resolution"], Some("Cmd+Shift+J"), Half), res!("view.res.third", "Third", ["View", "Resolution"], None, Third), res!("view.res.quarter", "Quarter", ["View", "Resolution"], Some("Cmd+Alt+Shift+J"), Quarter), - cmd!("view.res.custom", "Custom...", ["View", "Resolution"], None, "{factor?: 1..40 (render every n-th pixel), comp?}", has_comp, custom_resolution), + cmd!( + "view.res.custom", + "Custom...", + ["View", "Resolution"], + Some("Cmd+Alt+J"), + "{factor?: 1..40 (render every n-th pixel), comp?}", + has_comp, + custom_resolution + ), // The Composition panel's Resolution popup (not in the View menu). res!("view.res.auto", "Resolution: Auto", [], None, Auto), cmd!("view.layout", "Switch View Layout", [], None, "{views: 1|2|4}", has_comp, view_layout), diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index bd1cffb0a..5fedf7824 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -650,7 +650,7 @@ Edit All 3D Cache... | edit.purge {"what":"3d"} Undo | edit.purgeUndo Image Cache Memory | edit.purge {"what":"image"} - Snapshot | edit.purge {"what":"snapshot"} + Snapshot | edit.purge {"what":"snapshot"} | Cmd+Shift+F5 --- Edit Original... | edit.editOriginal --- @@ -775,8 +775,8 @@ Layer Draft | layer.quality {"quality":"draft"} | Cmd+Shift+U Wireframe | layer.quality {"quality":"wireframe"} | Cmd+Alt+Shift+U --- - Bilinear | layer.sampling {"sampling":"bilinear"} - Bicubic | layer.sampling {"sampling":"bicubic"} + Bilinear | layer.sampling {"sampling":"bilinear"} | Alt+B + Bicubic | layer.sampling {"sampling":"bicubic"} | Alt+Shift+B Switches Hide Other Video | layer.hideOtherVideo Show All Video | layer.showAllVideo diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs index f072d4cc6..b425e098d 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests_settings.rs @@ -410,6 +410,26 @@ fn fast_previews_and_show_channel_modes_have_default_keys() { assert_eq!(s.state.viewer.channel.id(), "rgb"); } +/// Default keys for commands and menu entries that had none. +#[test] +fn default_keys_for_commands_that_had_none() { + let s = Session::default(); + let t = s.shortcuts(); + for (id, params, key) in [ + ("edit.pasteReversedKeyframes", json!(null), "Cmd+Shift+V"), + ("edit.purge", json!({"what": "snapshot"}), "Cmd+Shift+F5"), + ("layer.sampling", json!({"sampling": "bilinear"}), "Alt+B"), + ("layer.sampling", json!({"sampling": "bicubic"}), "Alt+Shift+B"), + ("view.res.custom", json!(null), "Cmd+Alt+J"), + ("file.setProxy", json!(null), "Cmd+Alt+P"), + ("file.openRecent", json!(null), "Cmd+Alt+Shift+P"), + ("anim.applyRecentPreset", json!(null), "Cmd+Alt+Shift+F"), + ] { + assert_eq!(t.shortcut_of(id, ¶ms), Some(key), "{id} {params}"); + } + assert!(t.all_conflicts().is_empty(), "{:?}", t.all_conflicts()); +} + /// Cmd+Left / Cmd+Right Arrow step a frame like Page Up / Page Down (Shift: 10 frames). #[test] fn cmd_arrows_step_frames_like_page_keys() { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b10b0e31c..a60e524a8 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -43,7 +43,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("viewer.nudge.down10", "Move Selected Layers Down 10 Pixels at Current Magnification", [], Some("Shift+ArrowDown")), uic!("viewer.nudge.left10", "Move Selected Layers Left 10 Pixels at Current Magnification", [], Some("Shift+ArrowLeft")), uic!("viewer.nudge.right10", "Move Selected Layers Right 10 Pixels at Current Magnification", [], Some("Shift+ArrowRight")), - uic!("view.safeMargins", "Title/Action Safe", [], None), + uic!("view.safeMargins", "Title/Action Safe", [], Some("'")), uic!("view.transparencyGrid", "Transparency Grid", [], None), uic!("view.fastPreviews", "Fast Previews", [], None), uic!("view.theme.toggle", "Next Appearance Mode", [], None), @@ -52,7 +52,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("view.theme.light", "Theme: Light", [], None), uic!("timeline.zoomIn", "Zoom In Time", [], Some("=")), uic!("timeline.zoomOut", "Zoom Out Time", [], Some("-")), - uic!("timeline.zoomFit", "Zoom to Fit Comp", [], None), + uic!("timeline.zoomFit", "Zoom to Fit Comp", [], Some("Shift+;")), uic!("timeline.zoomFrameToggle", "Zoom In to Frame Level / Out to the Whole Comp", [], Some(";")), uic!("timeline.graphEditor", "Graph Editor", [], Some("Shift+F3")), uic!("timeline.switchesModes", "Toggle Switches / Modes", [], Some("F4")), From 4ee7e03584fc33261fed19101ed95d667e23fab1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 12 Oct 2026 06:54:19 -0700 Subject: [PATCH 051/181] Contributors: add dotCooCoo (Robert Lee) --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 6a5351215..0aeb9c6c7 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -64,3 +64,7 @@ sync_github_name = true [people.webdevtodayjason] display_name = "Jason Brashear" + +[people.dotCooCoo] +real_name = "Robert Lee" +sync_github_name = true From eec84a8095242c315eaa348c908e5eca61e46a37 Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:37:18 +0200 Subject: [PATCH 052/181] Second default keys, also in menu lines F2 Deselect All, Cmd+Shift+T Effect Controls, Cmd+Alt+Left / Right Go to Start / End, Cmd+Shift+/ Add to Render Queue. Effect Controls is a menu entry with bound parameters, which could hold one key: a menu line can now give further default keys after a comma (F3, Cmd+Shift+T); the menus show the first. MenuEntry gains alt_shortcuts. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/edit.rs | 2 +- crates/engine/src/commands/render_queue.rs | 2 +- crates/engine/src/commands/time.rs | 4 +- crates/engine/src/menus.rs | 32 ++++++++----- crates/engine/src/shortcuts.rs | 3 +- crates/engine/src/tests_m145.rs | 8 +++- crates/engine/src/tests_settings.rs | 20 ++++++++ crates/ui-egui/src/i18n.rs | 53 +++++++++++++++------- 8 files changed, 90 insertions(+), 34 deletions(-) diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index d28f34129..06ba43b1c 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -878,7 +878,7 @@ pub fn specs() -> Vec { cmd!("edit.liftWorkArea", "Lift Work Area", ["Edit"], None, "{layers?}", has_comp, lift), cmd!("edit.extractWorkArea", "Extract Work Area", ["Edit"], None, "{layers?}", has_comp, extract), cmd!("edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", has_comp, select_all), - cmd!("edit.deselectAll", "Deselect All", ["Edit"], Some("Cmd+Shift+A"), "{}", always_ok, deselect_all), + cmd!("edit.deselectAll", "Deselect All", ["Edit"], ["Cmd+Shift+A", "F2"], "{}", always_ok, deselect_all), cmd!("edit.label", "Label", ["Edit", "Label"], None, "{label: Red|Yellow|Aqua|…, layers?, keys?, target?: layers}", always_ok, label), cmd!("edit.selectLabelGroup", "Select Label Group", ["Edit", "Label"], None, "{}", has_props_or_layers_or_items, select_label_group), cmd!("edit.purgeUndo", "Undo", ["Edit", "Purge"], None, "{}", always_ok, purge), diff --git a/crates/engine/src/commands/render_queue.rs b/crates/engine/src/commands/render_queue.rs index 93a56ced7..09a7c7d2f 100644 --- a/crates/engine/src/commands/render_queue.rs +++ b/crates/engine/src/commands/render_queue.rs @@ -998,7 +998,7 @@ pub fn specs() -> Vec { "renderQueue.add", "Add to Render Queue", ["Composition"], - Some("Cmd+M"), + ["Cmd+M", "Cmd+Shift+/"], "{comp?: id|name, template?: Render Settings template, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff, output?: path|template, log?: errorsOnly|plusSettings|plusPerFrameInfo, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, proxyUse?, effects?, solo?, guideLayers?, colorDepth?, frameBlending?, fieldRender?, pulldown?, motionBlur?, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool, channels?: rgb|rgba|alpha, color?, bitrate?: kbps, webmBitrate?, keyframeInterval?, proresProfile?, profile?, level?, rateControl?, webmCodec?, audioBitrate?, opusApplication?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}", can_add, add diff --git a/crates/engine/src/commands/time.rs b/crates/engine/src/commands/time.rs index 1bc4638d9..a0060ad9e 100644 --- a/crates/engine/src/commands/time.rs +++ b/crates/engine/src/commands/time.rs @@ -178,8 +178,8 @@ pub fn specs() -> Vec { cmd!("time.forward10", "Forward 10 Frames", [], ["Shift+PageDown", "Cmd+Shift+ArrowRight"], "{}", has_comp, |s, _| step(s, &json!({"frames": 10}))), cmd!("time.back10", "Back 10 Frames", [], ["Shift+PageUp", "Cmd+Shift+ArrowLeft"], "{}", has_comp, |s, _| step(s, &json!({"frames": -10}))), cmd!("time.step", "Step Frames", [], None, "{frames}", has_comp, step), - cmd!("time.start", "Go to Start", [], Some("Home"), "{}", has_comp, |s, _| go(s, &json!({"to": "start"}))), - cmd!("time.end", "Go to End", [], Some("End"), "{}", has_comp, |s, _| go(s, &json!({"to": "end"}))), + cmd!("time.start", "Go to Start", [], ["Home", "Cmd+Alt+ArrowLeft"], "{}", has_comp, |s, _| go(s, &json!({"to": "start"}))), + cmd!("time.end", "Go to End", [], ["End", "Cmd+Alt+ArrowRight"], "{}", has_comp, |s, _| go(s, &json!({"to": "end"}))), cmd!("time.layerIn", "Go to Layer In Point", [], Some("I"), "{}", has_comp, |s, _| go(s, &json!({"to": "layerIn"}))), cmd!("time.layerOut", "Go to Layer Out Point", [], Some("O"), "{}", has_comp, |s, _| go(s, &json!({"to": "layerOut"}))), cmd!( diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 5fedf7824..1fb215a32 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -18,10 +18,11 @@ //! ``` //! //! An entry's shortcut is the command's default shortcut unless the line gives one (needed for -//! entries with bound parameters). Adobe-service entries (Team Projects, 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. +//! entries with bound parameters). A line can give further default keys after a comma +//! (`| F3, Cmd+Shift+T`); the menus show the first. Adobe-service entries (Team Projects, +//! 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. use std::sync::OnceLock; @@ -61,7 +62,7 @@ fn file_name(p: &str) -> String { } fn dyn_entry(label: String, command: &str, params: Value) -> MenuEntry { - MenuEntry { label, command: command.into(), params, shortcut: None } + MenuEntry { label, command: command.into(), params, shortcut: None, alt_shortcuts: vec![] } } /// The bindable holding shortcut `keys` in the active preset (its label). @@ -175,6 +176,9 @@ pub struct MenuEntry { #[serde(skip_serializing_if = "Value::is_null")] pub params: Value, pub shortcut: Option, + /// Further default shortcuts (the menus show `shortcut`). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub alt_shortcuts: Vec, } impl MenuEntry { @@ -434,8 +438,9 @@ pub fn parse(text: &str, mac: bool) -> Result, String> { }; // Entries with bound params state their own shortcut; plain entries use the command's. let inherited = if params.is_null() { commands::find(&command).and_then(|c| c.shortcut).map(str::to_string) } else { None }; - let shortcut = parts.next().map(|s| s.trim().to_string()).or(inherited); - out.push(MenuNode::Item(MenuEntry { label, command, params, shortcut })); + let mut keys = parts.next().into_iter().flat_map(|s| s.split(", ")).map(|s| s.trim().to_string()); + let shortcut = keys.next().or(inherited); + out.push(MenuNode::Item(MenuEntry { label, command, params, shortcut, alt_shortcuts: keys.collect() })); } None => { let children = build(lines, i, depth + 1, mac)?; @@ -456,8 +461,13 @@ pub fn parse(text: &str, mac: bool) -> Result, String> { fn effect_categories() -> Vec { let mut cats: Vec<(&str, Vec)> = vec![]; for e in crate::effects::all() { - let entry = - MenuNode::Item(MenuEntry { label: e.name.into(), command: "effect.apply".into(), params: serde_json::json!({"effect": e.id}), shortcut: None }); + let entry = MenuNode::Item(MenuEntry { + label: e.name.into(), + command: "effect.apply".into(), + params: serde_json::json!({"effect": e.id}), + shortcut: None, + alt_shortcuts: vec![], + }); match cats.iter_mut().find(|(c, _)| *c == e.category) { Some((_, v)) => v.push(entry), None => cats.push((e.category, vec![entry])), @@ -954,7 +964,7 @@ Layer Pre-compose... | layer.precompose Scene Edit Detection... | layer.sceneEditDetection Effect - Effect Controls | window.panel {"panel":"effectControls"} | F3 + Effect Controls | window.panel {"panel":"effectControls"} | F3, Cmd+Shift+T Last Effect | effect.applyLast Remove All | effect.removeAll --- @@ -1203,7 +1213,7 @@ Window Wiggler | window.panel {"panel":"wiggler"} --- Composition | window.panel {"panel":"composition"} - Effect Controls | window.panel {"panel":"effectControls"} | F3 + Effect Controls | window.panel {"panel":"effectControls"} | F3, Cmd+Shift+T Flowchart | window.panel {"panel":"flowchart"} | Cmd+F11 Footage | window.panel {"panel":"footage"} Layer | window.panel {"panel":"layer"} diff --git a/crates/engine/src/shortcuts.rs b/crates/engine/src/shortcuts.rs index 942184e38..630bfc2da 100644 --- a/crates/engine/src/shortcuts.rs +++ b/crates/engine/src/shortcuts.rs @@ -90,7 +90,8 @@ pub fn bindables(ui: &[UiCommand]) -> Vec { let key = binding_key(&e.command, &e.params); // A plain entry also gets its command's further default keys. let alt = if e.params.is_null() { crate::commands::find(&e.command).map_or(&[][..], |c| c.alt_shortcuts) } else { &[] }; - let defaults = e.shortcut.as_deref().into_iter().chain(alt.iter().copied()).filter_map(normalize).collect(); + let defaults = + e.shortcut.as_deref().into_iter().chain(e.alt_shortcuts.iter().map(String::as_str)).chain(alt.iter().copied()).filter_map(normalize).collect(); push( Bindable { key, diff --git a/crates/engine/src/tests_m145.rs b/crates/engine/src/tests_m145.rs index 183ae65fd..64bd9ebb6 100644 --- a/crates/engine/src/tests_m145.rs +++ b/crates/engine/src/tests_m145.rs @@ -661,7 +661,13 @@ fn dynamic_menus_and_window_viewer_labels() { assert_eq!(crate::menus::dynamic(&s, "history", &Default::default()).0.len(), s.history.undo.len()); let a = solid(&mut s, "#ff0000"); s.state.selected_layers = vec![LayerId(a)]; - let entry = |cmd: &str, panel: &str| crate::menus::MenuEntry { label: "x".into(), command: cmd.into(), params: json!({"panel": panel}), shortcut: None }; + let entry = |cmd: &str, panel: &str| crate::menus::MenuEntry { + label: "x".into(), + command: cmd.into(), + params: json!({"panel": panel}), + shortcut: None, + alt_shortcuts: vec![], + }; assert_eq!(crate::menus::entry_label(&s, &entry("window.panel", "composition")), "Composition: Main"); assert_eq!(crate::menus::entry_label(&s, &entry("window.panel", "timeline")), "Timeline: Main"); assert!(crate::menus::entry_label(&s, &entry("window.panel", "layer")).starts_with("Layer: ")); diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs index b425e098d..fea7709ef 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests_settings.rs @@ -430,6 +430,26 @@ fn default_keys_for_commands_that_had_none() { assert!(t.all_conflicts().is_empty(), "{:?}", t.all_conflicts()); } +/// Second default keys: on the command, or after a comma in a menu line with bound parameters. +#[test] +fn second_default_keys() { + let s = Session::default(); + let t = s.shortcuts(); + for (key, want) in [ + ("edit.deselectAll", ["Cmd+Shift+A", "F2"]), + ("time.start", ["Home", "Cmd+Alt+ArrowLeft"]), + ("time.end", ["End", "Cmd+Alt+ArrowRight"]), + ("renderQueue.add", ["Cmd+M", "Cmd+Shift+/"]), + (r#"window.panel {"panel":"effectControls"}"#, ["F3", "Cmd+Shift+T"]), + ] { + assert_eq!(t.keys.get(key).map(Vec::as_slice), Some(want.map(String::from).as_slice()), "{key}"); + } + // The menus keep showing the first key. + assert_eq!(t.shortcut_of("window.panel", &json!({"panel": "effectControls"})), Some("F3")); + assert_eq!(t.shortcut_of("edit.deselectAll", &json!(null)), Some("Cmd+Shift+A")); + assert!(t.all_conflicts().is_empty(), "{:?}", t.all_conflicts()); +} + /// Cmd+Left / Cmd+Right Arrow step a frame like Page Up / Page Down (Shift: 10 frames). #[test] fn cmd_arrows_step_frames_like_page_keys() { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 4e814ea26..9c10a4e9a 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -6591,7 +6591,8 @@ mod tests { assert_eq!(label(&app, "", source), translated); } assert_eq!(label(&app, "unknown.command", "Text"), "Text"); - let panel = MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel": "layer"}), shortcut: None }; + let panel = + MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel": "layer"}), shortcut: None, alt_shortcuts: vec![] }; assert_eq!(entry(&app, &panel, "Layer: File Čeština".into()), "Vrstva: File Čeština"); assert_eq!( submenu(&app, "Assign Shortcut to Workspace", "Assign Shortcut to “File Čeština” Workspace".into()), @@ -6607,8 +6608,8 @@ mod tests { fn czech_undo_redo_keeps_history_labels_verbatim() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); app.session.execute("prefs.set", json!({"key": "general.language", "value": "cs"})).unwrap(); - let undo = MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None }; - let redo = MenuEntry { label: "Redo".into(), command: "edit.redo".into(), params: json!({}), shortcut: None }; + let undo = MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None, alt_shortcuts: vec![] }; + let redo = MenuEntry { label: "Redo".into(), command: "edit.redo".into(), params: json!({}), shortcut: None, alt_shortcuts: vec![] }; assert_eq!(entry(&app, &undo, "Can't Undo".into()), "Není co vrátit"); assert_eq!(entry(&app, &redo, "Can't Redo".into()), "Není co zopakovat"); app.session.history.undo.push(("File Čeština".into(), app.session.project.clone())); @@ -6627,7 +6628,7 @@ mod tests { ("Reveal in Explorer", "Zobrazit v Průzkumníku"), ("Reveal in File Manager", "Zobrazit ve správci souborů"), ] { - let e = MenuEntry { label: source.into(), command: command.into(), params: json!({}), shortcut: None }; + let e = MenuEntry { label: source.into(), command: command.into(), params: json!({}), shortcut: None, alt_shortcuts: vec![] }; assert_eq!(label(&app, command, source), translated); assert_eq!(entry(&app, &e, source.into()), translated); assert_eq!(label(&app, "file.openRecent", source), source); @@ -6722,7 +6723,8 @@ mod tests { ("Reveal in Explorer", "Показати у Провіднику"), ("Reveal in File Manager", "Показати у файловому менеджері"), ] { - let e = MenuEntry { label: source.into(), command: command.into(), params: json!({"item": "My file.mp4"}), shortcut: None }; + let e = + MenuEntry { label: source.into(), command: command.into(), params: json!({"item": "My file.mp4"}), shortcut: None, alt_shortcuts: vec![] }; assert_eq!(label(&app, command, source), translated); assert_eq!(entry(&app, &e, source.into()), translated); assert_eq!(label(&app, "file.openRecent", source), source); @@ -6751,7 +6753,7 @@ mod tests { assert_eq!( entry( &app, - &MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None }, + &MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None, alt_shortcuts: vec![] }, "Layer: File".into() ), "レイヤー: File" @@ -6854,13 +6856,17 @@ mod tests { // Untranslated keys and dynamic labels stay verbatim. assert_eq!(label(&app, "unknown.command", "Text"), "Text"); assert_eq!( - entry(&app, &MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None }, "Undo Comp1".into()), + entry( + &app, + &MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None, alt_shortcuts: vec![] }, + "Undo Comp1".into() + ), "无可撤销的操作" ); assert_eq!( entry( &app, - &MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None }, + &MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None, alt_shortcuts: vec![] }, "Layer: File".into() ), "图层: File" @@ -6894,13 +6900,17 @@ mod tests { // Untranslated keys and dynamic labels stay verbatim. assert_eq!(label(&app, "unknown.command", "Text"), "Text"); assert_eq!( - entry(&app, &MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None }, "Undo Comp1".into()), + entry( + &app, + &MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None, alt_shortcuts: vec![] }, + "Undo Comp1".into() + ), "沒有可復原的操作" ); assert_eq!( entry( &app, - &MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None }, + &MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None, alt_shortcuts: vec![] }, "Layer: File".into() ), "圖層: File" @@ -6943,7 +6953,8 @@ mod tests { assert_eq!(label(&app, "layer.addTextAnimator", "Tracking"), "Міжлітерний інтервал"); assert_eq!(label(&app, "edit.purgeUndo", "Undo"), "Історія скасувань"); assert_eq!(label(&app, "unknown.command", "Text"), "Text"); - let panel = MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None }; + let panel = + MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None, alt_shortcuts: vec![] }; assert_eq!(entry(&app, &panel, "Layer: File".into()), "Шар: File"); assert_eq!( submenu(&app, "Assign Shortcut to Workspace", "Assign Shortcut to “File Україна” Workspace".into()), @@ -6958,8 +6969,8 @@ mod tests { fn ukrainian_undo_redo_keeps_history_labels_verbatim() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); app.session.execute("prefs.set", json!({"key":"general.language", "value":"uk"})).unwrap(); - let undo = MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None }; - let redo = MenuEntry { label: "Redo".into(), command: "edit.redo".into(), params: json!({}), shortcut: None }; + let undo = MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None, alt_shortcuts: vec![] }; + let redo = MenuEntry { label: "Redo".into(), command: "edit.redo".into(), params: json!({}), shortcut: None, alt_shortcuts: vec![] }; assert_eq!(entry(&app, &undo, "Can't Undo".into()), "Немає дій для скасування"); assert_eq!(entry(&app, &redo, "Can't Redo".into()), "Немає дій для повторення"); app.session.history.undo.push(("File Україна".into(), app.session.project.clone())); @@ -6995,13 +7006,17 @@ mod tests { // Untranslated keys and dynamic labels stay verbatim. assert_eq!(label(&app, "unknown.command", "Text"), "Text"); assert_eq!( - entry(&app, &MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None }, "Undo Comp1".into()), + entry( + &app, + &MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None, alt_shortcuts: vec![] }, + "Undo Comp1".into() + ), "No se puede deshacer" ); assert_eq!( entry( &app, - &MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None }, + &MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None, alt_shortcuts: vec![] }, "Layer: File".into() ), "Capa: File" @@ -7036,13 +7051,17 @@ mod tests { // Untranslated keys and dynamic labels stay verbatim. assert_eq!(label(&app, "unknown.command", "Text"), "Text"); assert_eq!( - entry(&app, &MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None }, "Undo Comp1".into()), + entry( + &app, + &MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None, alt_shortcuts: vec![] }, + "Undo Comp1".into() + ), "Não há nada para desfazer" ); assert_eq!( entry( &app, - &MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None }, + &MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None, alt_shortcuts: vec![] }, "Layer: File".into() ), "Camada: File" From dfff9ba2dc030287cbf303d565bb2bb2ddbd6cc8 Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:49:55 +0200 Subject: [PATCH 053/181] Alt+B / Alt+Shift+B toggle layer sampling layer.sampling takes `toggle`: layers that all have the sampling already get the other one. The Bilinear and Bicubic menu entries, which carry the keys, pass it. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/layer_menu.rs | 9 ++++++++- crates/engine/src/menus.rs | 4 ++-- crates/engine/src/tests_menu_cmds.rs | 24 ++++++++++++++++++++++++ crates/engine/src/tests_settings.rs | 4 ++-- 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/commands/layer_menu.rs b/crates/engine/src/commands/layer_menu.rs index 755dd8986..cac330887 100644 --- a/crates/engine/src/commands/layer_menu.rs +++ b/crates/engine/src/commands/layer_menu.rs @@ -76,6 +76,13 @@ fn sampling(s: &mut Session, p: &Value) -> Result { "bicubic" => Sampling::Bicubic, q => return Err(bad("layer.sampling", format!("sampling must be bilinear|bicubic, not `{q}`"))), }; + // `toggle`: layers that all have it already get the other one. + let all = s.project.comp(cid).is_some_and(|c| c.layers.iter().filter(|l| ids.contains(&l.id)).all(|l| l.switches.sampling == q)); + let q = match q { + Sampling::Bilinear if all && b_p(p, "toggle").unwrap_or(false) => Sampling::Bicubic, + Sampling::Bicubic if all && b_p(p, "toggle").unwrap_or(false) => Sampling::Bilinear, + q => q, + }; s.edit("Layer Sampling", None, |proj, _| { for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) { l.switches.sampling = q; @@ -800,7 +807,7 @@ fn sequence(s: &mut Session, p: &Value) -> Result { pub fn specs() -> Vec { vec![ cmd!("layer.quality", "Quality", [], None, "{layers?, quality: best|draft|wireframe}", has_layers, quality), - cmd!("layer.sampling", "Sampling", [], None, "{layers?, sampling: bilinear|bicubic}", has_layers, sampling), + cmd!("layer.sampling", "Sampling", [], None, "{layers?, sampling: bilinear|bicubic, toggle?}", has_layers, sampling), cmd!("layer.frameBlending", "Frame Blending", [], None, "{layers?, mode: off|frameMix|pixelMotion}", has_layers, frame_blending), cmd!("layer.hideOtherVideo", "Hide Other Video", ["Layer", "Switches"], Some("Cmd+Alt+Shift+V"), "{layers?}", has_layers, hide_other_video), cmd!("layer.showAllVideo", "Show All Video", ["Layer", "Switches"], None, "{comp?}", has_comp, show_all_video), diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 1fb215a32..471065403 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -785,8 +785,8 @@ Layer Draft | layer.quality {"quality":"draft"} | Cmd+Shift+U Wireframe | layer.quality {"quality":"wireframe"} | Cmd+Alt+Shift+U --- - Bilinear | layer.sampling {"sampling":"bilinear"} | Alt+B - Bicubic | layer.sampling {"sampling":"bicubic"} | Alt+Shift+B + Bilinear | layer.sampling {"sampling":"bilinear","toggle":true} | Alt+B + Bicubic | layer.sampling {"sampling":"bicubic","toggle":true} | Alt+Shift+B Switches Hide Other Video | layer.hideOtherVideo Show All Video | layer.showAllVideo diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index cb83c1639..098bd8568 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -52,6 +52,30 @@ fn layer_quality_sampling_frame_blending_and_undo() { assert!(s.execute("layer.quality", json!({"quality": "fancy"})).is_err()); } +/// `layer.sampling {toggle}` (Alt+B, Alt+Shift+B): layers that all have the sampling already get +/// the other one; without `toggle` it is only set. +#[test] +fn layer_sampling_toggles_when_every_layer_has_it() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + let b = solid(&mut s, "#00ff00"); + let sampling = |s: &Session| [layer(s, a).switches.sampling, layer(s, b).switches.sampling]; + let bicubic = json!({"layers": [a, b], "sampling": "bicubic", "toggle": true}); + s.execute("layer.sampling", bicubic.clone()).unwrap(); + assert_eq!(sampling(&s), [Sampling::Bicubic; 2]); + s.execute("layer.sampling", bicubic.clone()).unwrap(); + assert_eq!(sampling(&s), [Sampling::Bilinear; 2]); + s.execute("layer.sampling", json!({"layers": [a, b], "sampling": "bilinear", "toggle": true})).unwrap(); + assert_eq!(sampling(&s), [Sampling::Bicubic; 2]); + // Mixed layers all get the one asked for. + s.execute("layer.sampling", json!({"layers": [a], "sampling": "bilinear"})).unwrap(); + s.execute("layer.sampling", bicubic).unwrap(); + assert_eq!(sampling(&s), [Sampling::Bicubic; 2]); + // Without `toggle` it stays. + s.execute("layer.sampling", json!({"layers": [a, b], "sampling": "bicubic"})).unwrap(); + assert_eq!(sampling(&s), [Sampling::Bicubic; 2]); +} + /// `layer.setSwitch {others: true}` (Alt-click on an A/V feature): the layer stands alone, the /// other layers come back the next time. #[test] diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs index fea7709ef..dd68664d4 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests_settings.rs @@ -418,8 +418,8 @@ fn default_keys_for_commands_that_had_none() { for (id, params, key) in [ ("edit.pasteReversedKeyframes", json!(null), "Cmd+Shift+V"), ("edit.purge", json!({"what": "snapshot"}), "Cmd+Shift+F5"), - ("layer.sampling", json!({"sampling": "bilinear"}), "Alt+B"), - ("layer.sampling", json!({"sampling": "bicubic"}), "Alt+Shift+B"), + ("layer.sampling", json!({"sampling": "bilinear", "toggle": true}), "Alt+B"), + ("layer.sampling", json!({"sampling": "bicubic", "toggle": true}), "Alt+Shift+B"), ("view.res.custom", json!(null), "Cmd+Alt+J"), ("file.setProxy", json!(null), "Cmd+Alt+P"), ("file.openRecent", json!(null), "Cmd+Alt+Shift+P"), From 498dbc0c715ceca99ea717d7a9e52ea72a7da0f0 Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:49:55 +0200 Subject: [PATCH 054/181] Show Channel: grayscale and colorized of one channel replace each other Alt+1 after Alt+Shift+1 shows the red channel in grayscale instead of going back to RGB (and the other way round). The same key again is still RGB. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/viewer_cmds.rs | 6 ++++-- crates/engine/src/tests_settings.rs | 8 ++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/viewer_cmds.rs b/crates/engine/src/commands/viewer_cmds.rs index aac24267b..0c3ed69d2 100644 --- a/crates/engine/src/commands/viewer_cmds.rs +++ b/crates/engine/src/commands/viewer_cmds.rs @@ -127,8 +127,10 @@ fn snapping_options(s: &mut Session, p: &Value) -> Result { fn channel(s: &mut Session, p: &Value) -> Result { let name = str_p(p, "channel").unwrap_or("rgb"); let c = Channel::from_name(name).ok_or_else(|| bad("view.channel", format!("unknown channel `{name}` (rgb|red|green|blue|alpha|rgbStraight)")))?; - // Choosing the active single channel again returns to RGB, as in After Effects. - let c = if c == s.state.viewer.channel && c != Channel::Rgb && b_p(p, "toggle").unwrap_or(false) { Channel::Rgb } else { c }; + // Choosing the active single channel again returns to RGB; not when that changes it between + // grayscale and colorized. + let same = c == s.state.viewer.channel && b_p(p, "colorized").is_none_or(|v| v == s.state.viewer.colorized); + let c = if same && c != Channel::Rgb && b_p(p, "toggle").unwrap_or(false) { Channel::Rgb } else { c }; s.state.viewer.channel = c; if let Some(v) = b_p(p, "colorized") { s.state.viewer.colorized = v; diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs index dd68664d4..458c886fc 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests_settings.rs @@ -408,6 +408,14 @@ fn fast_previews_and_show_channel_modes_have_default_keys() { assert_eq!((s.state.viewer.channel.id(), s.state.viewer.colorized), ("green", false)); s.execute("view.channel.green", json!({})).unwrap(); assert_eq!(s.state.viewer.channel.id(), "rgb"); + // Grayscale and colorized of one channel replace each other; the same again is RGB. + s.execute("view.channel.redColorized", json!({})).unwrap(); + s.execute("view.channel.red", json!({})).unwrap(); + assert_eq!((s.state.viewer.channel.id(), s.state.viewer.colorized), ("red", false)); + s.execute("view.channel.redColorized", json!({})).unwrap(); + assert_eq!((s.state.viewer.channel.id(), s.state.viewer.colorized), ("red", true)); + s.execute("view.channel.redColorized", json!({})).unwrap(); + assert_eq!(s.state.viewer.channel.id(), "rgb"); } /// Default keys for commands and menu entries that had none. From 0eda17470685e1158fad626f89db7c482ec704cd Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:49:55 +0200 Subject: [PATCH 055/181] Shift+; again zooms the Timeline back in Zoom to Fit Comp remembers the span it left and goes back to it when pressed again from the whole comp. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels/timeline.rs | 15 +++++++++++++++ crates/ui-egui/src/state.rs | 4 ++++ crates/ui-egui/tests/ui_timeline_zoom.rs | 23 +++++++++++++++++++++++ 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a60e524a8..51921e62b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -592,7 +592,7 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V crate::panels::timeline::zoom(app, ctx, k); return Ok(Value::Null); } - "timeline.zoomFit" => app.ui.timeline.pps = None, + "timeline.zoomFit" => crate::panels::timeline::toggle_fit_zoom(app, ctx), "timeline.zoomFrameToggle" => crate::panels::timeline::toggle_frame_zoom(app, ctx), "timeline.column" => { let col = params.get("column").and_then(Value::as_str).ok_or("timeline.column: need `column`")?; diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 72f104290..69f7eabde 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -545,6 +545,21 @@ pub fn toggle_frame_zoom(app: &mut EffectcraftApp, ctx: &egui::Context) { set_zoom(&mut app.ui.timeline, &comp, MAX_PPS, fit, cti, w as f64 / 2.0); } +/// `Shift+;`: show the whole comp; from there, go back to the span it was pressed in. +pub fn toggle_fit_zoom(app: &mut EffectcraftApp, ctx: &egui::Context) { + let tl = &mut app.ui.timeline; + if let Some(pps) = tl.pps { + tl.before_fit = Some((tl.start, pps)); + tl.pps = None; + tl.start = 0.0; + return; + } + let Some((start, pps)) = tl.before_fit.take() else { return }; + let Some((_, w)) = ctx.data(|d| d.get_temp::<(f32, f32)>(egui::Id::new("timeline-graph-area"))) else { return }; + let Some(comp) = app.session.active_comp_arc() else { return }; + set_zoom(&mut app.ui.timeline, &comp, pps, fit_pps(w, &comp), start, 0.0); +} + /// Zoom the time ruler around the CTI. pub fn zoom(app: &mut EffectcraftApp, ctx: &egui::Context, k: f64) { let Some((_, w)) = ctx.data(|d| d.get_temp::<(f32, f32)>(egui::Id::new("timeline-graph-area"))) else { return }; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index d0a232797..56b5e2dbc 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -322,6 +322,9 @@ pub struct TimelineState { /// Visible span start (seconds) and pixels per second; None = fit comp. pub start: f64, pub pps: Option, + /// The span (`start`, `pps`) Zoom to Fit Comp left, which it goes back to when pressed again. + #[serde(skip)] + pub before_fit: Option<(f64, f64)>, pub scroll_y: f32, /// Left column area width. pub columns_w: f32, @@ -403,6 +406,7 @@ impl Default for TimelineState { TimelineState { start: 0.0, pps: None, + before_fit: None, scroll_y: 0.0, columns_w: 560.0, outline_scroll: 0.0, diff --git a/crates/ui-egui/tests/ui_timeline_zoom.rs b/crates/ui-egui/tests/ui_timeline_zoom.rs index 2c8026001..a68ee0e0b 100644 --- a/crates/ui-egui/tests/ui_timeline_zoom.rs +++ b/crates/ui-egui/tests/ui_timeline_zoom.rs @@ -163,6 +163,29 @@ fn semicolon_toggles_frame_level_and_the_whole_comp() { assert_eq!(h.state().ui.timeline.pps, None); } +/// `Shift+;` zooms out to the whole comp, and again back in to the span it left. +#[test] +fn shift_semicolon_toggles_the_whole_comp_and_the_span_before() { + let (mut h, _) = harness(); + h.state_mut().session.execute("time.set", json!({"time": 120.0})).unwrap(); + h.run_steps(3); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.zoomFrameToggle", json!({})).unwrap(); + h.run_steps(2); + let before = (h.state().ui.timeline.start, h.state().ui.timeline.pps); + assert!(before.1.is_some()); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.zoomFit", json!({})).unwrap(); + assert_eq!(h.state().ui.timeline.pps, None); + h.run_steps(2); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.zoomFit", json!({})).unwrap(); + assert_eq!((h.state().ui.timeline.start, h.state().ui.timeline.pps), before); + // With nothing to go back to, the whole comp stays. + h.state_mut().ui.timeline.pps = None; + h.state_mut().ui.timeline.before_fit = None; + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.zoomFit", json!({})).unwrap(); + assert_eq!(h.state().ui.timeline.pps, None); +} + fn space(h: &mut Harness<'_, EffectcraftApp>, pressed: bool) { h.event(Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); h.step(); From 7304f4c67217316a91cc83cf4b431ff144479b5f Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:54:50 +0200 Subject: [PATCH 056/181] =?UTF-8?q?Project=20panel:=20Composition=20Settin?= =?UTF-8?q?gs=E2=80=A6=20in=20a=20comp's=20right-click=20menu=20(fixes=20#?= =?UTF-8?q?1058)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First in the menu, as in After Effects, and for the right-clicked comp rather than the one in the viewer: the dialog keeps the comp it edits and OK passes it to comp.settings. comp.settings with only {"comp": id} opens the dialog on that comp. --- crates/ui-egui/src/menus.rs | 10 +++++ crates/ui-egui/src/panels/dialogs.rs | 14 ++++++- crates/ui-egui/src/panels/project.rs | 5 +++ crates/ui-egui/tests/ui_project_rows.rs | 52 +++++++++++++++++++++++++ docs/ui-parity.md | 2 +- 5 files changed, 81 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b10b0e31c..1379e654a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -271,6 +271,13 @@ fn no_params(p: &Value) -> bool { p.as_object().is_none_or(|m| m.is_empty()) } +/// `{"comp": id}` and nothing else: Composition Settings opens on that comp instead of +/// applying no settings to it. +fn only_comp(p: &Value) -> Option { + let m = p.as_object().filter(|m| m.len() == 1)?; + m.get("comp")?.as_u64().map(effectcraft_engine::project::ItemId) +} + /// Execute a UI or engine command by id. pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result { // Layer ▸ New ▸ Text (menu, shortcut, context menus) puts an insertion point in the centre of @@ -647,6 +654,9 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V "app.home" => app.ui.start_screen = !app.ui.start_screen, "app.newComp" | "comp.new" if no_params(¶ms) => crate::panels::dialogs::open_new_comp(app), "app.compSettings" | "comp.settings" if no_params(¶ms) => crate::panels::dialogs::open_comp_settings(app)?, + "app.compSettings" | "comp.settings" if only_comp(¶ms).is_some() => { + crate::panels::dialogs::open_comp_settings_for(app, only_comp(¶ms).ok_or("missing `comp`")?)? + } "app.solidSettings" | "layer.newSolid" if no_params(¶ms) => crate::panels::dialogs::open_new_solid(app)?, "keys.velocity" if no_params(¶ms) => crate::panels::key_dialogs::open_velocity(app)?, "keys.interpolation" if no_params(¶ms) => crate::panels::key_dialogs::open_interpolation(app)?, diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index e95a667ec..ba82a2a53 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -20,6 +20,9 @@ pub struct DialogState { /// from them, as in After Effects). pub last_new_comp: Option, pub editing_existing: bool, + /// The composition Composition Settings edits (the Project panel's row menu can pick one + /// the viewer doesn't show). + pub editing_comp: Option, pub solid_name: String, pub solid_color: [f32; 3], pub solid_size: [u32; 2], @@ -75,6 +78,11 @@ pub fn open_new_comp(app: &mut EffectcraftApp) { pub fn open_comp_settings(app: &mut EffectcraftApp) -> Result<(), String> { let cid = app.session.active_comp_id().ok_or("no composition is open")?; + open_comp_settings_for(app, cid) +} + +/// Composition Settings on `cid`, open in the viewer or not. +pub fn open_comp_settings_for(app: &mut EffectcraftApp, cid: ItemId) -> Result<(), String> { let c = app.session.project.comp(cid).ok_or("no composition")?; app.dialog_state.comp = CompDraft { name: app.session.project.item(cid).map(|i| i.name.clone()).unwrap_or_default(), @@ -95,6 +103,7 @@ pub fn open_comp_settings(app: &mut EffectcraftApp) -> Result<(), String> { ..Default::default() }; app.dialog_state.editing_existing = true; + app.dialog_state.editing_comp = Some(cid); app.dialog = Some(Dialog::CompSettings); Ok(()) } @@ -362,7 +371,10 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis app.dialog_state.comp = d; match result { Some(serde_json::Value::Null) => app.dialog = None, - Some(params) => { + Some(mut params) => { + if existing && let (Some(cid), Some(m)) = (app.dialog_state.editing_comp, params.as_object_mut()) { + m.insert("comp".into(), json!(cid.0)); + } let r = if existing { app.session.execute("comp.settings", params) } else { app.session.execute("comp.new", params) }; if !existing && r.is_ok() { app.dialog_state.last_new_comp = Some(app.dialog_state.comp.clone()); diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index e18f76bc9..a608c175f 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -782,6 +782,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let footage = matches!(it.kind, ItemKind::Footage(_)); crate::widgets::context_menu(&resp, |ui| { + // First on a comp, as in After Effects; for this comp, not the viewer's (#1058). + if matches!(it.kind, ItemKind::Comp(_)) && ui.button(crate::i18n::tr("Composition Settings…")).clicked() { + actions.push(("comp.settings".into(), json!({"comp": id.0}))); + ui.close(); + } if ui.button(crate::i18n::tr("Delete")).clicked() { actions.push(("project.delete".into(), json!({"items": targets(app, *id, selected)}))); ui.close(); diff --git a/crates/ui-egui/tests/ui_project_rows.rs b/crates/ui-egui/tests/ui_project_rows.rs index 7d0ebf667..e43a9ce9b 100644 --- a/crates/ui-egui/tests/ui_project_rows.rs +++ b/crates/ui-egui/tests/ui_project_rows.rs @@ -214,3 +214,55 @@ fn a_short_project_panel_draws() { assert!(h.state().auto.find("project.empty").is_some(), "the Project panel drew at {height} points"); } } + +/// #1058: a comp's row menu offers Composition Settings…, as After Effects' does, for the comp +/// right-clicked even when the viewer shows another one; OK applies to that comp. Footage has none. +#[test] +fn comp_context_menu_opens_that_comps_settings() { + let (mut h, _, main, _) = harness(); + let s = &mut h.state_mut().session; + let side = ItemId(s.execute("comp.new", json!({"name": "Side", "width": 640, "height": 360, "duration": 2})).unwrap()["comp"].as_u64().unwrap()); + s.execute("comp.open", json!({"comp": main.0})).unwrap(); + let footage = Footage { kind: FootageKind::Video, path: "clip.mp4".into(), width: 64, height: 36, has_video: true, ..Default::default() }; + let clip = std::sync::Arc::make_mut(&mut s.project).add_item("Clip", Default::default(), None, ItemKind::Footage(footage)); + h.run_steps(2); + assert_eq!(h.state().session.active_comp_id(), Some(main)); + let right_click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| { + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(2); + }; + let at = center(&h, &format!("project.item.{}.name", clip.0)); + right_click(&mut h, at); + assert!(h.query_by_label("Rename").is_some(), "the footage menu is open"); + assert!(h.query_by_label_contains("Composition Settings").is_none(), "footage isn't a comp"); + h.input_mut().events.push(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + let at = center(&h, &format!("project.item.{}.name", side.0)); + right_click(&mut h, at); + let entry = h.query_by_label_contains("Composition Settings").expect("in the comp's menu").rect(); + click_at(&mut h, entry.center()); + assert_eq!(h.state().dialog, Some(Dialog::CompSettings)); + assert_eq!(h.state().session.active_comp_id(), Some(main), "the viewer keeps its comp"); + // A new name, then OK: Side's own size goes back to Side under it. Main's values in the + // dialog, or OK writing to the viewer's comp, would resize or rename the wrong one. + click(&mut h, "dialog.comp.name"); + h.input_mut().events.push(Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }); + h.input_mut().events.push(Event::Text("Side 2".into())); + h.run_steps(2); + let undo = h.state().session.history.undo.len(); + h.input_mut().events.push(Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.state().dialog, None, "OK closed the dialog"); + assert_eq!(h.state().session.history.undo.len(), undo + 1, "OK applied the settings"); + let size = |h: &Harness<'_, EffectcraftApp>, c: ItemId| { + let s = &h.state().session; + (s.project.item(c).unwrap().name.clone(), s.project.comp(c).unwrap().width, s.project.comp(c).unwrap().height) + }; + assert_eq!(size(&h, side), ("Side 2".into(), 640, 360), "the dialog showed and set the right-clicked comp"); + assert_eq!(size(&h, main), ("Main".into(), 320, 180), "the open comp is untouched"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 3654f61b7..4e6d04e10 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -23,7 +23,7 @@ feel and platform differences, and the 26.x interface changes are not in yet. | Nudging and modifiers | Arrow nudge (1 px, Shift 10 px), Alt/Option duplicates and slips, Shift constrains | 75% | #290 nudging fixed; Linux multi-layer edits (#491) | | Numeric entry | Scrubbable values, typed entry with Tab / Shift+Tab, angle revolutions, Escape cancels, non-finite rejected | 85% | `ui_angles`, `ui_inline_edit` (#93, #141, #361); rename arrows on Linux (#488) | | Effect Controls | After Effects' twirl-downs, angle dial, point crosshair, eyedropper, curves/levels editors, effect menu on right-click | 70% | `ui_effect_controls`, `ui_fx_editors`; 26.5 rebuilt this panel with icon controls and effect colour labels, not matched | -| Context menus | Layer, property, Project, Timeline empty area, work area, labels with colours | 80% | `ui_blank_context_menus`; #227, #290, #407 fixed | +| Context menus | Layer, property, Project, Timeline empty area, work area, labels with colours | 80% | `ui_blank_context_menus`, `ui_project_rows`; #227, #290, #407 fixed; a comp's Project row menu has Composition Settings… (#1058) | | Keyboard shortcuts | After Effects' defaults; Keyboard Shortcuts editor that rebinds and saves sets | 75% | Shortcut editor in Settings; adding shortcuts fails on Linux (#467); requests for more (#428, #336); no shortcuts for scripts | | Drag and drop | Project → Timeline / viewer / New Comp / Render Queue; effects onto layers; files from the OS | 65% | `ui_drag_drop`; OS drops fail on Wayland (winit) and some Linux setups (#268); multi-file drops (#552, #351); crash dragging over the import dialog (#511) | | Text editing on canvas | Point and paragraph text, per-character styles, Character / Paragraph panels, OpenType and variable axes | 70% | `ui_text_edit`, `ui_font_search`; paragraph alignment inverted (#316); font search by typing (#583); toolbar font options (#550); the font menus' variable-font filter (26.3) landed (#741) | From 925760c9c85aba319461b7499df38e706de00c57 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 19:08:21 +0200 Subject: [PATCH 057/181] Web disk cache: handle the Matte kind (mattes/ folder) Co-Authored-By: Claude Sonnet 5.5 --- apps/effectcraft-web/js/host.js | 3 ++- apps/effectcraft-web/src/diskcache.rs | 12 ++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js index 793900bee..3db6ad35c 100644 --- a/apps/effectcraft-web/js/host.js +++ b/apps/effectcraft-web/js/host.js @@ -404,7 +404,7 @@ export function frameWorkerStart(base, onMessage, gpu) { // ends in a checksum) and read the layer buffers they prefetch; the page lists, reads and // deletes. const CACHE_ROOT = "effectcraft-cache"; -const CACHE_KINDS = ["frames", "layers"]; +const CACHE_KINDS = ["frames", "layers", "mattes"]; const cacheDirHandles = {}; async function cacheDir(kind = "frames") { @@ -419,6 +419,7 @@ async function cacheDir(kind = "frames") { /// The folder and file name of an entry name (`layers/.ecc` or `.ecc`). async function entryDir(name) { if (name.startsWith("layers/")) return [await cacheDir("layers"), name.slice(7)]; + if (name.startsWith("mattes/")) return [await cacheDir("mattes"), name.slice(7)]; return [await cacheDir("frames"), name]; } diff --git a/apps/effectcraft-web/src/diskcache.rs b/apps/effectcraft-web/src/diskcache.rs index d96e6f132..ef9d46f8c 100644 --- a/apps/effectcraft-web/src/diskcache.rs +++ b/apps/effectcraft-web/src/diskcache.rs @@ -105,19 +105,23 @@ pub fn init() { }); } -/// An entry's name in `js/host.js`'s cache folders (`layers/` for layer buffers). +/// An entry's name in `js/host.js`'s cache folders (`layers/` for layer buffers, `mattes/` for segmentations). pub(crate) fn name_of(kind: Kind, key: u128) -> String { match kind { Kind::Frame => disk_cache::entry_name(key), Kind::Layer => format!("layers/{}", disk_cache::entry_name(key)), + Kind::Matte => format!("mattes/{}", disk_cache::entry_name(key)), } } fn parse_name(name: &str) -> Option<(Kind, u128)> { - match name.strip_prefix("layers/") { - Some(n) => Some((Kind::Layer, disk_cache::parse_entry_name(n)?)), - None => Some((Kind::Frame, disk_cache::parse_entry_name(name)?)), + if let Some(n) = name.strip_prefix("layers/") { + return Some((Kind::Layer, disk_cache::parse_entry_name(n)?)); } + if let Some(n) = name.strip_prefix("mattes/") { + return Some((Kind::Matte, disk_cache::parse_entry_name(n)?)); + } + Some((Kind::Frame, disk_cache::parse_entry_name(name)?)) } /// Layer entries (32 hex digits) a frame worker should read before its next frame: recent From 9375cc3406429c929ca247306b81e3441a0108d2 Mon Sep 17 00:00:00 2001 From: zitann2020 Date: Tue, 13 Oct 2026 01:28:39 +0900 Subject: [PATCH 058/181] i18n(ja): finish the Japanese panel, dialog and tooltip catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The menus have been complete in Japanese since the menu catalog landed, but the panel catalog below the menu bar stopped at 410 of the 506 source strings the Chinese catalogs define. Settings, the Home screen, the viewer tools and the Fast Previews and channel popups therefore still drew in English for a Japanese interface. This adds the 96 missing rows. Existing rows are untouched and keep their order; the new ones follow the canonical (Simplified Chinese) order, which the catalog tests require, and no row changes the number of `{}` values its English source carries. Terms follow the Japanese already in the catalog and, wherever a control exists in both, the wording After Effects 2026 (26.5) itself shows in Japanese: 平面 (Solid), 関心領域 (Region of Interest), フル画質 / 1/2 画質 / 1/3 画質 / 1/4 画質 (viewer resolution), ドラフト 3D (Fast Previews), 透明グリッド (Transparency Grid), マスクとシェイプのパスを表示 (Toggle Mask and Shape Path Visibility), チャンネルとカラーマネジメント設定を表示 (Show Channel and Color Management Settings). The crate's `i18n` unit tests pass (10/10) and rustfmt is clean. --- crates/ui-egui/src/i18n/ui.rs | 96 +++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index f31d0951c..0c25b631d 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -100,7 +100,9 @@ const JAPANESE: &[(&str, &str)] = &[ ), ("About EffectCraft", "EffectCraftについて"), ("About...", "情報..."), + ("Active", "使用中"), ("Active Camera", "アクティブカメラ"), + ("Alpha", "アルファ"), ("Adapt Feature On Every Frame", "フレームごとに特徴を更新"), ("Adaptive Sample Limit:", "適応サンプル数の上限:"), ("Add Animator", "アニメーターを追加"), @@ -121,6 +123,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("All six face cameras turn together; the scene is not changed.", "6つの面のカメラがすべて一緒に回転します。シーンは変更されません。"), ("Anchor:", "アンカー:"), ("Aperture", "絞り"), + ("Appearance Mode", "外観モード"), + ("Appearance Mode: {}", "外観モード:{}"), ("Apply Dimensions:", "適用する次元:"), ("Apply Motion To:", "モーションの適用先:"), ("Arrange", "重ね順"), @@ -133,6 +137,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Blending Mode", "描画モード"), ("Blur", "ブラー"), ("Blur Level", "ブラーレベル"), + ("Boundary", "境界"), ("Browser Storage", "ブラウザーストレージ"), ("Browse…", "参照…"), ("Brush Dynamics", "ブラシのダイナミクス"), @@ -186,6 +191,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("Continuous (Lock Outgoing to Incoming)", "連続(出る速度を入る速度に固定)"), ("Cue Point", "キューポイント"), ("Current Render", "現在のレンダリング"), + ("Dark", "ダーク"), + ("Dark Theme", "ダークテーマ"), ("Darker", "暗く"), ("Default Output:", "既定の出力:"), ("Default: {}", "初期設定:{}"), @@ -248,6 +255,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Frame Rate:", "フレームレート:"), ("Frame Render Time {}ms", "フレームのレンダリング時間 {}ms"), ("Frame Target:", "フレームターゲット:"), + ("Freeze", "フリーズ"), ("Grab bag", "いろいろ"), ("Gradient Editor", "グラデーションエディター"), ("Grid", "グリッド"), @@ -283,6 +291,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("Leave all attributes in \"{}\"", "すべての属性を「{}」に残す"), ("Licence: {}", "ライセンス:{}"), ("Ligatures", "合字"), + ("Light", "ライト"), + ("Light Theme", "ライトテーマ"), ("Light Type", "ライトの種類"), ("Lighter", "明るく"), ("Link Selected Property", "選択したプロパティをリンク"), @@ -362,6 +372,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Original Duration:", "元のデュレーション:"), ("Output Cycle: {}", "出力サイクル:{}"), ("Output To:", "出力先:"), + ("Overlay", "オーバーレイ"), ("Parameter Name:", "パラメーター名:"), ("Parent pick whip: drag onto a layer", "親ピックウィップ:レイヤー上にドラッグ"), ("Pixel Aspect Ratio:", "ピクセルアスペクト比:"), @@ -386,6 +397,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Protected Region", "保護領域"), ("Proxy Use: {}", "プロキシの使用:{}"), ("Push All to Comp", "すべてをコンポジションに反映"), + ("RGB", "RGB"), ("Radius", "半径"), ("Rate Control: {}", "レート制御:{}"), ("Recent", "最近使用した項目"), @@ -460,6 +472,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Start Timecode:", "開始タイムコード:"), ("Start: {} Duration: {}", "開始:{} デュレーション:{}"), ("Step {} of {}: {}", "ステップ{}/{}:{}"), + ("Stop", "停止"), ("Stop {} at {}°", "ストップ{}:{}°"), ("Stored in: {}", "保存先:{}"), ("Stretch", "伸縮"), @@ -467,6 +480,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Stylistic Sets", "スタイルセット"), ("Subpixel Positioning", "サブピクセル位置決め"), ("Swap Paint Colors (X)", "ペイントカラーを入れ替え(X)"), + ("Sync with System", "システムに合わせる"), ("System Default", "システムの既定"), ("Table", "表形式"), ("Template name", "テンプレート名"), @@ -500,6 +514,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Undock Panel", "パネルのドッキングを解除"), ("Move Panel to New Window", "新規ウィンドウにパネルを移動"), ("Move Panel to Main Window", "メインウィンドウにパネルを移動"), + ("Unfreeze", "フリーズ解除"), ("Unlink {}", "{}のリンクを解除"), ( "Use this option to place the currently selected layers together into a new intermediate composition.", @@ -525,6 +540,87 @@ const JAPANESE: &[(&str, &str)] = &[ ("⌘/Ctrl-drag a pin to record its motion", "⌘/Ctrl+ドラッグでピンの動きを記録"), ("▣ {}", "▣ {}"), ("⚠ {} is used by:", "⚠ {}は次のコマンドで使用中:"), + ("3D Reference Axes", "3D参照軸"), + ("Affect all layers that use this solid", "この平面を使用するすべてのレイヤーに適用"), + ("Choose grid and guide options", "グリッドとガイドのオプションを選択"), + ("Colorize", "カラー化"), + ("Custom", "カスタム"), + ("Custom...", "カスタム..."), + ("Fit", "フィット"), + ("Fit up to 100%", "100%までフィット"), + ("Full", "フル画質"), + ("Half", "1/2 画質"), + ("Only this layer uses this solid.", "この平面を使用するのはこのレイヤーだけです。"), + ("Proportional Grid", "均等グリッド"), + ("Quarter", "1/4 画質"), + ("Region of Interest", "関心領域"), + ("Reset Exposure", "露出をリセット"), + ("Show Channel and Color Management Settings", "チャンネルとカラーマネジメント設定を表示"), + ("Show Snapshot (hold)", "スナップショットを表示(長押し)"), + ("Take Snapshot", "スナップショットを取得"), + ("Third", "1/3 画質"), + ("Toggle Mask and Shape Path Visibility", "マスクとシェイプのパスを表示"), + ("Toggle Transparency Grid", "透明グリッドを切り替え"), + ("16 bits per channel", "チャンネルあたり16ビット"), + ("32 bits per channel (float)", "チャンネルあたり32ビット(浮動小数点)"), + ("8 bits per channel", "チャンネルあたり8ビット"), + ("Adaptive Resolution", "適応解像度"), + ("Adobe-style built-in", "Adobe標準の組み込み"), + ("Blue", "青"), + ("Current Settings", "現在の設定"), + ("Disable Per-character 3D", "文字ごとの3Dを無効化"), + ("Draft", "ドラフト"), + ("Enable Per-character 3D", "文字ごとの3Dを有効化"), + ("Fast Draft", "高速ドラフト"), + ("Fast Previews (Draft 3D, Faster Effects)", "高速プレビュー(ドラフト 3D、より高速なエフェクト)"), + ("Green", "緑"), + ("Import Image Sequence", "画像シーケンスを読み込み"), + ("Import PDF / Illustrator File", "PDF / Illustratorファイルを読み込み"), + ("Import Photoshop File", "Photoshopファイルを読み込み"), + ("Interpret Unlabeled Alpha As", "ラベルなしアルファの解釈"), + ("Join the ArtCraft Discord", "ArtCraftのDiscordに参加"), + ("Mercury GPU Acceleration ({})", "Mercury GPUアクセラレーション({})"), + ("Mercury Software Only", "Mercuryソフトウェアのみ"), + ("OCIO (built-in ACES config)", "OCIO(組み込みACES設定)"), + ("Off (Final Quality)", "オフ(最終品質)"), + ("OpenGL (compatibility)", "OpenGL(互換)"), + ("Opus Bitrate: {} kbps", "Opusビットレート:{} kbps"), + ("RGB Straight", "RGB(ストレート)"), + ("Red", "赤"), + ("Tone map", "トーンマップ"), + ("Use In-Window Menu Bar on macOS", "macOSでウィンドウ内メニューバーを使用"), + ("Wireframe", "ワイヤーフレーム"), + ("sRGB IEC61966-2.1 (default)", "sRGB IEC61966-2.1(既定)"), + ("New Project", "新規プロジェクト"), + ("Open Project…", "プロジェクトを開く…"), + ("Templates", "テンプレート"), + ("EffectCraft home page", "EffectCraftのホームページ"), + ("EffectCraft on GitHub", "GitHubのEffectCraft"), + ("Motion graphics and visual effects · version {}", "モーショングラフィックスとビジュアルエフェクト · バージョン{}"), + ("New Composition", "新規コンポジション"), + ("Open Demo Project", "デモプロジェクトを開く"), + ("Import Footage…", "フッテージを読み込み…"), + ("Filter recent files", "最近使用したファイルを絞り込み"), + ("NAME", "名前"), + ("OPENED", "開いた日時"), + ("SIZE", "サイズ"), + ("KIND", "種類"), + ("Your recent files will show here", "最近使用したファイルがここに表示されます"), + ("Create a new project, or open the demo project to look around.", "新規プロジェクトを作成するか、デモプロジェクトを開いて見て回れます。"), + ("No recent files match the filter", "フィルターに一致する最近使用したファイルはありません"), + ("Try another name or folder.", "別の名前またはフォルダーを試してください。"), + ("Just now", "たった今"), + ("Yesterday", "昨日"), + ("{} minute ago", "{}分前"), + ("{} minutes ago", "{}分前"), + ("{} hour ago", "{}時間前"), + ("{} hours ago", "{}時間前"), + ("{} day ago", "{}日前"), + ("{} days ago", "{}日前"), + ("EffectCraft project", "EffectCraftプロジェクト"), + ("After Effects project", "After Effectsプロジェクト"), + ("Lottie animation", "Lottieアニメーション"), + ("Project", "プロジェクト"), ]; const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ From 0852511c3fa374715cd141257547dd2e050d3b52 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 12 Oct 2026 19:19:00 +0200 Subject: [PATCH 059/181] #551: Shift+Spacebar plays every frame by default Shift+Spacebar set Skip to 1, so it played every other frame out of the box. It now plays every frame, like Spacebar. Skip still works when it is set. Co-Authored-By: Claude Haiku 5.5 --- crates/engine/src/preview.rs | 12 +++++++++--- crates/ui-egui/tests/ui_preview_align.rs | 3 ++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/preview.rs b/crates/engine/src/preview.rs index 5b9733ddd..76d21a806 100644 --- a/crates/engine/src/preview.rs +++ b/crates/engine/src/preview.rs @@ -205,9 +205,8 @@ impl PreviewPreset { pub fn default_for(s: PreviewShortcut) -> Self { let d = PreviewPreset::default(); match s { - PreviewShortcut::Spacebar => d, - // A lighter preview: every other frame. - PreviewShortcut::ShiftSpacebar => PreviewPreset { skip: 1, ..d }, + // Frame skipping is off by default; set Skip to play fewer frames. + PreviewShortcut::Spacebar | PreviewShortcut::ShiftSpacebar => d, PreviewShortcut::Numpad0 => PreviewPreset { range: PreviewRange::WorkArea, play_from: PlayFrom::RangeStart, ..d }, PreviewShortcut::ShiftNumpad0 => { PreviewPreset { range: PreviewRange::WorkArea, play_from: PlayFrom::RangeStart, skip: 1, resolution: PreviewResolution::Half, ..d } @@ -509,6 +508,13 @@ mod tests { assert_eq!(pl.frame_after(237, 1), None); } + /// #551: Shift+Spacebar used to skip every other frame out of the box. + #[test] + fn shift_spacebar_plays_every_frame_by_default() { + assert_eq!(PreviewPreset::default_for(PreviewShortcut::ShiftSpacebar).skip, 0); + assert_eq!(PreviewPreset::default_for(PreviewShortcut::ShiftSpacebar), PreviewPreset::default_for(PreviewShortcut::Spacebar)); + } + #[test] fn settings_commands_round_trip() { let mut s = comp_session(); diff --git a/crates/ui-egui/tests/ui_preview_align.rs b/crates/ui-egui/tests/ui_preview_align.rs index c2e03535e..e9b8d6c44 100644 --- a/crates/ui-egui/tests/ui_preview_align.rs +++ b/crates/ui-egui/tests/ui_preview_align.rs @@ -118,7 +118,8 @@ fn playback_follows_the_shortcut_options() { h.step(); assert!(h.state().playback.playing); assert_eq!(h.state().playback.shortcut, PreviewShortcut::ShiftSpacebar); - assert_eq!(h.state().playback.plan.unwrap().step, 2); + // Every frame by default (#551): Skip is 0 until the user sets it. + assert_eq!(h.state().playback.plan.unwrap().step, 1); } #[test] From 4d105f02d4700bdbaf824d048adc70db9469068d Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 12 Oct 2026 19:19:11 +0200 Subject: [PATCH 060/181] #809: layer.source and a Layer Control's layer work in expressions layer.source was undefined in expressions, and a layer from a Layer Control had no transform. Both now work. Co-Authored-By: Claude Haiku 5.5 --- crates/expr/src/host.rs | 26 ++++++++++++++++++++++++++ crates/expr/src/prelude.js | 20 ++++++++++++++++++-- crates/expr/src/runtime.rs | 1 + crates/expr/src/tests.rs | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/crates/expr/src/host.rs b/crates/expr/src/host.rs index 700937903..87d2d8be8 100644 --- a/crates/expr/src/host.rs +++ b/crates/expr/src/host.rs @@ -63,6 +63,10 @@ pub enum Req { Sample { comp: u64, layer: u64, x: Secs, y: Secs, rx: Secs, ry: Secs, post: bool, t: Secs }, /// `thisProject`: `[bitsPerChannel, linearBlending, workingSpace]`. Project, + /// A layer's source item (AE's `Layer.source`): `[kind, id, name, width, height, duration, + /// frameDuration, pixelAspect]`, kind `"comp"` (id = the precomp's composition) or `"footage"` + /// (footage, solids and models), or null for layers without an item (text, shape, null…). + SourceInfo { comp: u64, layer: u64 }, } /// Parse CSV/TSV text (RFC 4180 quoting) into rows of cells. @@ -457,6 +461,28 @@ impl<'a> Resolver<'a> { Resp::Bool(project.settings.blend_linear || project.settings.linearize), Resp::Str(project.settings.working_space.map(|w| format!("{w:?}")).unwrap_or_default()), ]), + Req::SourceInfo { comp, layer } => { + let c = self.comp(*comp)?; + let l = self.layer(*comp, *layer)?; + let it = project.item(l.source.item()?)?; + let (kind, aspect, frame, duration) = match &it.kind { + ItemKind::Comp(nc) => ("comp", nc.pixel_aspect, nc.frame_duration().seconds(), nc.duration.seconds()), + ItemKind::Footage(f) => ("footage", f.pixel_aspect, f.frame_rate.frame_duration().seconds(), f.duration.seconds()), + ItemKind::Solid(s) => ("footage", s.pixel_aspect, c.frame_duration().seconds(), c.duration.seconds()), + ItemKind::Folder => return None, + }; + let (w, h) = it.dimensions().unwrap_or((0, 0)); + Resp::List(vec![ + Resp::Str(kind.into()), + Resp::Num(it.id.0 as f64), + Resp::Str(it.name.clone()), + Resp::Num(w as f64), + Resp::Num(h as f64), + Resp::Num(duration), + Resp::Num(frame), + Resp::Num(aspect), + ]) + } Req::Sample { comp, layer, x, y, rx, ry, post, t } => { let l = self.layer(*comp, *layer)?; let f = |b: &Secs| f64::from_bits(*b); diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 5756365af..5f6dd0159 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -18,7 +18,7 @@ var selectorValue = [100, 100, 100]; // Request ops (must match runtime.rs). var __COMP = 0, __COMPNAME = 1, __LAYER = 2, __LINFO = 3, __CHILD = 4, __PINFO = 5, __VALUE = 6, __XFORM = 7, __RECT = 8, __MARKERS = 9, __DOC = 10; -var __FOOT = 11, __FDATA = 12, __SAMPLE = 13, __PROJ = 14; +var __FOOT = 11, __FDATA = 12, __SAMPLE = 13, __PROJ = 14, __SOURCE = 15; function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) { time = t; @@ -569,6 +569,14 @@ Layer.prototype = { get enabled() { return this.__info[11]; }, get active() { return this.__info[11] && time >= this.inPoint && time < this.outPoint; }, get containingComp() { return new Comp(this.__c); }, + // The source item (AE's Layer.source): a Comp for a precomp layer, a Footage for footage, solid + // and model layers, null for text, shape, null, camera and light layers (#809). + get source() { + var s = __h(__SOURCE, this.__c, this.__l); + if (s === null) return null; + if (s[0] === 'comp') return new Comp(s[1]); + return new Footage([s[2], s[3], s[4], s[5], s[6], s[7], s[0], null, null]); + }, __group: function (k) { var g = __child(this.__c, this.__l, '', k); if (g === undefined) __err('Layer "' + this.name + '" has no property "' + k + '"'); @@ -810,10 +818,18 @@ Prop.prototype = { var __propHandler = { get: function (t, k) { - if (typeof k !== 'string' || k in t) return t[k]; + if (typeof k !== 'string') return t[k]; + // A layer-valued property (a Layer Control's Layer) is named as its layer (#809). + if (k === 'name' && t.__info[2] === 'layer') { + var lv = t.value; + if (lv !== null && lv !== undefined && lv.__isLayer) return lv.name; + } + if (k in t) return t[k]; var v = t.value; if (v === null || v === undefined) return undefined; var x = v[k]; + // Property groups are callable proxies: hand them over as they are, not bound like methods (#809). + if (x !== null && x !== undefined && x.__isGroup) return x; return typeof x === 'function' ? x.bind(v) : x; }, }; diff --git a/crates/expr/src/runtime.rs b/crates/expr/src/runtime.rs index 142658b16..9ec8172aa 100644 --- a/crates/expr/src/runtime.rs +++ b/crates/expr/src/runtime.rs @@ -185,6 +185,7 @@ fn native_host(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult Req::Project, + 15 => Req::SourceInfo { comp: id(args, 1, ctx)?, layer: id(args, 2, ctx)? }, _ => return Err(JsNativeError::typ().with_message("unknown host request").into()), }; let found = FRAMES.with(|f| { diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 93a255628..d71db3900 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -1251,3 +1251,37 @@ fn pending_text_style_baseline_setters_are_visible_to_expression_getters() { assert_eq!(x.num("thisComp.layer('Title').text.sourceText.style.setBaselineOption('superscript').isSuperscript ? 1 : 0", 0.0), 1.0); assert_eq!(x.text("var s = text.sourceText.style.setSuperscript(true); s.setText(s.isSuperscript ? 'super' : 'normal')", 0.0), "super"); } + +#[test] +fn layer_source_is_the_layers_source_item() { + let mut f = fx(); + // A solid's source is an item of the solid's size and name (#809). + assert_close!(f.num("thisComp.layer('A').source.width", 0.0), 20.0); + assert_close!(f.num("thisComp.layer('A').source.height", 0.0), 20.0); + assert_close!(f.num("thisComp.layer('A').source.name == 'A' ? 1 : 0", 0.0), 1.0); + // Text and shape layers have no source item. + assert_close!(f.num("thisComp.layer('Title').source == null ? 1 : 0", 0.0), 1.0); + // A precomp layer's source is its composition. + let main = f.p.comp(f.cid).unwrap().clone(); + let other = f.p.items.values().find(|i| i.name == "Other").unwrap().id; + let pre = build::layer(&mut f.p, &main, "Pre", LayerSource::Comp { item: other }, (64, 32), None); + f.p.comp_mut(f.cid).unwrap().layers.push(pre); + assert_close!(f.num("thisComp.layer('Pre').source.width", 0.0), 64.0); + assert_close!(f.num("thisComp.layer('Pre').source.numLayers", 0.0), 1.0); +} + +#[test] +fn layer_control_property_stands_for_its_layer() { + let mut f = fx(); + // Layer A gets a Layer Control ("Map") that points at layer B (#809). + let spec = effectcraft_effects::find("ec.control.layer").unwrap(); + let mut map = group_push(&mut f.p, |ids| effectcraft_effects::instantiate(spec, ids, "Map", [20.0, 20.0])); + map.prop_mut("layer").unwrap().value = Value::Layer(Some(f.b.0)); + f.p.comp_mut(f.cid).unwrap().layers[0].props.sub_mut("effects").unwrap().children.push(map.into()); + // effect("Map")(1) is the layer itself: its name, index, transform and value. + assert_close!(f.num("effect('Map')(1).name == 'B' ? 1 : 0", 0.0), 1.0); + assert_close!(f.num("effect('Map')(1).index", 0.0), 2.0); + assert_close!(f.num("effect('Map')(1).transform.position[0]", 0.0), 100.0); + assert_close!(f.num("effect('Map')(1).transform.scale[0]", 0.0), 100.0); + assert_close!(f.num("effect('Map')(1).value.name == 'B' ? 1 : 0", 0.0), 1.0); +} From 2ef3d977a66d596b93cc2d7e039916fd4924ee61 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 12 Oct 2026 19:19:13 +0200 Subject: [PATCH 061/181] #808: sourcePointToComp and compPointToSource follow the layer's transform Both returned their input unchanged. They now go through the layer's transform and its parents' at comp time, and the inverse. Co-Authored-By: Claude Haiku 5.5 --- crates/script/src/model.rs | 24 ++++++++++++++++++++++++ crates/script/src/prelude.js | 8 ++++++-- crates/script/src/tests.rs | 22 ++++++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index 4445fbbf2..d3c084aff 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -44,6 +44,7 @@ pub fn query(s: &mut Session, kind: &str, a: &J) -> R { "textdoc" => textdoc(s, a), "markers" => markers(s, a), "sourceRect" => source_rect(s, a), + "pointConvert" => point_convert(s, a), "resolveAdd" => resolve_add(s, a), "menuCommandId" => Ok(menu_command_id(a.get("name").and_then(J::as_str).unwrap_or(""))), "commandAt" => { @@ -577,6 +578,29 @@ fn markers(s: &Session, a: &J) -> R { Ok(J::Array(out)) } +/// A layer's source point in comp space (`toComp`) or the reverse, through the layer's transform +/// and its parents' at comp time (#808: `sourcePointToComp`, `compPointToSource`). +fn point_convert(s: &Session, a: &J) -> R { + let (cid, c, l) = layer_of(s, a)?; + let t = f(a, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid)); + let p = a.get("point").and_then(J::as_array).ok_or("missing point")?; + let x = p.first().and_then(J::as_f64).unwrap_or(0.0); + let y = p.get(1).and_then(J::as_f64).unwrap_or(0.0); + let mut ctx = EvalCtx::new(&s.project, cid, c, t); + ctx.expr = s.expr.as_deref(); + let (layer_space, _) = ctx.layer_to_comp(l); + let m = if a.get("toComp").and_then(J::as_bool).unwrap_or(true) { + layer_space + } else { + layer_space.inverse().ok_or("the layer's transform can't be inverted")? + }; + let k = m.0; + let w = k[2][0] * x + k[2][1] * y + k[2][2]; + let (ox, oy) = (k[0][0] * x + k[0][1] * y + k[0][2], k[1][0] * x + k[1][1] * y + k[1][2]); + let (ox, oy) = if w != 0.0 && w.is_finite() { (ox / w, oy / w) } else { (ox, oy) }; + Ok(json!([ox, oy])) +} + fn source_rect(s: &Session, a: &J) -> R { let (cid, c, l) = layer_of(s, a)?; let t = f(a, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid)); diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index 9daac6ab7..1956ce32f 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -1285,8 +1285,12 @@ AVLayer.prototype.constructor = AVLayer; P.sourceRectAtTime = function (t, extents) { return __get("sourceRect", { comp: this.__comp, layer: this.__id, time: __num(t, "time"), extents: !!extents }); }; - P.sourcePointToComp = function (p) { return p; }; - P.compPointToSource = function (p) { return p; }; + // Source ↔ comp points, through the layer's transform and its parents' at the comp's time (#808). + function pointIn(layer, p, toComp) { + return __get("pointConvert", { comp: layer.__comp, layer: layer.__id, point: [__num(p[0], "point"), __num(p[1], "point")], toComp: toComp, time: layer.containingComp.time }); + } + P.sourcePointToComp = function (p) { return pointIn(this, p, true); }; + P.compPointToSource = function (p) { return pointIn(this, p, false); }; P.replaceSource = function () { throw __err("replaceSource is not supported yet"); }; P.openInViewer = function () { __call("layer.openLayer", this.__p1()); return null; }; })(); diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 303bd820a..986dc1361 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -1010,3 +1010,25 @@ fn scripted_text_range_selector_resolves_and_is_the_only_selector() { ); assert_eq!(o.result, json!([1, "ADBE Text Selector", 50, 100, "ADBE Text Percent Offset"])); } + +/// #808: sourcePointToComp and compPointToSource follow the layer's transform. +#[test] +fn source_point_conversions_follow_the_layer_transform() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var comp = app.project.items.addComp("Repro", 1920, 1080, 1, 10, 25); + var layer = comp.layers.addSolid([1, 0, 0], "S", 200, 100, 1); + layer.transform.position.setValue([400, 300]); + layer.transform.scale.setValue([50, 50]); + var a = layer.sourcePointToComp([0, 0]); + var b = layer.compPointToSource([400, 300]); + [a[0], a[1], b[0], b[1]] + "#, + ); + let v = o.result; + let near = |x: &Value, want: f64| x.as_f64().is_some_and(|g| (g - want).abs() < 1e-6); + assert!(near(&v[0], 350.0) && near(&v[1], 275.0), "{v}"); + assert!(near(&v[2], 100.0) && near(&v[3], 50.0), "{v}"); +} From 42c7a7141ea0a07ccb5a03089618a21be87f0de0 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 19:40:37 +0200 Subject: [PATCH 062/181] #1061: Enter renames the selected effect, text animator, mask or shape group Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/commands/prop_groups.rs | 4 +- crates/engine/src/tests_project_items.rs | 23 ++++ crates/project/src/props.rs | 6 ++ crates/ui-egui/src/panels/effect_controls.rs | 11 ++ crates/ui-egui/src/panels/timeline.rs | 62 ++++++++++- crates/ui-egui/tests/ui_group_rename.rs | 106 +++++++++++++++++++ 6 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/tests/ui_group_rename.rs diff --git a/crates/engine/src/commands/prop_groups.rs b/crates/engine/src/commands/prop_groups.rs index d070713ad..e4d80b16d 100644 --- a/crates/engine/src/commands/prop_groups.rs +++ b/crates/engine/src/commands/prop_groups.rs @@ -2,7 +2,7 @@ //! effect, mask, shape item or text animator (what scripting's `PropertyGroup.name`, `enabled`, //! `remove()`, `duplicate()` and `moveTo()` do, and what agents need without a UI selection). -use effectcraft_project::{GroupKind, ItemId, LayerId, LayerSource, Node, PropGroup, Uid}; +use effectcraft_project::{ItemId, LayerId, LayerSource, Node, PropGroup, Uid}; use serde_json::{Value, json}; use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layer_p, layers_p, str_p}; @@ -10,7 +10,7 @@ use crate::{EngineError, Result, Session, cmd}; /// Groups that can be renamed, removed, duplicated and reordered: instances in indexed lists. fn is_instance(g: &PropGroup) -> bool { - matches!(g.kind, GroupKind::Indexed | GroupKind::Effect { .. } | GroupKind::Mask { .. } | GroupKind::Tracker { .. }) + g.is_instance() } /// `{layer, prop: uid}` → (comp, layer, group uid), checking that the group is an instance. diff --git a/crates/engine/src/tests_project_items.rs b/crates/engine/src/tests_project_items.rs index b9363f658..8da72d31e 100644 --- a/crates/engine/src/tests_project_items.rs +++ b/crates/engine/src/tests_project_items.rs @@ -167,3 +167,26 @@ fn delete_removes_selected_groups_not_their_layer() { delete(&mut s); assert!(s.active_comp().unwrap().layer(LayerId(t)).is_none(), "a fixed group selected: the layer goes"); } + +/// #1061: text animators, masks and shape groups rename like effects, with undo; fixed groups and +/// empty names are refused. +#[test] +fn text_animators_masks_and_shape_groups_rename() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "C", "width": 64, "height": 64, "duration": 1})).unwrap(); + let t = s.execute("layer.newText", json!({"text": "ABCD"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.addTextAnimator", json!({"layer": t, "properties": ["tracking"]})).unwrap(); + let sh = s.execute("layer.newShape", json!({"kind": "star"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.addMask", json!({"layer": sh, "shape": "ellipse"})).unwrap(); + let uid = |s: &Session, l: u64, path: &str| s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.group(path).unwrap().groups().next().unwrap().uid; + let name = |s: &Session, l: u64, u: u64| s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.find_group(u).unwrap().name.clone(); + for (l, path, new) in [(t, "text/animators", "Wiggle In"), (sh, "masks", "Reveal"), (sh, "contents", "Star Body")] { + let u = uid(&s, l, path); + let old = name(&s, l, u); + s.execute("prop.renameGroup", json!({"layer": l, "prop": u, "name": new})).unwrap(); + assert_eq!(name(&s, l, u), new, "{path}"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(name(&s, l, u), old, "{path} undone"); + assert!(s.execute("prop.renameGroup", json!({"layer": l, "prop": u, "name": " "})).is_err()); + } +} diff --git a/crates/project/src/props.rs b/crates/project/src/props.rs index f1d5149d3..18b44edb0 100644 --- a/crates/project/src/props.rs +++ b/crates/project/src/props.rs @@ -337,6 +337,12 @@ pub fn parse_path(s: &str) -> Vec { } impl PropGroup { + /// An instance in an indexed list (effect, mask, shape item, text animator, tracker): these + /// can be renamed, removed, duplicated and reordered; fixed groups (Transform…) can't. + pub fn is_instance(&self) -> bool { + matches!(self.kind, GroupKind::Indexed | GroupKind::Effect { .. } | GroupKind::Mask { .. } | GroupKind::Tracker { .. }) + } + pub fn new(uid: Uid, match_id: &str, name: &str) -> PropGroup { PropGroup { uid, match_id: match_id.into(), name: name.into(), kind: GroupKind::Plain, enabled: true, children: vec![] } } diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index d8de61d83..9da88d5ad 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -1053,6 +1053,14 @@ fn histogram_only(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter // --------------------------------------------------------------------------------------------- +/// Open the rename field on effect `uid` (Enter with the effect selected), name selected. +pub fn start_rename(ctx: &egui::Context, uid: u64, name: &str) { + let id = egui::Id::new(("ec-rename", uid)); + ctx.data_mut(|d| d.insert_temp(id, name.to_string())); + ctx.data_mut(|d| d.insert_temp(id.with("select"), true)); + ctx.memory_mut(|m| m.request_focus(id.with("field"))); +} + pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let p = ui.painter().with_clip_rect(rect); @@ -1134,6 +1142,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { response.request_focus(); } app.auto.add(&format!("effectControls.effect.{}.nameEdit", g.uid), name_rect, "Effect name"); + if ctx.data_mut(|d| d.remove_temp::(rename_id.with("select"))).unwrap_or(false) { + widgets::select_all(&ctx, response.id, &value); + } // Escape cancels. egui drops the field's focus on Escape in the same frame, so the // key is checked on the focus being lost as well as held. let escape = (response.has_focus() || response.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 72f104290..6cdd136da 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -573,6 +573,41 @@ pub fn locate(app: &EffectcraftApp, layer: u64, time: Option) -> Option<(f3 /// Start renaming the first selected layer (Enter in the Timeline). pub fn begin_rename(app: &mut EffectcraftApp, ctx: &egui::Context) { + // A selected effect, mask, shape group or text animator renames before its layer (as in + // After Effects, #1061). + let group = app.session.state.selected_props.iter().rev().find_map(|(lid, uid)| { + let g = app.session.active_comp()?.layer(*lid)?.props.find_group(*uid).filter(|g| g.is_instance())?; + Some((lid.0, g.uid, g.name.clone(), matches!(g.kind, GroupKind::Effect { .. }))) + }); + if let Some((layer, uid, name, is_fx)) = group { + // An effect in a visible Effect Controls panel renames there, the rest in the Timeline. + if is_fx && app.auto.previous.iter().chain(app.auto.elements.iter()).any(|e| e.id == format!("effectControls.effect.{uid}.name")) { + super::effect_controls::start_rename(ctx, uid, &name); + } else { + // Show the group's row first: the field lives in it. + fn ancestors(g: &PropGroup, uid: u64, out: &mut Vec) -> bool { + for sg in g.groups() { + if sg.uid == uid || ancestors(sg, uid, out) { + out.push(sg.uid); + return true; + } + } + false + } + let mut open = vec![]; + if let Some(l) = app.session.active_comp().and_then(|c| c.layer(LayerId(layer))) { + ancestors(&l.props, uid, &mut open); + } + open.retain(|u| *u != uid); + app.ui.timeline.open_layers.insert(layer); + app.ui.timeline.open_groups.extend(open); + ctx.data_mut(|d| { + d.insert_temp(group_rename_id(), (layer, uid, name)); + d.insert_temp(rename_focus_id(), true); + }); + } + return; + } if let Some(l) = app.session.state.selected_layers.first().and_then(|id| app.session.active_comp().and_then(|c| c.layer(*id))) { start_rename(ctx, l.id.0, &l.name); } @@ -598,6 +633,11 @@ fn start_rename(ctx: &egui::Context, id: u64, name: &str) { }); } +/// The Timeline's open group rename field: (layer, group uid, text). +fn group_rename_id() -> egui::Id { + egui::Id::new("tl-group-rename") +} + fn rename_focus_id() -> egui::Id { egui::Id::new("tl-rename-focus") } @@ -2197,7 +2237,27 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } app.auto.add(&format!("timeline.group.{uid}.eye"), er, name); } - lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text); + let group_edit: Option<(u64, u64, String)> = ctx.data(|d| d.get_temp(group_rename_id())); + if let Some((_, _, mut buf)) = group_edit.filter(|(rl, ru, _)| *rl == layer.id.0 && ru == uid) { + let field = Rect::from_min_max(pos2(indent + 8.0, r.min.y + 2.0), pos2((cw.switches - 4.0).max(indent + 60.0), r.max.y - 2.0)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(field)); + let er = child.add(egui::TextEdit::singleline(&mut buf).font(Tokens::ui(12.0)).desired_width(field.width())); + app.auto.add(&format!("timeline.group.{uid}.nameEdit"), er.rect, &buf); + if ctx.data_mut(|d| d.remove_temp::(rename_focus_id())).unwrap_or(false) { + er.request_focus(); + widgets::select_all(&ctx, er.id, &buf); + } + if er.lost_focus() { + if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && !buf.trim().is_empty() && buf != *name { + actions.push(("prop.renameGroup".into(), json!({"layer": layer.id.0, "prop": uid, "name": buf}))); + } + ctx.data_mut(|d| d.remove::<(u64, u64, String)>(group_rename_id())); + } else { + ctx.data_mut(|d| d.insert_temp(group_rename_id(), (layer.id.0, *uid, buf))); + } + } else { + lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text); + } text_anim_popups(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions); shape_add_popup(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions); if *uid == GEOM_BIT | layer.id.0 { diff --git a/crates/ui-egui/tests/ui_group_rename.rs b/crates/ui-egui/tests/ui_group_rename.rs new file mode 100644 index 000000000..cf56ac6f4 --- /dev/null +++ b/crates/ui-egui/tests/ui_group_rename.rs @@ -0,0 +1,106 @@ +//! Enter renames the selected effect, text animator, mask or shape group, not only the layer +//! (#1061). Enter commits, Escape cancels. + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Key, Modifiers}; +use egui_kittest::Harness; +use serde_json::json; + +struct Fx { + text: u64, + shape: u64, + effect: u64, + anim: u64, + mask: u64, + group: u64, +} + +fn harness() -> (Harness<'static, EffectcraftApp>, Fx) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4, "fps": 24})).unwrap(); + let text = s.execute("layer.newText", json!({"text": "ABCD"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.addTextAnimator", json!({"layer": text, "properties": ["tracking"]})).unwrap(); + let effect = s.execute("effect.apply", json!({"layer": text, "effect": "Gaussian Blur"})).unwrap()["effects"][0].as_u64().unwrap(); + let shape = s.execute("layer.newShape", json!({"kind": "star"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.addMask", json!({"layer": shape, "shape": "ellipse"})).unwrap(); + let first = |s: &Session, l: u64, path: &str| { + s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.group(path).unwrap().groups().next().unwrap().uid + }; + let (anim, mask, group) = (first(&s, text, "text/animators"), first(&s, shape, "masks"), first(&s, shape, "contents")); + let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(6); + (h, Fx { text, shape, effect, anim, mask, group }) +} + +fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key) { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn name(h: &Harness<'_, EffectcraftApp>, layer: u64, uid: u64) -> String { + h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().props.find_group(uid).unwrap().name.clone() +} + +fn layer_name(h: &Harness<'_, EffectcraftApp>, layer: u64) -> String { + h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().name.clone() +} + +fn select(h: &mut Harness<'_, EffectcraftApp>, layer: u64, uid: u64) { + let s = &mut h.state_mut().session; + s.execute("layer.select", json!({"layers": [layer]})).unwrap(); + s.execute("prop.select", json!({"layer": layer, "prop": uid, "selectKeys": false})).unwrap(); + h.run_steps(3); +} + +fn editing(h: &Harness<'_, EffectcraftApp>, uid: u64) -> bool { + let ids = [format!("timeline.group.{uid}.nameEdit"), format!("effectControls.effect.{uid}.nameEdit")]; + h.state().auto.previous.iter().chain(h.state().auto.elements.iter()).any(|e| ids.contains(&e.id)) +} + +fn rename_with_enter(h: &mut Harness<'_, EffectcraftApp>, layer: u64, uid: u64, new: &str) { + select(h, layer, uid); + key(h, Key::Enter); + assert!(editing(h, uid), "Enter opens a rename field on group {uid}"); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.event(Event::Text(new.into())); + h.step(); + key(h, Key::Enter); + assert!(!editing(h, uid), "Enter closes the field"); +} + +#[test] +fn enter_renames_the_selected_group_not_the_layer() { + let (mut h, x) = harness(); + let before = (layer_name(&h, x.text), layer_name(&h, x.shape)); + for (layer, uid, new) in [(x.text, x.effect, "Delay"), (x.text, x.anim, "Wiggle"), (x.shape, x.mask, "Reveal"), (x.shape, x.group, "Body")] { + rename_with_enter(&mut h, layer, uid, new); + assert_eq!(name(&h, layer, uid), new); + } + assert_eq!((layer_name(&h, x.text), layer_name(&h, x.shape)), before, "the layers keep their names"); +} + +#[test] +fn escape_cancels_a_group_rename_and_a_layer_still_renames_without_a_group() { + let (mut h, x) = harness(); + let old = name(&h, x.text, x.anim); + select(&mut h, x.text, x.anim); + key(&mut h, Key::Enter); + assert!(editing(&h, x.anim)); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.event(Event::Text("Nope".into())); + h.step(); + key(&mut h, Key::Escape); + assert!(!editing(&h, x.anim)); + assert_eq!(name(&h, x.text, x.anim), old); + // No group selected: Enter renames the layer as before. + h.state_mut().session.execute("layer.select", json!({"layers": [x.shape]})).unwrap(); + h.run_steps(3); + key(&mut h, Key::Enter); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.event(Event::Text("Plate".into())); + h.step(); + key(&mut h, Key::Enter); + assert_eq!(layer_name(&h, x.shape), "Plate"); +} From ecc131fc869253161fc827a7be58a33a30938fe2 Mon Sep 17 00:00:00 2001 From: webdevtodayjason <115739170+webdevtodayjason@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:45:45 +0000 Subject: [PATCH 063/181] SVG: percentages in the root resolve against its own width and height --- crates/svg/src/lib.rs | 4 ++++ crates/svg/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 75985fbd1..9e24a5b88 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -1147,6 +1147,10 @@ pub fn parse(bytes: &[u8]) -> Result { } None => (pct_or("width", 'x', 300.0), pct_or("height", 'y', 150.0)), }; + // Percentages use the root viewport's user space, even without a viewBox. A size that is not + // a finite positive number keeps the 300x150 default, so it cannot produce NaN geometry. + let usable = |v: f64, d: f64| if v.is_finite() && v > 0.0 { v } else { d }; + (p.vw, p.vh) = vb.map_or((usable(dw, 300.0), usable(dh, 150.0)), |(_, w, h)| (w, h)); let mut rst = st.clone(); let (opacity, _) = p.cascade(root, &mut rst); let mut children = vec![]; diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index 3553205d8..e06537e32 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -86,6 +86,28 @@ fn paths_gradients_css_use() { assert!(c[0] > 0.9 && c[3] > 0.9, "{c:?}"); } +#[test] +fn root_svg_percentages_use_the_document_viewport() { + for (attrs, width, height) in [ + ("width=\"100\" height=\"80\"", 100.0, 80.0), + ("width=\"50%\" height=\"200%\"", 150.0, 300.0), + ("", 300.0, 150.0), + ("width=\"100\" height=\"80\" viewBox=\"0 0 200 160\"", 200.0, 160.0), + ("width=\"NaN%\" height=\"1e400\"", 300.0, 150.0), + ] { + let svg = format!(r#""#); + let d = parse(svg.as_bytes()).unwrap(); + let shapes = d.flatten(); + assert_eq!(shapes.len(), 1); + assert!( + matches!(shapes[0].2.geom, Geom::Rect { x, y, w, h, .. } + if x == width / 4.0 && y == height / 4.0 && w == width / 2.0 && h == height / 2.0), + "{attrs}: {:?}", + shapes[0].2.geom + ); + } +} + #[test] fn nested_svg_percentages_use_the_nearest_viewport() { let d = parse( From 85a269b609981326e0a0360bd00f0c1007f4a818 Mon Sep 17 00:00:00 2001 From: webdevtodayjason <115739170+webdevtodayjason@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:03:15 +0000 Subject: [PATCH 064/181] SVG: non-finite viewBox and nested sizes no longer panic the importer --- crates/svg/src/lib.rs | 8 ++++---- crates/svg/src/tests.rs | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 75985fbd1..2497294ed 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -789,8 +789,8 @@ impl<'a, 'i> Parser<'a, 'i> { // Nested viewport. let x = self.attr_len(n, "x", 'x', &st); let y = self.attr_len(n, "y", 'y', &st); - let w = n.attribute("width").and_then(|v| self.length(v, 'x', st.font_size)).unwrap_or(self.vw); - let h = n.attribute("height").and_then(|v| self.length(v, 'y', st.font_size)).unwrap_or(self.vh); + let w = n.attribute("width").and_then(|v| self.length(v, 'x', st.font_size)).filter(|v| v.is_finite()).unwrap_or(self.vw); + let h = n.attribute("height").and_then(|v| self.length(v, 'y', st.font_size)).filter(|v| v.is_finite()).unwrap_or(self.vh); let vb = viewbox_transform(n, w, h); let mut children = vec![]; let saved = (self.vw, self.vh); @@ -926,7 +926,7 @@ impl<'a, 'i> Parser<'a, 'i> { } "rect" => { let (w, h) = (l("width", 'x'), l("height", 'y')); - if w <= 0.0 || h <= 0.0 { + if !(w > 0.0 && h > 0.0) { return None; } let rx_a = n.attribute("rx").and_then(|v| self.length(v, 'x', st.font_size)); @@ -981,7 +981,7 @@ impl<'a, 'i> Parser<'a, 'i> { fn viewbox(n: roxmltree::Node) -> Option<([f64; 2], f64, f64)> { let v = pathdata::numbers(n.attribute("viewBox")?); - (v.len() == 4 && v[2] > 0.0 && v[3] > 0.0).then(|| ([v[0], v[1]], v[2], v[3])) + (v.len() == 4 && v.iter().all(|x| x.is_finite()) && v[2] > 0.0 && v[3] > 0.0).then(|| ([v[0], v[1]], v[2], v[3])) } /// The viewBox → viewport transform (`preserveAspectRatio`, default `xMidYMid meet`). diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index 3553205d8..243b87ff0 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -119,6 +119,45 @@ fn nested_svg_percentages_use_the_nearest_viewport() { assert_eq!(px(&img, 55, 65)[3], 0.0); } +#[test] +fn non_finite_viewboxes_do_not_panic_or_produce_nan_geometry() { + for attrs in ["viewBox=\"0 0 1e400 100\"", "viewBox=\"0 0 100 1e400\"", "viewBox=\"-1e400 0 100 100\""] { + for shape in ["", ""] { + let svg = format!(r#"{shape}"#); + let d = parse(svg.as_bytes()).unwrap(); + assert_eq!((d.width, d.height), (300.0, 150.0)); + let shapes = d.flatten(); + assert_eq!(shapes.len(), 1); + assert!(matches!(shapes[0].2.geom, Geom::Rect { x, y, w, h, rx, ry } + if [x, y, w, h, rx, ry].iter().all(|v| v.is_finite()) && w == 150.0 && h == 75.0)); + assert!(shapes[0].0.as_coeffs().iter().all(|v| v.is_finite())); + } + } +} + +#[test] +fn non_finite_nested_viewports_do_not_panic_or_produce_nan_geometry() { + for attrs in ["width=\"1e400\" height=\"10\"", "width=\"10\" height=\"1e400\""] { + for shape in ["", ""] { + let svg = format!(r#"{shape}"#); + let d = parse(svg.as_bytes()).unwrap(); + let shapes = d.flatten(); + assert_eq!(shapes.len(), 1); + assert!(matches!(shapes[0].2.geom, Geom::Rect { x, y, w, h, rx, ry } + if [x, y, w, h, rx, ry].iter().all(|v| v.is_finite()) && w > 0.0 && h > 0.0)); + assert!(shapes[0].0.as_coeffs().iter().all(|v| v.is_finite())); + } + } +} + +#[test] +fn zero_size_nested_viewport_draws_nothing() { + for attrs in ["width=\"0\" height=\"10\"", "width=\"10\" height=\"0\""] { + let svg = format!(r#""#); + assert!(parse(svg.as_bytes()).unwrap().flatten().is_empty(), "{attrs}"); + } +} + #[test] fn malformed_input() { assert!(parse(b"").is_err()); From 2cb5d199ea3311a6363679c37fc78d1e754ef3ab Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 00:12:45 +0000 Subject: [PATCH 065/181] G4: relink footage that moved footage.relink {item, path} relinks one item and every other missing item that moved with it (one undo step); Replace Footage on a missing item does the same, as in After Effects. File > Dependencies > Relink Missing Footage in Folder... (footage.relinkFolder) finds missing files by name in a folder; footage.missing lists them. The footage check after open finds files that moved along with the project's folder, without marking the project modified. Paths compare by parts, so Windows and macOS projects relink either way. Checks: engine relink:: / footage_check:: (13 passed with replay::), host relink:: (1 passed), clippy -D warnings (engine, host, ui-egui, script, cli, xtask). Not run: ui-egui i18n tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TEytG1Qum4TJrMTLKjgwDa Upstream port: the folder search (footage.relinkFolder) is left out, since upstream's file.relinkFootage already scans a folder; the module is relink_moved.rs next to upstream's relink.rs. (cherry picked from commit ea7923535fb81693ed95aa0fe768b850b347b2a9) (cherry picked from commit d15e853a720304c1032e21974d84e1501c6edfc3) (cherry picked from commit 0b8a328dda1fb727488e912a13f94a2ef4147d03) (cherry picked from commit 9d85bfde72483d4dfeddb5c59071bceeb4daff7c) --- crates/engine/src/commands/file_more.rs | 20 +- crates/engine/src/commands/mod.rs | 2 + crates/engine/src/commands/relink_moved.rs | 319 +++++++++++++++++++++ crates/engine/src/footage_check.rs | 59 +++- crates/host/tests/relink.rs | 56 ++++ docs/footage.md | 8 + 6 files changed, 459 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/commands/relink_moved.rs create mode 100644 crates/host/tests/relink.rs diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index ef26d59b9..c535862bc 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -849,14 +849,32 @@ fn replace_footage(s: &mut Session, p: &Value) -> Result { let src = crate::sequence::source_of(s.services.as_ref(), &path, opts); let f = crate::sequence::probe(importer.as_ref(), &src, s.sequence_rate()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; let name = src.name(); + // Replacing a missing file relinks the other missing files that moved with it (as After Effects + // does when you locate one). + let moved = match s.project.item(item).map(|it| &it.kind) { + Some(ItemKind::Footage(previous)) if previous.missing || !s.services.exists(&previous.path) => { + super::relink_moved::moved_with(s, item, &previous.path, &path) + } + _ => vec![], + }; + let relinked: Vec = moved.iter().map(|(id, _)| id.0).collect(); s.edit("Replace Footage", None, |proj, _| { let it = proj.item_mut(item).ok_or_else(|| bad("file.replaceFootage", "no such item"))?; it.name = name; it.kind = ItemKind::Footage(f); retarget_layers(proj, item, |_| LayerSource::Footage { item }); + for (id, nf) in moved { + if let Some(it) = proj.item_mut(id) { + it.kind = ItemKind::Footage(nf); + } + } Ok(()) })?; - Ok(Value::Null) + if relinked.is_empty() { + return Ok(Value::Null); + } + s.toast(format!("Also relinked {} other missing footage item(s)", relinked.len())); + Ok(json!({"relinked": relinked})) } /// Point every layer using `item` at a new source kind. diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 99b2d642a..f1d270662 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -50,6 +50,7 @@ mod proxy; pub mod puppet; mod query; mod relink; +pub(crate) mod relink_moved; mod render_queue; pub(crate) mod rig3d; pub mod roto_cmds; @@ -192,6 +193,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(key_transform::specs()); v.extend(file_more::specs()); v.extend(relink::specs()); + v.extend(relink_moved::specs()); v.extend(lottie::specs()); v.extend(timeline::specs()); v.extend(comp_more::specs()); diff --git a/crates/engine/src/commands/relink_moved.rs b/crates/engine/src/commands/relink_moved.rs new file mode 100644 index 000000000..4f70dd6ee --- /dev/null +++ b/crates/engine/src/commands/relink_moved.rs @@ -0,0 +1,319 @@ +//! Relinking footage that moved together (gaps.md G4; After Effects' Replace Footage on a missing +//! file, which then finds the other missing files in the same place). Searching a folder for +//! compatible files is `file.relinkFootage` ([`super::relink`]). +//! +//! - `footage.relink {item, path}`: point a footage item at `path`, keeping its interpretation, and +//! relink every other missing item that sits in the same place relative to it (the folder moved, +//! or a drive was renamed). One undo step. Replace Footage on a missing item does the same. +//! - `footage.missing`: the missing items. +//! - When a project opens from a folder it wasn't saved in, the footage check ([`moved_candidates`]) +//! tries each missing file at the same place under the project's new folder. +//! +//! Paths are compared by their parts, split on `/` and `\`, so a project made on Windows relinks on +//! macOS and the other way round. + +use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, Project}; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, str_p}; +use crate::{Result, Services, Session, cmd}; + +/// Trailing parts of a path tried under a moved project's folder. +const MOVED_PARTS: usize = 4; + +fn parts(path: &str) -> Vec<&str> { + path.split(['/', '\\']).filter(|p| !p.is_empty()).collect() +} + +/// The separator `path` uses (Windows paths keep theirs). +fn sep_of(path: &str) -> char { + if path.contains('\\') && !path.contains('/') { '\\' } else { '/' } +} + +fn file_name(path: &str) -> &str { + parts(path).last().copied().unwrap_or("") +} + +/// `prefix` and `rest` joined with `prefix`'s separator. +fn join(prefix: &str, rest: &[&str]) -> String { + let sep = sep_of(prefix); + let mut out = prefix.trim_end_matches(['/', '\\']).to_string(); + for p in rest { + out.push(sep); + out.push_str(p); + } + out +} + +/// What moved: `old` became `new`. The parts they end with in common are the same file's place +/// inside the moved folder; what comes before is the folder's old and new location. +/// `("/a/b/shots/x.mov", "/c/shots/x.mov")` → `(["a", "b"], "/c")`. +fn moved_root<'a>(old: &'a str, new: &str) -> Option<(Vec<&'a str>, String)> { + let (o, n) = (parts(old), parts(new)); + let common = o.iter().rev().zip(n.iter().rev()).take_while(|(a, b)| a == b).count(); + // At least the file name must match, else it's a different file (then only its folder moved). + let common = common.max(1).min(o.len()).min(n.len()); + let old_root: Vec<&str> = o.get(..o.len() - common)?.to_vec(); + // The new root keeps a leading `/` or a drive letter as written. + let new_parts = n.len() - common; + let mut new_root = String::new(); + if new.starts_with('/') { + new_root.push('/'); + } else if new.starts_with("\\\\") { + new_root.push_str("\\\\"); + } + let sep = sep_of(new); + for (i, p) in n.iter().take(new_parts).enumerate() { + if i > 0 { + new_root.push(sep); + } + new_root.push_str(p); + } + Some((old_root, new_root)) +} + +/// `path` under the moved root, when it was under the old one. +fn remap(path: &str, old_root: &[&str], new_root: &str) -> Option { + let p = parts(path); + if p.len() <= old_root.len() || p.get(..old_root.len())? != old_root { + return None; + } + Some(join(new_root, p.get(old_root.len()..)?)) +} + +/// The item's file and, for an image sequence, every frame, moved by `f`; `None` unless its main +/// file maps. +fn moved_footage(footage: &Footage, f: impl Fn(&str) -> Option) -> Option { + let mut nf = footage.clone(); + nf.path = f(&footage.path)?; + nf.sequence = footage.sequence.iter().map(|p| f(p).unwrap_or_else(|| p.clone())).collect(); + nf.missing = false; + Some(nf) +} + +fn present(s: &dyn Services, f: &Footage) -> bool { + !f.path.is_empty() && s.exists(&f.path) +} + +/// Footage items whose file is gone (flagged by the footage check, or not there now). +fn missing_items(s: &Session) -> Vec<(ItemId, Footage)> { + s.project + .items + .values() + .filter_map(|i| match &i.kind { + ItemKind::Footage(f) if f.kind != FootageKind::Data && !f.path.is_empty() && (f.missing || !present(s.services.as_ref(), f)) => { + Some((i.id, f.clone())) + } + _ => None, + }) + .collect() +} + +/// Where a file a moved project lost may be now: the same place under the project's new folder, +/// trying the longest tail of its old path first (`/old/proj/shots/a.mov` with the project now in +/// `/new/proj` → `/new/proj/old/proj/shots/a.mov`, …, `/new/proj/shots/a.mov`, `/new/proj/a.mov`). +pub(crate) fn moved_candidates(project_dir: &str, path: &str) -> Vec { + let p = parts(path); + (1..=MOVED_PARTS.min(p.len())).rev().filter_map(|k| p.get(p.len() - k..)).map(|tail| join(project_dir, tail)).collect() +} + +/// A missing item found again under a moved project's folder ([`moved_candidates`]), with its +/// sequence frames moved alongside. +pub(crate) fn find_moved(services: &dyn Services, project_dir: &str, f: &Footage) -> Option { + moved_candidates(project_dir, &f.path).into_iter().filter(|c| services.exists(c)).find_map(|hit| { + let (old_root, new_root) = moved_root(&f.path, &hit)?; + moved_footage(f, |q| if q == f.path { Some(hit.clone()) } else { remap(q, &old_root, &new_root) }) + }) +} + +/// The folder a project file is in. +pub(crate) fn project_dir(project_path: &str) -> Option { + let p = parts(project_path); + if p.len() < 2 { + return None; + } + let mut dir = join(if project_path.starts_with('/') { "/" } else { "" }, p.get(..p.len() - 1)?); + if !project_path.starts_with('/') { + dir = dir.trim_start_matches(['/', '\\']).to_string(); + } + Some(dir) +} + +/// Relink `found` in one undo step and say how many. +fn apply(s: &mut Session, label: &str, found: Vec<(ItemId, Footage)>) -> Result { + if found.is_empty() { + return Ok(json!({"relinked": [], "stillMissing": missing_items(s).len()})); + } + let ids: Vec = found.iter().map(|(id, _)| id.0).collect(); + s.edit(label, None, |proj: &mut Project, _| { + for (id, nf) in found { + if let Some(it) = proj.item_mut(id) { + it.kind = ItemKind::Footage(nf); + } + } + Ok(()) + })?; + s.events.push(crate::Event::PurgeCaches); + let still = missing_items(s).len(); + s.toast(match still { + 0 => format!("Relinked {} footage item(s)", ids.len()), + n => format!("Relinked {} footage item(s); {n} still missing", ids.len()), + }); + Ok(json!({"relinked": ids, "stillMissing": still})) +} + +/// The other missing items that moved the way `item` did (`old_path` → `new_path`): the same place +/// under the moved folder, else the same name in the new file's folder. Used by `footage.relink` +/// and by Replace Footage on a missing item. +pub(crate) fn moved_with(s: &Session, item: ItemId, old_path: &str, new_path: &str) -> Vec<(ItemId, Footage)> { + let Some((old_root, new_root)) = moved_root(old_path, new_path) else { return vec![] }; + let services = s.services.clone(); + let new_dir = project_dir(new_path); + let mut found = vec![]; + for (id, f) in missing_items(s) { + if id == item { + continue; + } + let same_place = moved_footage(&f, |q| remap(q, &old_root, &new_root)).filter(|nf| present(services.as_ref(), nf)); + let same_folder = || { + let dir = new_dir.as_deref()?; + let dir_parts = parts(dir); + let old_dir_len = parts(&f.path).len().checked_sub(1)?; + let old_dir: Vec<&str> = parts(&f.path).get(..old_dir_len)?.to_vec(); + moved_footage(&f, |q| remap(q, &old_dir, dir).or_else(|| Some(join(dir, &[file_name(q)])))).filter(|nf| { + // Only frames in that one folder (a name match, not a guess deeper down). + present(services.as_ref(), nf) && parts(&nf.path).len() == dir_parts.len() + 1 + }) + }; + if let Some(nf) = same_place.or_else(same_folder) { + found.push((id, nf)); + } + } + found +} + +/// `footage.relink {item, path}` +fn relink(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "footage.relink"; + let path = str_p(p, "path").filter(|p| !p.trim().is_empty()).ok_or_else(|| bad(CMD, "missing `path` (the file's new location)"))?.to_string(); + let item = p.get("item").and_then(Value::as_u64).map(ItemId).ok_or_else(|| bad(CMD, "missing `item` (a footage item id)"))?; + let Some(ItemKind::Footage(old)) = s.project.item(item).map(|i| &i.kind) else { return Err(bad(CMD, "`item` is not a footage item")) }; + let old = old.clone(); + if !s.services.exists(&path) { + return Err(bad(CMD, format!("there is no file at {path}"))); + } + let (old_root, new_root) = moved_root(&old.path, &path).ok_or_else(|| bad(CMD, "cannot compare the old and new paths"))?; + // The item itself: its file is `path`; its sequence frames move with it. + let main = + moved_footage(&old, |q| if q == old.path { Some(path.clone()) } else { remap(q, &old_root, &new_root) }).ok_or_else(|| bad(CMD, "cannot relink"))?; + let mut found = vec![(item, main)]; + found.extend(moved_with(s, item, &old.path, &path)); + apply(s, "Relink Footage", found) +} + +/// `footage.missing`: the missing footage items and where they were. +fn missing(s: &mut Session, _: &Value) -> Result { + let list: Vec = missing_items(s) + .into_iter() + .map(|(id, f)| json!({"item": id.0, "name": s.project.item(id).map(|i| i.name.clone()).unwrap_or_default(), "path": f.path})) + .collect(); + Ok(json!({"missing": list})) +} + +fn has_footage(s: &Session) -> std::result::Result<(), String> { + if s.project.items.values().any(|i| matches!(i.kind, ItemKind::Footage(_))) { Ok(()) } else { Err("the project has no footage".into()) } +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "footage.relink", + "Relink Footage", + [], + None, + "{item, path} — point a footage item at its file's new location (keeps the interpretation) and relink the other missing files found in the same place", + has_footage, + relink + ), + crate::query!("footage.missing", "Missing Footage", "{} → {missing: [{item, name, path}]}", missing), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn moved_roots_and_remaps() { + let (old, new) = moved_root("/a/b/shots/x.mov", "/c/shots/x.mov").unwrap(); + assert_eq!((old, new.as_str()), (vec!["a", "b"], "/c")); + assert_eq!(remap("/a/b/shots/y.mov", &["a", "b"], "/c").as_deref(), Some("/c/shots/y.mov")); + assert_eq!(remap("/a/z/y.mov", &["a", "b"], "/c"), None); + // Windows to macOS. + let (old, new) = moved_root("D:\\Work\\Job\\audio\\m.wav", "/Volumes/Work/Job/audio/m.wav").unwrap(); + assert_eq!((old, new.as_str()), (vec!["D:"], "/Volumes")); + assert_eq!(remap("D:\\Work\\Job\\img\\a.png", &["D:"], "/Volumes").as_deref(), Some("/Volumes/Work/Job/img/a.png")); + // A different file name: only the folder moved. + let (old, new) = moved_root("/a/x.mov", "/b/y.mov").unwrap(); + assert_eq!((old, new.as_str()), (vec!["a"], "/b")); + assert_eq!( + moved_candidates("/new/p", "/old/p/shots/a.mov"), + ["/new/p/old/p/shots/a.mov", "/new/p/p/shots/a.mov", "/new/p/shots/a.mov", "/new/p/a.mov"] + ); + assert_eq!(project_dir("/x/y/p.ecproj").as_deref(), Some("/x/y")); + assert_eq!(project_dir("p.ecproj"), None); + // Hostile input doesn't panic. + for (a, b) in [("", ""), ("/", "/"), ("x", ""), ("\\\\", "//"), ("/a", "/a/b/c")] { + let _ = moved_root(a, b); + let _ = moved_candidates(b, a); + let _ = project_dir(a); + } + } + + fn temp(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("ec-relink-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + d + } + + fn add(s: &mut Session, path: &str) -> ItemId { + let f = Footage { path: path.into(), width: 8, height: 8, has_video: true, missing: true, ..Default::default() }; + std::sync::Arc::make_mut(&mut s.project).add_item(file_name(path), crate::color::Label::None, None, ItemKind::Footage(f)) + } + + fn path_of(s: &Session, id: ItemId) -> String { + match s.project.item(id).map(|i| &i.kind) { + Some(ItemKind::Footage(f)) => f.path.clone(), + _ => String::new(), + } + } + + #[test] + fn relinking_one_file_finds_the_others_that_moved_with_it() { + let d = temp("one"); + let new = d.join("moved"); + std::fs::create_dir_all(new.join("shots")).unwrap(); + std::fs::create_dir_all(new.join("audio")).unwrap(); + for f in ["shots/a.mov", "shots/b.mov", "audio/m.wav"] { + std::fs::write(new.join(f), b"x").unwrap(); + } + let mut s = Session::default(); + let a = add(&mut s, "/gone/proj/shots/a.mov"); + let b = add(&mut s, "/gone/proj/shots/b.mov"); + let m = add(&mut s, "/gone/proj/audio/m.wav"); + let other = add(&mut s, "/elsewhere/c.mov"); + let target = new.join("shots/a.mov").to_string_lossy().to_string(); + let r = s.execute("footage.relink", json!({"item": a.0, "path": target})).unwrap(); + assert_eq!(r["relinked"], json!([a.0, b.0, m.0]), "{r}"); + assert_eq!(r["stillMissing"], 1); + assert_eq!(path_of(&s, m), new.join("audio/m.wav").to_string_lossy()); + assert_eq!(path_of(&s, other), "/elsewhere/c.mov"); + // One undo step brings every path back. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(path_of(&s, b), "/gone/proj/shots/b.mov"); + // Errors: no such file, not footage. + assert!(s.execute("footage.relink", json!({"item": a.0, "path": "/nope/a.mov"})).is_err()); + assert!(s.execute("footage.relink", json!({"item": 999_999, "path": target})).is_err()); + let _ = std::fs::remove_dir_all(d); + } +} diff --git a/crates/engine/src/footage_check.rs b/crates/engine/src/footage_check.rs index 423fd69fe..49b226bb5 100644 --- a/crates/engine/src/footage_check.rs +++ b/crates/engine/src/footage_check.rs @@ -27,6 +27,8 @@ struct Job { enum Found { Missing(bool), Probed(Box), + /// Found again under the project's new folder (the project was moved with its footage). + Moved(Box), } fn exists(s: &dyn crate::Services, path: &str) -> bool { @@ -52,6 +54,7 @@ pub fn start(s: &mut Session, items: Option>, wait: bool) -> crate:: let services = s.services.clone(); let importer = s.importer.clone(); let rate = s.sequence_rate(); + let project_dir = s.path.as_deref().and_then(crate::commands::relink_moved::project_dir); let n = jobs.len(); s.spawn_task("footageCheck", format!("Checking footage ({n} items)"), wait, move |ctl| { let mut found: Vec<(ItemId, Found)> = Vec::with_capacity(jobs.len()); @@ -68,6 +71,14 @@ pub fn start(s: &mut Session, items: Option>, wait: bool) -> crate:: // placeholders, as in After Effects). exists(services.as_ref(), &f.path) || f.sequence.iter().any(|p| exists(services.as_ref(), p)) }; + // Moved with the project: the same place under the project's folder. + if !present + && let Some(dir) = &project_dir + && let Some(nf) = crate::commands::relink_moved::find_moved(services.as_ref(), dir, f) + { + found.push((j.id, Found::Moved(Box::new(nf)))); + continue; + } // Saved without metadata (a hand-written or converted project): probe it now. let needs_probe = present && f.width == 0 && f.height == 0 && f.kind != FootageKind::Audio; let probed = importer.as_ref().filter(|_| needs_probe).map(|imp| match f.kind { @@ -105,7 +116,7 @@ pub fn start(s: &mut Session, items: Option>, wait: bool) -> crate:: /// not an edit (the same as After Effects marking footage missing at open). fn apply(s: &mut Session, found: Vec<(ItemId, Found)>) -> Value { let checked = found.len(); - let (mut missing, mut probed, mut changed) = (0, 0, false); + let (mut missing, mut probed, mut moved, mut changed) = (0, 0, 0, false); let p = std::sync::Arc::make_mut(&mut s.project); for (id, f) in found { let Some(ItemKind::Footage(cur)) = p.item_mut(id).map(|i| &mut i.kind) else { continue }; @@ -122,6 +133,11 @@ fn apply(s: &mut Session, found: Vec<(ItemId, Found)>) -> Value { *cur = *nf; changed = true; } + Found::Moved(nf) => { + moved += 1; + *cur = *nf; + changed = true; + } } } if changed { @@ -133,10 +149,17 @@ fn apply(s: &mut Session, found: Vec<(ItemId, Found)>) -> Value { } s.events.push(crate::Event::PurgeCaches); } - if missing > 0 { - s.toast(format!("{missing} footage item(s) missing (File ▸ Dependencies ▸ Find Missing Footage)")); + if moved > 0 { + s.toast(format!("Found {moved} footage item(s) in the project's new folder")); } - json!({"checked": checked, "missing": missing, "probed": probed}) + if missing > 0 { + s.toast(format!("{missing} footage item(s) missing (File ▸ Dependencies ▸ Relink Missing Footage in Folder…)")); + } + let mut out = json!({"checked": checked, "missing": missing, "probed": probed}); + if moved > 0 { + out["relinked"] = json!(moved); + } + out } pub(crate) fn command(s: &mut Session, p: &Value) -> crate::Result { @@ -223,6 +246,34 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[test] + fn footage_moved_with_its_project_is_found_on_open() { + let d = std::env::temp_dir().join(format!("ec-footage-moved-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(d.join("Job/shots")).unwrap(); + std::fs::write(d.join("Job/shots/a.png"), b"x").unwrap(); + // Saved on another machine: the footage sat next to the project in /Users/x/Job. + let mut s = session_with(&["/Users/x/Job/shots/a.png", "/Users/x/elsewhere/b.png"]); + let path = d.join("Job/p.ecproj"); + s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + let mut o = Session::default(); + o.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + let r = o.execute("footage.check", json!({"wait": true})).unwrap(); + assert_eq!((r["relinked"].as_u64(), r["missing"].as_u64()), (Some(1), Some(1)), "{r}"); + let paths: Vec = o + .project + .items + .values() + .filter_map(|i| match &i.kind { + ItemKind::Footage(f) => Some(f.path.clone()), + _ => None, + }) + .collect(); + assert!(paths.contains(&d.join("Job/shots/a.png").to_string_lossy().to_string()), "{paths:?}"); + assert!(!o.is_dirty(), "finding the files is not an edit"); + let _ = std::fs::remove_dir_all(d); + } + #[test] fn open_starts_the_check_when_asked() { let mut s = session_with(&["/nonexistent/ec/a.mov"]); diff --git a/crates/host/tests/relink.rs b/crates/host/tests/relink.rs new file mode 100644 index 000000000..5bb31b4ee --- /dev/null +++ b/crates/host/tests/relink.rs @@ -0,0 +1,56 @@ +//! Relinking footage that moved (docs/footage.md "Replacing and reloading"): Replace Footage on a +//! missing item relinks the other missing items that moved with it, as in After Effects. + +use std::path::{Path, PathBuf}; + +use effectcraft_engine::project::ItemKind; +use serde_json::json; + +fn icon() -> Vec { + std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/app-icon/hicolor/16x16/apps/ai.storyteller.effectcraft.png")).unwrap() +} + +fn footage_paths(s: &effectcraft_engine::Session) -> Vec<(u64, String, bool)> { + let mut v: Vec<(u64, String, bool)> = s + .project + .items + .values() + .filter_map(|i| match &i.kind { + ItemKind::Footage(f) => Some((i.id.0, f.path.clone(), f.missing)), + _ => None, + }) + .collect(); + v.sort(); + v +} + +#[test] +fn replacing_a_missing_file_relinks_the_others_that_moved_with_it() { + let root: PathBuf = std::env::temp_dir().join(format!("ec-host-relink-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let old = root.join("Job"); + std::fs::create_dir_all(old.join("stills")).unwrap(); + std::fs::write(old.join("stills/a.png"), icon()).unwrap(); + std::fs::write(old.join("stills/b.png"), icon()).unwrap(); + let mut s = effectcraft_host::session(); + let paths: Vec = ["stills/a.png", "stills/b.png"].iter().map(|p| old.join(p).to_string_lossy().to_string()).collect(); + s.execute("file.import", json!({"paths": paths, "sequence": false})).unwrap(); + let before = footage_paths(&s); + assert_eq!(before.len(), 2, "{before:?}"); + // The job folder moves; both files go missing. + let new = root.join("Archive/Job"); + std::fs::create_dir_all(root.join("Archive")).unwrap(); + std::fs::rename(&old, &new).unwrap(); + let r = s.execute("footage.check", json!({"wait": true})).unwrap(); + assert_eq!(r["missing"], 2, "{r}"); + // Locating one finds the other. + let a = before.iter().find(|(_, p, _)| p.ends_with("a.png")).unwrap().0; + let r = s.execute("file.replaceFootage", json!({"item": a, "path": new.join("stills/a.png").to_string_lossy()})).unwrap(); + assert_eq!(r["relinked"].as_array().map(Vec::len), Some(1), "{r}"); + let after = footage_paths(&s); + assert!(after.iter().all(|(_, p, missing)| p.starts_with(&*new.to_string_lossy()) && !missing), "{after:?}"); + // One undo step puts both back. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(footage_paths(&s).iter().map(|(_, p, _)| p.clone()).collect::>(), before.iter().map(|(_, p, _)| p.clone()).collect::>()); + let _ = std::fs::remove_dir_all(root); +} diff --git a/docs/footage.md b/docs/footage.md index 458c4c990..20ca89265 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -98,6 +98,14 @@ vectors, positions and IDs are never the picture. EXtractoR reads every other la - **File ▸ Replace Footage ▸ File…** (Ctrl+H) points the item at another file. Pick a numbered file to replace it with that file's whole sequence (`file.replaceFootage {"path": …, "sequence": false}` for one frame). +- **Relinking moved files.** `footage.relink {"item": id, "path": …}` points a missing item at + its file's new location, keeping its interpretation, and relinks every other missing item that + was in the same place relative to it (the whole folder moved, a drive was renamed, a project + made on Windows opened on macOS); File ▸ Replace Footage on a missing item does the same. One + undo step; `footage.missing` lists what is still missing. +- **A project moved with its footage** finds it again on open: the footage check that runs after + File ▸ Open tries each missing file at the same place under the project's new folder, without + marking the project modified. - **File ▸ Reload Footage** (Ctrl+Alt+L) reads the files again. A sequence picks up frames added to its folder (a render still in progress) and keeps its interpretation. From e23b926efc3677293c06fa92f837dccdeabbe78b Mon Sep 17 00:00:00 2001 From: Elias Date: Sat, 10 Oct 2026 15:21:19 +0100 Subject: [PATCH 066/181] G5: cargo xtask bench: render benchmark of generated scenarios (peak memory, CPU, wall) with results.jsonl and --compare (cherry picked from commit 40b232d02069925e9c72677f52ee6f356495a736) (cherry picked from commit a2d89bda11b7217c83c98ab681b8f9b4a334786d) (cherry picked from commit 63951595222207b6bfd0ecde72f991628c6f12d9) (cherry picked from commit 20c2bb0dcbb14013d5da3d506e7709ba8582b00b) --- docs/gaps.md | 4 + docs/testing.md | 24 ++ xtask/src/bench.rs | 961 +++++++++++++++++++++++++++++++++++++++++++++ xtask/src/main.rs | 6 +- 4 files changed, 994 insertions(+), 1 deletion(-) create mode 100644 xtask/src/bench.rs diff --git a/docs/gaps.md b/docs/gaps.md index e621ee876..95cf368af 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -249,6 +249,10 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie - Benchmark projects at 1080p and 4K with many layers, heavy effects, long footage and nested comps; record preview frame times, RAM preview fill and render times on reference machines. + Started 10 October 2026: `cargo xtask bench` renders generated 1080p / 4K scenarios (many + layers with common effects, nested precomps, footage) at Full and Half and records peak memory, + CPU and wall time per run, with `--compare` against an earlier run (docs/testing.md, "Render + benchmark"). - Compare with After Effects on the same machine where possible. - Done when: benchmark numbers are tracked over time and regressions fail a check. diff --git a/docs/testing.md b/docs/testing.md index 4aa8514a1..fca9762e2 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -46,3 +46,27 @@ cargo run --release -p effectcraft-cli -- bench --n 10 --play 30 prints per-layer and per-effect timings for one frame, and playback timings with and without the layer cache. + +### Render benchmark (`cargo xtask bench`) + +```sh +nice -n 10 cargo xtask bench --bin target/release/effectcraft-cli # or no --bin: one `--release` build into target/bench +nice -n 10 cargo xtask bench --quick --compare before.jsonl # 8 frames instead of 60, % change against an earlier run +cargo xtask bench --project F.ecproj --comp "Main" # any project's comp +``` + +measures memory, CPU and wall time of whole `effectcraft-cli` runs, so every optimisation has a +before and after number. It generates four deterministic scenarios into `target/bench/projects` +with engine commands (no committed media): `demo` (the built-in demo comp), `layers` (1080p, +100 solid, text and shape layers with Gaussian Blur, Glow, Levels, Curves, Drop Shadow and +keyframes), `precomps` (static and animated precomps nested three deep, each used several times) +and `footage` (a rendered PNG sequence and H.264 clip on several 1080p layers, plus a 4K still). +Each is opened (`open`), rendered at Full and Half (PNG sequence, deleted afterwards) and as one +frame. `/usr/bin/time -l` on macOS gives peak memory footprint, max RSS, user + sys CPU and +instructions retired; `/usr/bin/time -v` on Linux gives max RSS and CPU; elsewhere wall time only. + +It prints a table and appends one JSON line per run to `target/bench/results.jsonl` (git rev, +date, load average, scenario, settings, metrics); `--compare OLD.jsonl` prints the % change against +the latest matching runs there, `--compare OLD --with NEW` compares two files without rendering. +The table notes the 1-minute load average: on a busy machine wall time is noise, so compare +peak memory and instructions retired, and rerun when the load differs a lot. diff --git a/xtask/src/bench.rs b/xtask/src/bench.rs new file mode 100644 index 000000000..74154ef4d --- /dev/null +++ b/xtask/src/bench.rs @@ -0,0 +1,961 @@ +//! `cargo xtask bench`: a repeatable render benchmark (docs/testing.md "Render benchmark", gaps.md G5). +//! +//! Generates deterministic projects into `target/bench/projects` with `effectcraft-cli` (engine +//! commands, no committed media), renders each at Full and Half plus one single frame, and measures +//! every run with `/usr/bin/time` (macOS `-l`: peak memory footprint, max RSS, instructions +//! retired; Linux `-v`: max RSS; elsewhere wall time only). Prints a table and appends one JSON +//! line per run to `target/bench/results.jsonl`; `--compare OLD.jsonl` prints the % change. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::Instant; + +use serde_json::{Value, json}; + +const HELP: &str = "usage: cargo xtask bench [options] + +Renders generated scenarios with effectcraft-cli and records memory, CPU and wall time. +Run it under `nice -n 10` on a busy machine; the table notes the load average of every run. + + --bin PATH use this effectcraft-cli binary. Without it, effectcraft-cli is built once + with `cargo build --release` into target/bench (its own target dir: + the first build compiles everything, later ones are incremental) + --scenario a,b only these scenarios: demo, layers, precomps, footage (default: all) + --project F --comp C benchmark this project's comp (name or id) instead of the scenarios + --start S first frame time in seconds for --project (default 0) + --frames N frames per Full / Half render (default 60; --quick: 8) + --quick fewer frames, for a fast check + --format F render format (default png: a numbered sequence, deleted after each run) + --regen regenerate the footage media (otherwise reused when present) + --compare OLD.jsonl print the % change of this run against the latest matching runs in OLD + --with NEW.jsonl with --compare: compare two result files, without rendering + +Scenarios: demo (the built-in demo comp), layers (1080p, 100 solid/text/shape layers with Gaussian +Blur, Glow, Levels, Curves, Drop Shadow and keyframes), precomps (static and animated precomps +nested three deep, each used several times), footage (a generated PNG sequence and H.264 clip on +several layers at 1080p, plus a 4K still). Each renders Full, Half and one frame (`frame`); `open` +is the cost of loading the project alone."; + +// ---------------------------------------------------------------------------------------- options + +#[derive(Debug, Default)] +struct Opts { + bin: Option, + scenarios: Option>, + project: Option, + comp: Option, + start: f64, + frames: Option, + quick: bool, + format: String, + regen: bool, + compare: Option, + with: Option, +} + +fn parse_opts(args: &[String]) -> Result { + let mut o = Opts { format: "png".into(), ..Default::default() }; + let mut it = args.iter(); + while let Some(a) = it.next() { + let mut val = |name: &str| it.next().cloned().ok_or_else(|| format!("{name} needs a value")); + match a.as_str() { + "--bin" => o.bin = Some(PathBuf::from(val("--bin")?)), + "--scenario" | "--scenarios" => o.scenarios = Some(val("--scenario")?.split(',').map(|s| s.trim().to_string()).collect()), + "--project" => o.project = Some(PathBuf::from(val("--project")?)), + "--comp" => o.comp = Some(val("--comp")?), + "--start" => o.start = val("--start")?.parse().map_err(|_| "--start: seconds expected")?, + "--frames" => o.frames = Some(val("--frames")?.parse().map_err(|_| "--frames: a number expected")?), + "--quick" => o.quick = true, + "--format" => o.format = val("--format")?, + "--regen" => o.regen = true, + "--compare" => o.compare = Some(PathBuf::from(val("--compare")?)), + "--with" => o.with = Some(PathBuf::from(val("--with")?)), + other => return Err(format!("unknown option `{other}`\n\n{HELP}")), + } + } + if o.project.is_some() != o.comp.is_some() { + return Err("--project and --comp go together".into()); + } + if o.with.is_some() && o.compare.is_none() { + return Err("--with needs --compare OLD.jsonl".into()); + } + if o.frames == Some(0) { + return Err("--frames must be at least 1".into()); + } + Ok(o) +} + +pub fn run(root: &Path, args: &[String]) -> Result<(), String> { + if args.iter().any(|a| matches!(a.as_str(), "-h" | "--help" | "help")) { + println!("{HELP}"); + return Ok(()); + } + let o = parse_opts(args)?; + if let (Some(old), Some(new)) = (&o.compare, &o.with) { + let (old, new) = (read_jsonl(old)?, read_jsonl(new)?); + print!("{}", compare_table(&latest_per_key(&old), &latest_per_key(&new))); + return Ok(()); + } + let dir = root.join("target").join("bench"); + std::fs::create_dir_all(dir.join("projects")).map_err(|e| format!("{}: {e}", dir.display()))?; + let bin = match &o.bin { + Some(b) => b.clone(), + None => build_cli(root, &dir)?, + }; + if !bin.exists() { + return Err(format!("{}: no such binary", bin.display())); + } + let frames = o.frames.unwrap_or(if o.quick { 8 } else { 60 }); + let scenarios = match (&o.project, &o.comp) { + (Some(p), Some(c)) => vec![user_project(&bin, p, c, o.start)?], + _ => { + let want = o.scenarios.clone().unwrap_or_else(|| SCENARIOS.iter().map(|s| s.to_string()).collect()); + let mut v = Vec::new(); + for name in &want { + eprintln!("bench: generating {name}"); + v.push(generate(&bin, &dir, name, o.regen)?); + } + v + } + }; + let timer = TimeTool::detect(); + let ctx = RunContext::new(root, &bin, &o); + let results_path = dir.join("results.jsonl"); + let mut rows = Vec::new(); + for sc in &scenarios { + for kind in RUNS { + eprintln!("bench: {} {kind}", sc.name); + let row = measure(&bin, &dir, sc, kind, frames, &o.format, timer, &ctx)?; + append_line(&results_path, &row)?; + rows.push(row); + } + } + print!("{}", table(&rows, timer)); + println!("appended {} runs to {}", rows.len(), results_path.display()); + if let Some(old) = &o.compare { + let old = read_jsonl(old)?; + print!("{}", compare_table(&latest_per_key(&old), &latest_per_key(&rows))); + } + Ok(()) +} + +/// `cargo build -p effectcraft-cli --release` into `target/bench`. +fn build_cli(root: &Path, dir: &Path) -> Result { + eprintln!("bench: building effectcraft-cli (--release) into {} (one build; pass --bin to skip)", dir.display()); + let st = Command::new(env!("CARGO")) + .current_dir(root) + .env("CARGO_TARGET_DIR", dir) + .args(["build", "-p", "effectcraft-cli", "--release"]) + .status() + .map_err(|e| e.to_string())?; + if !st.success() { + return Err("building effectcraft-cli failed".into()); + } + Ok(dir.join("release").join(format!("effectcraft-cli{}", std::env::consts::EXE_SUFFIX))) +} + +// -------------------------------------------------------------------------------------- scenarios + +const SCENARIOS: &[&str] = &["demo", "layers", "precomps", "footage"]; +/// `open` loads the project only; `full` / `half` render `frames` frames; `frame` renders one. +const RUNS: &[&str] = &["open", "full", "half", "frame"]; + +struct Scenario { + name: String, + /// `--demo` or `--project F`. + source: Vec, + comp: String, + fps: f64, + start: f64, +} + +type Cmds = Vec<(&'static str, Value)>; + +/// A deterministic value in [0, 1) for layer `i`, stream `k`. +fn rnd(i: u32, k: u32) -> f64 { + let mut h = i.wrapping_mul(0x9E37_79B9) ^ k.wrapping_mul(0x85EB_CA6B); + h ^= h >> 15; + h = h.wrapping_mul(0x2C1B_3C6D); + h ^= h >> 12; + f64::from(h % 10_000) / 10_000.0 +} + +fn hex(i: u32) -> String { + let c = |k| (40.0 + rnd(i, k) * 215.0) as u8; + format!("#{:02x}{:02x}{:02x}", c(11), c(12), c(13)) +} + +fn comp(name: &str, w: u32, h: u32, duration: f64) -> (&'static str, Value) { + ("comp.new", json!({"name": name, "width": w, "height": h, "frameRate": 30, "duration": duration})) +} + +fn key(path: &str, time: f64, value: Value) -> (&'static str, Value) { + ("prop.addKey", json!({"path": path, "time": time, "value": value})) +} + +fn set(path: &str, value: Value) -> (&'static str, Value) { + ("prop.set", json!({"path": path, "value": value})) +} + +fn effect(id: &str) -> (&'static str, Value) { + ("effect.apply", json!({"effect": id})) +} + +/// Position keys from a start to an end point across `dur` seconds. +fn moving(c: &mut Cmds, i: u32, w: f64, h: f64, dur: f64) { + c.push(key("transform/position", 0.0, json!([rnd(i, 1) * w, rnd(i, 2) * h, 0]))); + c.push(key("transform/position", dur, json!([rnd(i, 3) * w, rnd(i, 4) * h, 0]))); +} + +/// The common effects, by layer: blur (animated), glow, levels + curves, drop shadow, blur + shadow. +fn common_effects(c: &mut Cmds, i: u32) { + match i % 5 { + 0 => { + c.push(effect("ec.blur.gaussian")); + c.push(key("effects/#1/blurriness", 0.0, json!(2.0 + rnd(i, 5) * 10.0))); + c.push(key("effects/#1/blurriness", 4.0, json!(10.0 + rnd(i, 6) * 20.0))); + } + 1 => { + c.push(effect("ec.stylize.glow")); + c.push(set("effects/#1/radius", json!(20.0))); + c.push(set("effects/#1/threshold", json!(40.0))); + } + 2 => { + c.push(effect("ec.color.levels")); + c.push(set("effects/#1/gamma", json!(1.3))); + c.push(effect("ec.color.curves")); + c.push(set("effects/#2/rgb", json!("0,0 0.4,0.55 1,1"))); + } + 3 => { + c.push(effect("ec.perspective.dropshadow")); + c.push(set("effects/#1/distance", json!(12.0))); + c.push(set("effects/#1/softness", json!(20.0))); + } + _ => { + c.push(effect("ec.blur.gaussian")); + c.push(set("effects/#1/blurriness", json!(6.0))); + c.push(effect("ec.perspective.dropshadow")); + c.push(set("effects/#2/softness", json!(10.0))); + } + } +} + +/// One solid, text or shape layer (by `i % 3`) with keyframes. +fn graphic_layer(c: &mut Cmds, i: u32, w: f64, h: f64, dur: f64) { + match i % 3 { + 0 => c.push(( + "layer.newSolid", + json!({"name": format!("Solid {i}"), "color": hex(i), "width": 200 + (rnd(i, 7) * 400.0) as u32, "height": 120 + (rnd(i, 8) * 300.0) as u32}), + )), + 1 => c.push(("layer.newText", json!({"text": format!("Layer {i}"), "size": 40 + (rnd(i, 7) * 80.0) as u32, "fill": hex(i)}))), + _ => { + let kind = ["rect", "ellipse", "star", "polygon", "rounded"][(i as usize / 3) % 5]; + let s = 80.0 + rnd(i, 7) * 260.0; + c.push(("layer.newShape", json!({"kind": kind, "size": [s, s], "fill": hex(i), "stroke": "#ffffff", "strokeWidth": 4}))); + } + } + moving(c, i, w, h, dur); + if i.is_multiple_of(2) { + c.push(key("transform/rotation", 0.0, json!(0.0))); + c.push(key("transform/rotation", dur, json!(rnd(i, 9) * 360.0 - 180.0))); + } + if i.is_multiple_of(3) { + c.push(key("transform/scale", 0.0, json!([60, 60, 100]))); + c.push(key("transform/scale", dur, json!([120, 120, 100]))); + } + if i.is_multiple_of(5) { + c.push(key("transform/opacity", 0.0, json!(30.0))); + c.push(key("transform/opacity", dur / 2.0, json!(100.0))); + } +} + +/// `layers`: a 1080p comp with 100 layers, the common effects and keyframes. +fn layers_cmds() -> Cmds { + let mut c = vec![comp("Layers", 1920, 1080, 10.0), ("layer.newSolid", json!({"name": "Background", "color": "#202838"}))]; + for i in 0..100 { + graphic_layer(&mut c, i, 1920.0, 1080.0, 4.0); + common_effects(&mut c, i); + } + c.push(("layer.newAdjustment", json!({"name": "Grade"}))); + c.push(effect("ec.color.curves")); + c.push(set("effects/#1/rgb", json!("0,0.03 0.5,0.52 1,0.97"))); + c +} + +/// `precomps`: static and animated 960x540 tiles, a 1080p `Mid` using them, and `Main` using +/// `Mid` and the tiles again (three levels deep). +fn precomps_cmds() -> Cmds { + let mut c = vec![comp("Static Tile", 960, 540, 10.0), ("layer.newSolid", json!({"name": "Tile BG", "color": "#334455"}))]; + for i in 0..6u32 { + let kind = ["rect", "ellipse", "star"][i as usize % 3]; + c.push(("layer.newShape", json!({"kind": kind, "size": [140, 140], "fill": hex(i), "position": [120 + i * 140, 270]}))); + } + c.push(("layer.newText", json!({"text": "STATIC", "size": 90, "fill": "#ffffff"}))); + c.push(effect("ec.perspective.dropshadow")); + c.push(set("effects/#1/softness", json!(15.0))); + + c.push(comp("Anim Tile", 960, 540, 10.0)); + c.push(("layer.newSolid", json!({"name": "Tile BG", "color": "#443322"}))); + for i in 10..18 { + graphic_layer(&mut c, i, 960.0, 540.0, 3.0); + if i.is_multiple_of(4) { + common_effects(&mut c, i); + } + } + + c.push(comp("Mid", 1920, 1080, 10.0)); + c.push(("layer.newSolid", json!({"name": "Mid BG", "color": "#101010"}))); + for (n, item) in ["Anim Tile", "Static Tile", "Anim Tile", "Static Tile"].iter().enumerate() { + let (x, y) = (480 + (n % 2) * 960, 270 + (n / 2) * 540); + c.push(("layer.addItem", json!({"item": item, "position": [x, y]}))); + } + c.push(effect("ec.stylize.glow")); + c.push(set("effects/#1/radius", json!(25.0))); + + c.push(comp("Main", 1920, 1080, 10.0)); + c.push(("layer.newSolid", json!({"name": "Main BG", "color": "#000000"}))); + for (n, item) in ["Mid", "Static Tile", "Mid", "Anim Tile", "Mid", "Static Tile", "Anim Tile"].iter().enumerate() { + let i = 40 + n as u32; + c.push(("layer.addItem", json!({"item": item}))); + c.push(set("transform/scale", json!([45, 45, 100]))); + moving(&mut c, i, 1920.0, 1080.0, 5.0); + if n % 2 == 0 { + c.push(key("transform/rotation", 0.0, json!(-10.0))); + c.push(key("transform/rotation", 5.0, json!(10.0))); + } + if n == 2 { + c.push(effect("ec.perspective.dropshadow")); + c.push(set("effects/#1/softness", json!(30.0))); + } + } + c +} + +/// The comps the footage media are rendered from: `Source` (1080p, animated) and `Still 4K`. +fn footage_source_cmds() -> Cmds { + let mut c = vec![comp("Source", 1920, 1080, 3.0), ("layer.newSolid", json!({"name": "BG", "color": "#183048"}))]; + for i in 60..76 { + graphic_layer(&mut c, i, 1920.0, 1080.0, 2.0); + } + c.push(comp("Still 4K", 3840, 2160, 1.0)); + c.push(("layer.newSolid", json!({"name": "BG", "color": "#402818"}))); + for i in 80..110 { + graphic_layer(&mut c, i, 3840.0, 2160.0, 1.0); + } + c +} + +/// `footage`: the PNG sequence and H.264 clip on several layers at 1080p, plus the 4K still. +fn footage_cmds(seq_first: &Path, clip: &Path, still: &Path) -> Cmds { + let import = |p: &Path, extra: Value| { + let mut v = json!({"paths": [p.to_string_lossy()], "addToComp": true}); + if let (Some(o), Some(e)) = (v.as_object_mut(), extra.as_object()) { + o.extend(e.clone()); + } + ("file.import", v) + }; + // Duplicate the top layer (the one just added) and select the copy (now on top). + let dup = |c: &mut Cmds| { + c.push(("edit.duplicate", json!({"layers": ["#1"]}))); + c.push(("layer.select", json!({"layers": ["#1"]}))); + }; + let mut c = vec![comp("Footage", 1920, 1080, 10.0)]; + c.push(import(still, json!({}))); + c.push(key("transform/scale", 0.0, json!([50, 50, 100]))); + c.push(key("transform/scale", 4.0, json!([70, 70, 100]))); + c.push(effect("ec.color.curves")); + c.push(set("effects/#1/rgb", json!("0,0 0.5,0.6 1,1"))); + c.push(import(clip, json!({}))); + c.push(set("transform/scale", json!([60, 60, 100]))); + moving(&mut c, 200, 1920.0, 1080.0, 4.0); + dup(&mut c); + c.push(("layer.setBlendMode", json!({"mode": "Screen"}))); + c.push(effect("ec.blur.gaussian")); + c.push(set("effects/#1/blurriness", json!(12.0))); + dup(&mut c); + moving(&mut c, 201, 1920.0, 1080.0, 4.0); + c.push(import(seq_first, json!({"sequence": true, "frameRate": 30}))); + c.push(set("transform/scale", json!([50, 50, 100]))); + moving(&mut c, 202, 1920.0, 1080.0, 4.0); + c.push(effect("ec.color.levels")); + c.push(set("effects/#1/gamma", json!(1.2))); + dup(&mut c); + c.push(set("transform/opacity", json!(60.0))); + moving(&mut c, 203, 1920.0, 1080.0, 4.0); + dup(&mut c); + c.push(effect("ec.perspective.dropshadow")); + moving(&mut c, 204, 1920.0, 1080.0, 4.0); + c +} + +/// Run `cmds` with `effectcraft-cli --empty run … --save-as out`. +fn build_project(bin: &Path, cmds: &Cmds, out: &Path) -> Result<(), String> { + let mut cmd = Command::new(bin); + cmd.arg("--empty").arg("run"); + for (id, params) in cmds { + cmd.arg(id).arg(params.to_string()); + } + cmd.arg("--save-as").arg(out); + cli(&mut cmd) +} + +/// Run a CLI command quietly; on failure, the tail of its output is the error. +fn cli(cmd: &mut Command) -> Result<(), String> { + let out = cmd.stdin(Stdio::null()).output().map_err(|e| format!("{cmd:?}: {e}"))?; + if out.status.success() { + return Ok(()); + } + let err = String::from_utf8_lossy(&out.stderr); + let tail: Vec<&str> = err.lines().rev().take(8).collect(); + Err(format!("effectcraft-cli failed ({}):\n{}", out.status, tail.into_iter().rev().collect::>().join("\n"))) +} + +fn generate(bin: &Path, dir: &Path, name: &str, regen: bool) -> Result { + let projects = dir.join("projects"); + let project = |file: &str, comp: &str| Scenario { + name: name.into(), + source: vec!["--project".into(), projects.join(file).to_string_lossy().into_owned()], + comp: comp.into(), + fps: 30.0, + start: 0.0, + }; + match name { + "demo" => Ok(Scenario { name: name.into(), source: vec!["--demo".into()], comp: "EffectCraft Intro".into(), fps: 30_000.0 / 1001.0, start: 0.0 }), + "layers" => { + build_project(bin, &layers_cmds(), &projects.join("layers.ecproj"))?; + Ok(project("layers.ecproj", "Layers")) + } + "precomps" => { + build_project(bin, &precomps_cmds(), &projects.join("precomps.ecproj"))?; + Ok(project("precomps.ecproj", "Main")) + } + "footage" => { + let media = projects.join("media"); + let (seq_dir, clip, still) = (media.join("seq"), media.join("clip.mp4"), media.join("still4k.png")); + let seq_first = seq_dir.join("src_00000.png"); + if regen || !seq_first.exists() || !clip.exists() || !still.exists() { + let _ = std::fs::remove_dir_all(&media); + std::fs::create_dir_all(&seq_dir).map_err(|e| e.to_string())?; + let src = projects.join("footage-source.ecproj"); + build_project(bin, &footage_source_cmds(), &src)?; + let render = |args: &[&str], out: &Path| cli(Command::new(bin).arg("--project").arg(&src).args(args).arg("--out").arg(out)); + render(&["render", "--comp", "Source", "--format", "png", "--start", "0", "--end", "2"], &seq_dir.join("src.png"))?; + render(&["render", "--comp", "Source", "--format", "h264", "--start", "0", "--end", "3"], &clip)?; + render(&["render-frame", "--comp", "Still 4K", "--time", "0"], &still)?; + if !seq_first.exists() { + return Err(format!("{}: the sequence render wrote no first frame", seq_first.display())); + } + } + build_project(bin, &footage_cmds(&seq_first, &clip, &still), &projects.join("footage.ecproj"))?; + Ok(project("footage.ecproj", "Footage")) + } + other => Err(format!("unknown scenario `{other}` (demo, layers, precomps, footage)")), + } +} + +/// `--project F --comp C`: the comp's frame rate from `info --json`. +fn user_project(bin: &Path, project: &Path, comp: &str, start: f64) -> Result { + let out = Command::new(bin).arg("--project").arg(project).args(["info", "--json"]).output().map_err(|e| e.to_string())?; + let info: Value = serde_json::from_slice(&out.stdout).map_err(|e| format!("info --json: {e}"))?; + let fps = info["project"]["items"] + .as_array() + .into_iter() + .flatten() + .find(|i| i["type"] == "Composition" && (i["name"] == comp || i["id"].as_u64().is_some_and(|id| id.to_string() == comp))) + .and_then(|i| i["frameRate"].as_f64()) + .filter(|f| *f > 0.0) + .ok_or_else(|| format!("{}: no comp `{comp}`", project.display()))?; + let stem = project.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + Ok(Scenario { name: format!("{stem}/{comp}"), source: vec!["--project".into(), project.to_string_lossy().into_owned()], comp: comp.into(), fps, start }) +} + +// ------------------------------------------------------------------------------------- measuring + +#[derive(Clone, Copy, Debug, PartialEq)] +enum TimeTool { + /// macOS `/usr/bin/time -l`. + Bsd, + /// GNU `/usr/bin/time -v`. + Gnu, + /// Wall time only. + None, +} + +impl TimeTool { + fn detect() -> Self { + if !Path::new("/usr/bin/time").exists() { + return TimeTool::None; + } + if cfg!(target_os = "macos") { + TimeTool::Bsd + } else if cfg!(target_os = "linux") { + TimeTool::Gnu + } else { + TimeTool::None + } + } +} + +#[derive(Clone, Debug, Default, PartialEq)] +struct Metrics { + wall_s: f64, + user_s: Option, + sys_s: Option, + max_rss: Option, + peak_footprint: Option, + instructions: Option, +} + +/// macOS `/usr/bin/time -l` output: ` 0.33 real 0.67 user 0.15 sys`, then ` ], width: u32, height: u32, inverted: bool) -> ([u8; 4], Vec) { let mut d = vec![]; - u32b(&mut d, 3); - u32b(&mut d, inverted as u32); + put_u32(&mut d, 3); + put_u32(&mut d, inverted as u32); // Path fill rule record. - u16b(&mut d, 6); + put_u16(&mut d, 6); d.resize(d.len() + 24, 0); let fx = |v: f64| ((v * (1 << 24) as f64).round() as i32).to_be_bytes(); for sp in subpaths { - u16b(&mut d, 0); - u16b(&mut d, sp.len() as u16); + put_u16(&mut d, 0); + put_u16(&mut d, sp.len() as u16); d.resize(d.len() + 22, 0); for k in sp { - u16b(&mut d, 1); + put_u16(&mut d, 1); for p in k { d.extend_from_slice(&fx(p[1] / height as f64)); d.extend_from_slice(&fx(p[0] / width as f64)); From 6259bf0b5741f43bbad14b7957e94f7d1413b05e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 23:00:40 +0200 Subject: [PATCH 116/181] #1132: a damaged PSD layer section keeps the merged image instead of failing the document Psd::parse drops the layers, records Psd::warnings and keeps the header and merged image; the importer then imports the flattened image as one layer and reports 'Layers could not be read'. Header damage and a missing merged image stay hard errors. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/psd_import.rs | 37 +++++++++++++++- crates/engine/src/tests_vector_import.rs | 27 ++++++++++++ crates/psd/src/lib.rs | 21 ++++++++- crates/psd/src/tests.rs | 55 ++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/psd_import.rs b/crates/engine/src/psd_import.rs index b025a7a5f..6741ed75a 100644 --- a/crates/engine/src/psd_import.rs +++ b/crates/engine/src/psd_import.rs @@ -111,10 +111,45 @@ struct Ctx<'a> { pub fn import(proj: &mut Project, psd: &Psd, path: &str, name: &str, retain: bool, rate: FrameRate, duration: Tick) -> PsdImport { let folder = proj.add_item(&format!("{name} Layers"), Label::Yellow, None, ItemKind::Folder); let mut cx = Ctx { psd, path, retain, folder, rate, duration, items: vec![], warnings: vec![] }; - let comp = build_comp(proj, &mut cx, name, &psd.tree(), None); + cx.warnings.extend(psd.warnings.iter().cloned()); + let comp = if psd.layers.is_empty() && !psd.warnings.is_empty() { + flattened_comp(proj, &mut cx, name) + } else { + build_comp(proj, &mut cx, name, &psd.tree(), None) + }; PsdImport { comp, folder, items: cx.items, warnings: cx.warnings } } +/// The layers could not be read: the merged image as the composition's only layer. +fn flattened_comp(proj: &mut Project, cx: &mut Ctx, name: &str) -> ItemId { + let cid = proj.add_item(name, Label::Sandstone, None, ItemKind::Comp(Arc::new(new_comp(cx)))); + cx.items.push(cid); + let mut comp = new_comp(cx); + let file = std::path::Path::new(cx.path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let f = Footage { + path: cx.path.to_string(), + kind: FootageKind::Still, + width: cx.psd.width, + height: cx.psd.height, + pixel_aspect: 1.0, + frame_rate: cx.rate, + has_video: true, + alpha: if cx.psd.merged_alpha { AlphaMode::Straight } else { AlphaMode::Ignore }, + loop_count: 1, + codec: "PSD".into(), + layer: None, + ..Default::default() + }; + let id = proj.add_item(&file, Label::Lavender, Some(cx.folder), ItemKind::Footage(f)); + cx.items.push(id); + let l = build::layer(proj, &comp, name, LayerSource::Footage { item: id }, (cx.psd.width, cx.psd.height), None); + comp.layers.push(l); + if let Some(item) = proj.item_mut(cid) { + item.kind = ItemKind::Comp(Arc::new(comp)); + } + cid +} + fn new_comp(cx: &Ctx) -> Comp { // A PSB can be 300,000 px a side; a composition is at most 30,000 (#788). let size = |v: u32| effectcraft_project::limits::size(v.into()); diff --git a/crates/engine/src/tests_vector_import.rs b/crates/engine/src/tests_vector_import.rs index e33a38b08..bbacb1bfd 100644 --- a/crates/engine/src/tests_vector_import.rs +++ b/crates/engine/src/tests_vector_import.rs @@ -283,3 +283,30 @@ fn paste_svg_rejects_text_that_is_not_usable_svg() { assert!(s.execute_checked("edit.pasteSvg", json!({})).is_err()); assert_eq!(s.active_comp().unwrap().layers.len(), 1, "nothing pasted"); } + +#[test] +fn psd_with_damaged_layer_section_imports_the_flattened_image() { + let mut d = WDoc::new(30, 20); + d.layers = vec![ + WLayer::solid("Back", Rect::new(0, 0, 30, 20), [0.0, 0.0, 1.0, 1.0]), + WLayer::solid("Box", Rect::new(2, 2, 8, 8), [1.0, 0.0, 0.0, 1.0]).with_blend(b"mul "), + ]; + let mut bytes = write(&d); + let at = bytes.windows(8).position(|w| w == b"8BIMmul ").unwrap(); + bytes[at] = b'X'; + let path = tmp("damaged.psd"); + std::fs::write(&path, bytes).unwrap(); + let mut s = Session::default(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap(); + // Photoshop import warnings are reported with the import's `errors` (as for adjustment layers). + let warnings = r["errors"].as_array().unwrap(); + assert_eq!(warnings.len(), 1, "{r}"); + assert!(warnings[0].as_str().unwrap().contains("Layers could not be read"), "{r}"); + let cid = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap()); + let c = s.project.comp(cid).unwrap().clone(); + assert_eq!((c.width, c.height), (30, 20)); + assert_eq!(c.layers.len(), 1); + let LayerSource::Footage { item } = c.layers[0].source else { panic!("footage") }; + let ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!() }; + assert!(f.layer.is_none(), "the merged image, not a Photoshop layer"); +} diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 1c0fd3d4d..202a40ccb 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -339,6 +339,9 @@ pub struct Psd { pub palette: Vec, /// Files embedded for smart objects. pub linked: Vec, + /// Repairs made while reading: damage that was skipped instead of failing the document + /// (for example an unreadable layer section, in which case only the merged image is kept). + pub warnings: Vec, data: Arc<[u8]>, merged_offset: usize, } @@ -387,6 +390,7 @@ impl Psd { merged_alpha: false, palette, linked: vec![], + warnings: vec![], data: data.clone(), merged_offset: 0, }; @@ -395,8 +399,17 @@ impl Psd { if lm_len > 0 { let li_len = r.length(psb)?; let li_end = r.pos.checked_add(li_len).filter(|&e| e <= lm_end).ok_or(Error::Truncated)?; - if li_len > 0 { - psd.layer_info(&data, r.pos, li_end)?; + if li_len > 0 + && let Err(e) = psd.layer_info(&data, r.pos, li_end) + { + // A damaged layer section must not discard the document: keep the header and the + // merged image, drop the layers and say so. Without a merged image there is + // nothing to fall back to, so the original error stands. + if lm_end.checked_add(2).is_none_or(|n| n > data.len()) { + return Err(e); + } + psd.layers.clear(); + psd.warnings.push(format!("Layers could not be read ({e}); only the flattened image is available")); } r.pos = li_end; if r.pos + 4 <= lm_end { @@ -422,6 +435,10 @@ impl Psd { r.pos = end; } } + if !psd.layers.is_empty() { + // A 16/32-bit layer block recovered the layers. + psd.warnings.clear(); + } psd.merged_offset = lm_end; Ok(psd) } diff --git a/crates/psd/src/tests.rs b/crates/psd/src/tests.rs index 235163b61..bb366595c 100644 --- a/crates/psd/src/tests.rs +++ b/crates/psd/src/tests.rs @@ -252,3 +252,58 @@ fn huge_layer_bounds_error_instead_of_allocating() { let p = Psd::parse(bytes).unwrap(); assert!(matches!(p.layer_pixels(0, false), Err(Error::Unsupported(_)))); } + +fn find(bytes: &[u8], needle: &[u8]) -> usize { + bytes.windows(needle.len()).position(|w| w == needle).expect("needle present") +} + +/// The second layer record's signature overwritten. +fn bad_signature() -> Vec { + let mut bytes = write(&doc_with_layers(8, ColorMode::Rgb, true)); + let at = find(&bytes, b"8BIMmul "); + bytes[at] = b'X'; + bytes +} + +/// The first channel of the first layer claims more data than the file holds. +fn bad_channel_length() -> Vec { + let mut bytes = write(&doc_with_layers(8, ColorMode::Rgb, true)); + let at = find(&bytes, b"8BIMnorm"); + let first_len = at - 4 * 6 + 2; + bytes[first_len..first_len + 4].copy_from_slice(&0x7fff_fff0u32.to_be_bytes()); + bytes +} + +#[test] +fn damaged_layer_section_keeps_the_merged_image() { + let good = Psd::parse(write(&doc_with_layers(8, ColorMode::Rgb, true))).unwrap(); + assert!(good.warnings.is_empty() && good.layers.len() == 3); + let want = good.composite().unwrap(); + for bytes in [bad_signature(), bad_channel_length()] { + let p = Psd::parse(bytes).unwrap(); + assert!(p.layers.is_empty()); + assert_eq!(p.warnings.len(), 1, "{:?}", p.warnings); + assert!(p.warnings[0].contains("Layers could not be read")); + assert_eq!((p.width, p.height), (40, 30)); + let got = p.composite().unwrap(); + assert_eq!(got.data, want.data); + } +} + +#[test] +fn header_damage_and_missing_merged_image_still_fail() { + let good = write(&doc_with_layers(8, ColorMode::Rgb, true)); + // Zero width. + let mut b = good.clone(); + b[18..22].copy_from_slice(&0u32.to_be_bytes()); + assert!(Psd::parse(b).is_err()); + // Unknown colour mode. + let mut b = good.clone(); + b[24..26].copy_from_slice(&99u16.to_be_bytes()); + assert!(Psd::parse(b).is_err()); + // Damaged layers and no merged image at all: nothing to fall back to. + let end = Psd::parse(good).unwrap().merged_offset; + let mut b = bad_signature(); + b.truncate(end); + assert!(Psd::parse(b).is_err()); +} From 35c5ff5fa1c3cd55cfc14b003fd622b221ec901e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 23:03:35 +0200 Subject: [PATCH 117/181] PSD: avoid redundant plane copies, per-pixel mask tests and repeated quad solves (#1136) Raw channels are borrowed, ZIP planes are un-predicted in place in the inflated buffer, the 32-bit un-predictor uses one scratch row, the layer mask tests the rect per row instead of per pixel (and skips a constant factor of 1.0), and a placed-layer bake solves the quad once and hoists per-triangle invariants. Output is bit-identical; tests compare against the replaced implementations and count the avoided work. Co-Authored-By: Claude Sonnet 5.5 --- crates/psd/src/lib.rs | 117 +++++-- crates/psd/src/placed.rs | 41 ++- crates/psd/src/tests_opt.rs | 649 ++++++++++++++++++++++++++++++++++++ 3 files changed, 771 insertions(+), 36 deletions(-) create mode 100644 crates/psd/src/tests_opt.rs diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 1c0fd3d4d..eff3e1678 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -15,6 +15,7 @@ pub mod placed; mod read; pub mod write; +use std::borrow::Cow; use std::sync::Arc; pub use descriptor::{DValue, Descriptor}; @@ -794,9 +795,16 @@ impl Psd { let mut r = Reader::at(&self.data[..end], offset); let comp = r.u16()?; decode_pixels(w, h)?; + let raw = self.plane_bytes(comp, &mut r, w, h)?; + Ok(self.samples(&raw, w, h)) + } + + /// The raw bytes (`row_bytes(w)` × `h`) of one channel whose pixel data follows `r`'s position + /// (after the compression field). Uncompressed data is borrowed, not copied. + fn plane_bytes<'a>(&self, comp: u16, r: &mut Reader<'a>, w: usize, h: usize) -> Result> { let rowb = self.row_bytes(w); - let raw = match comp { - 0 => r.bytes(rowb * h)?.to_vec(), + Ok(match comp { + 0 => Cow::Borrowed(r.bytes(rowb * h)?), 1 => { let mut counts = Vec::with_capacity(h.min(r.remaining() / 2)); for _ in 0..h { @@ -806,7 +814,7 @@ impl Psd { for c in counts { unpack_bits(r.bytes(c)?, rowb, &mut out)?; } - out + Cow::Owned(out) } 2 | 3 => { let rest = r.bytes(r.remaining())?; @@ -815,11 +823,20 @@ impl Psd { if comp == 3 { self.unpredict(&mut v, w, h); } - v + Cow::Owned(v) } c => return Err(Error::Unsupported(format!("compression {c}"))), - }; - Ok(self.samples(&raw, w, h)) + }) + } + + /// Plane `c` of the inflated ZIP data of consecutive planes, un-predicted in place (`comp` 3). + fn zip_plane<'v>(&self, comp: u16, v: &'v mut [u8], c: usize, w: usize, h: usize) -> Result<&'v [u8]> { + let rowb = self.row_bytes(w); + let plane = v.get_mut(c * rowb * h..(c + 1) * rowb * h).ok_or(Error::Truncated)?; + if comp == 3 { + self.unpredict(plane, w, h); + } + Ok(plane) } fn row_bytes(&self, w: usize) -> usize { @@ -839,12 +856,16 @@ impl Psd { } } 32 => { + // One scratch row for the whole plane. + let mut planar = vec![0u8; w * 4]; + #[cfg(test)] + counters::bump(&counters::SCRATCH); for row in v.chunks_exact_mut(w * 4).take(h) { for i in 1..row.len() { row[i] = row[i].wrapping_add(row[i - 1]); } // Bytes are stored plane by plane (all first bytes, then all second bytes…). - let planar = row.to_vec(); + planar.copy_from_slice(row); for x in 0..w { for b in 0..4 { row[x * 4 + b] = planar[b * w + x]; @@ -934,13 +955,11 @@ impl Psd { } 2 | 3 => { let rest = r.bytes(r.remaining())?; - let v = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, want * rowb * h).map_err(|e| Error::Invalid(format!("zip: {e:?}")))?; + let mut v = + miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, want * rowb * h).map_err(|e| Error::Invalid(format!("zip: {e:?}")))?; + // Each plane is un-predicted in place in the inflated buffer. for (c, p) in planes.iter_mut().enumerate().take(want) { - let mut plane = v.get(c * rowb * h..(c + 1) * rowb * h).ok_or(Error::Truncated)?.to_vec(); - if comp == 3 { - self.unpredict(&mut plane, w, h); - } - *p = Some(self.samples(&plane, w, h)); + *p = Some(self.samples(self.zip_plane(comp, &mut v, c, w, h)?, w, h)); } } c => return Err(Error::Unsupported(format!("compression {c}"))), @@ -1019,19 +1038,41 @@ impl Psd { _ => None, }; let def = m.default_color as f32 / 255.0; - for y in 0..oh { - let py = out_rect.top + y as i32; - for x in 0..ow { - let px = out_rect.left + x as i32; - let inside = py >= m.rect.top && py < m.rect.bottom && px >= m.rect.left && px < m.rect.right; - let mut v = match (&mask, inside) { - (Some(mk), true) => mk[(py - m.rect.top) as usize * mw + (px - m.rect.left) as usize], - _ => def, - }; - if m.inverted { - v = 1.0 - v; + // The factor outside the mask rect, or everywhere when there is no mask data. + let outside = if m.inverted { 1.0 - def } else { def }; + let scale = |px: &mut [[f32; 4]], v: f32| { + if v != 1.0 { + for p in px { + p[3] *= v; + } + } + }; + match &mask { + None => scale(&mut out, outside), + Some(mk) => { + // The columns of the output that fall inside the mask rect (the same for every row). + let lo = (i64::from(m.rect.left) - i64::from(out_rect.left)).clamp(0, ow as i64); + let hi = (i64::from(m.rect.right) - i64::from(out_rect.left)).clamp(lo, ow as i64); + let (lo, hi) = (lo as usize, hi as usize); + for (y, row) in out.chunks_exact_mut(ow.max(1)).take(oh).enumerate() { + let py = i64::from(out_rect.top) + y as i64; + #[cfg(test)] + counters::bump(&counters::INSIDE); + if py < i64::from(m.rect.top) || py >= i64::from(m.rect.bottom) { + scale(row, outside); + continue; + } + let (left, rest) = row.split_at_mut(lo); + let (mid, right) = rest.split_at_mut(hi - lo); + scale(left, outside); + scale(right, outside); + // The mask row under `mid`: its first column is output column `lo`. + let base = (py - i64::from(m.rect.top)) as usize * mw + (i64::from(out_rect.left) + lo as i64 - i64::from(m.rect.left)) as usize; + for (i, p) in mid.iter_mut().enumerate() { + let v = mk.get(base + i).copied().unwrap_or(def); + p[3] *= if m.inverted { 1.0 - v } else { v }; + } } - out[y * ow + x][3] *= v; } } } @@ -1149,3 +1190,29 @@ fn lab_to_rgb(l: f32, a: f32, b: f32) -> [f32; 3] { #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_opt; + +/// Test-only call counters (per thread), to prove what the optimised paths avoid doing. +#[cfg(test)] +pub(crate) mod counters { + use std::cell::Cell; + use std::thread::LocalKey; + + thread_local! { + /// Scratch rows allocated by the 32-bit ZIP un-predictor. + pub static SCRATCH: Cell = const { Cell::new(0) }; + /// `inside the mask rect` evaluations of the layer-mask loop. + pub static INSIDE: Cell = const { Cell::new(0) }; + /// `square_to_quad` solves. + pub static QUAD: Cell = const { Cell::new(0) }; + } + + pub fn bump(c: &'static LocalKey>) { + c.with(|c| c.set(c.get() + 1)); + } + /// Read and reset. + pub fn take(c: &'static LocalKey>) -> usize { + c.with(|c| c.replace(0)) + } +} diff --git a/crates/psd/src/placed.rs b/crates/psd/src/placed.rs index 156a8e271..a704d3ac3 100644 --- a/crates/psd/src/placed.rs +++ b/crates/psd/src/placed.rs @@ -191,7 +191,9 @@ fn style_map(style: &str, b: f64, x: f64, y: f64, fw: f64, fh: f64) -> [f64; 2] /// The projective map taking the unit square's corners (0,0), (1,0), (1,1), (0,1) to `q` /// (Heckbert's square-to-quad), as a 3×3 matrix applied to (u, v, 1). -fn square_to_quad(q: [[f64; 2]; 4]) -> [[f64; 3]; 3] { +pub(crate) fn square_to_quad(q: [[f64; 2]; 4]) -> [[f64; 3]; 3] { + #[cfg(test)] + crate::counters::bump(&crate::counters::QUAD); let [p0, p1, p2, p3] = q; let sx = p0[0] - p1[0] + p2[0] - p3[0]; let sy = p0[1] - p1[1] + p2[1] - p3[1]; @@ -206,6 +208,11 @@ fn square_to_quad(q: [[f64; 2]; 4]) -> [[f64; 3]; 3] { [[p1[0] - p0[0] + g * p1[0], p3[0] - p0[0] + h * p3[0], p0[0]], [p1[1] - p0[1] + g * p1[1], p3[1] - p0[1] + h * p3[1], p0[1]], [g, h, 1.0]] } +/// The baked size of placed content with document-space bounds `bb` at `k` pixels per document pixel. +fn size_for(bb: [f64; 4], k: f64) -> (u32, u32) { + (((bb[2] - bb[0]) * k).ceil().clamp(1.0, 16384.0) as u32, ((bb[3] - bb[1]) * k).ceil().clamp(1.0, 16384.0) as u32) +} + impl SmartObject { /// Whether the corner quad is a parallelogram (an affine placement). pub fn is_affine(&self) -> bool { @@ -222,11 +229,15 @@ impl SmartObject { /// A content point (pixels of a `w`×`h` content) in document pixels: the warp, then the quad. pub fn map(&self, p: [f64; 2], w: f64, h: f64) -> [f64; 2] { + self.map_with(&square_to_quad(self.quad), p, w, h) + } + + /// [`Self::map`] with the quad's projective matrix already solved (`square_to_quad(self.quad)`). + fn map_with(&self, m: &[[f64; 3]; 3], p: [f64; 2], w: f64, h: f64) -> [f64; 2] { let p = match &self.warp { Some(wp) => wp.apply(p, w, h), None => p, }; - let m = square_to_quad(self.quad); let (u, v) = (p[0] / w.max(1e-9), p[1] / h.max(1e-9)); let z = m[2][0] * u + m[2][1] * v + m[2][2]; let z = if z.abs() < 1e-12 { 1e-12 } else { z }; @@ -235,11 +246,15 @@ impl SmartObject { /// The document-space bounds (x0, y0, x1, y1) of the placed (warped) content. pub fn placed_bounds(&self, w: f64, h: f64) -> [f64; 4] { + self.bounds_with(&square_to_quad(self.quad), w, h) + } + + fn bounds_with(&self, m: &[[f64; 3]; 3], w: f64, h: f64) -> [f64; 4] { let n = 32; let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN]; for j in 0..=n { for i in 0..=n { - let p = self.map([i as f64 / n as f64 * w, j as f64 / n as f64 * h], w, h); + let p = self.map_with(m, [i as f64 / n as f64 * w, j as f64 / n as f64 * h], w, h); b = [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])]; } } @@ -248,8 +263,7 @@ impl SmartObject { /// The baked size for a `w`×`h` content at `k` pixels per document pixel. pub fn placed_size(&self, w: f64, h: f64, k: f64) -> (u32, u32) { - let bb = self.placed_bounds(w, h); - (((bb[2] - bb[0]) * k).ceil().clamp(1.0, 16384.0) as u32, ((bb[3] - bb[1]) * k).ceil().clamp(1.0, 16384.0) as u32) + size_for(self.placed_bounds(w, h), k) } /// Bake the content (straight RGBA) as placed: warped and mapped by the quad, rendered at @@ -260,10 +274,12 @@ impl SmartObject { if content.width == 0 || content.height == 0 { return None; } - let bb = self.placed_bounds(cw, ch); + // The quad is solved once for the bounds, the size and the whole grid. + let m = square_to_quad(self.quad); + let bb = self.bounds_with(&m, cw, ch); let (ow, oh) = match size { Some((w, h)) => (w.max(1), h.max(1)), - None => self.placed_size(cw, ch, k), + None => size_for(bb, k), }; let (ow, oh) = (ow as usize, oh as usize); // A forward grid, filled triangle by triangle with inverse barycentric sampling. @@ -273,7 +289,7 @@ impl SmartObject { for j in 0..=n { for i in 0..=n { let s = [i as f64 / n as f64 * cw, j as f64 / n as f64 * ch]; - let d = self.map(s, cw, ch); + let d = self.map_with(&m, s, cw, ch); dst.push([(d[0] - bb[0]) * k, (d[1] - bb[1]) * k]); src.push(s); } @@ -304,6 +320,10 @@ impl SmartObject { if den.abs() < 1e-12 { continue; } + // Loop-invariant per triangle: the source corners and the barycentric coefficients. + let [sa, sb, sc] = tri.map(|t| src[t]); + let (l1y, l1x) = (b[1] - c[1], c[0] - b[0]); + let (l2y, l2x) = (c[1] - a[1], a[0] - c[0]); let x0 = a[0].min(b[0]).min(c[0]).floor().max(0.0) as usize; let x1 = (a[0].max(b[0]).max(c[0]).ceil() as usize).min(ow); let y0 = a[1].min(b[1]).min(c[1]).floor().max(0.0) as usize; @@ -311,13 +331,12 @@ impl SmartObject { for y in y0..y1 { for x in x0..x1 { let (px, py) = (x as f64 + 0.5, y as f64 + 0.5); - let l1 = ((b[1] - c[1]) * (px - c[0]) + (c[0] - b[0]) * (py - c[1])) / den; - let l2 = ((c[1] - a[1]) * (px - c[0]) + (a[0] - c[0]) * (py - c[1])) / den; + let l1 = (l1y * (px - c[0]) + l1x * (py - c[1])) / den; + let l2 = (l2y * (px - c[0]) + l2x * (py - c[1])) / den; let l3 = 1.0 - l1 - l2; if l1 < -1e-9 || l2 < -1e-9 || l3 < -1e-9 { continue; } - let [sa, sb, sc] = tri.map(|t| src[t]); let sx = sa[0] * l1 + sb[0] * l2 + sc[0] * l3; let sy = sa[1] * l1 + sb[1] * l2 + sc[1] * l3; out[y * ow + x] = sample(sx, sy); diff --git a/crates/psd/src/tests_opt.rs b/crates/psd/src/tests_opt.rs new file mode 100644 index 000000000..1ffb565c9 --- /dev/null +++ b/crates/psd/src/tests_opt.rs @@ -0,0 +1,649 @@ +//! The optimised decode / mask / placed-bake paths against the implementations they replaced +//! (kept below verbatim as references), and counters for the work they no longer do. + +use super::counters::{self, INSIDE, QUAD, SCRATCH}; +use super::placed::{Warp, square_to_quad}; +use super::write::*; +use super::*; + +// ------------------------------------------------------------------ the replaced code + +fn ref_unpredict(depth: u16, v: &mut [u8], w: usize, h: usize) -> usize { + let mut allocs = 0; + match depth { + 16 => { + for row in v.chunks_exact_mut(w * 2).take(h) { + let mut prev = 0u16; + for c in row.as_chunks_mut::<2>().0 { + let x = u16::from_be_bytes([c[0], c[1]]).wrapping_add(prev); + c.copy_from_slice(&x.to_be_bytes()); + prev = x; + } + } + } + 32 => { + for row in v.chunks_exact_mut(w * 4).take(h) { + for i in 1..row.len() { + row[i] = row[i].wrapping_add(row[i - 1]); + } + let planar = row.to_vec(); + allocs += 1; + for x in 0..w { + for b in 0..4 { + row[x * 4 + b] = planar[b * w + x]; + } + } + } + } + _ => { + for row in v.chunks_exact_mut(w).take(h) { + for i in 1..row.len() { + row[i] = row[i].wrapping_add(row[i - 1]); + } + } + } + } + allocs +} + +fn ref_decode_channel(p: &Psd, offset: usize, len: usize, w: usize, h: usize) -> Result> { + let end = offset.checked_add(len).filter(|&e| e <= p.data.len()).ok_or(Error::Truncated)?; + let mut r = Reader::at(&p.data[..end], offset); + let comp = r.u16()?; + decode_pixels(w, h)?; + let rowb = p.row_bytes(w); + let raw = match comp { + 0 => r.bytes(rowb * h)?.to_vec(), + 1 => { + let mut counts = Vec::with_capacity(h.min(r.remaining() / 2)); + for _ in 0..h { + counts.push(if p.psb { r.u32()? as usize } else { r.u16()? as usize }); + } + let mut out = Vec::with_capacity(rowb * h); + for c in counts { + unpack_bits(r.bytes(c)?, rowb, &mut out)?; + } + out + } + 2 | 3 => { + let rest = r.bytes(r.remaining())?; + let mut v = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, rowb * h).map_err(|e| Error::Invalid(format!("zip: {e:?}")))?; + v.resize(rowb * h, 0); + if comp == 3 { + ref_unpredict(p.depth, &mut v, w, h); + } + v + } + c => return Err(Error::Unsupported(format!("compression {c}"))), + }; + Ok(p.samples(&raw, w, h)) +} + +fn ref_composite(p: &Psd) -> Result { + let (w, h) = (p.width as usize, p.height as usize); + decode_pixels(w, h)?; + let mut r = Reader::at(&p.data, p.merged_offset); + let comp = r.u16()?; + let nch = p.channels as usize; + let cc = p.mode.color_channels(); + let want = (cc + usize::from(p.merged_alpha)).min(nch); + let rowb = p.row_bytes(w); + let mut planes: Vec>> = vec![None; nch]; + match comp { + 0 => { + for pl in planes.iter_mut().take(want) { + *pl = Some(p.samples(r.bytes(rowb * h)?, w, h)); + } + } + 1 => { + let mut counts = Vec::with_capacity((nch * h).min(r.remaining() / 2)); + for _ in 0..nch * h { + counts.push(if p.psb { r.u32()? as usize } else { r.u16()? as usize }); + } + for (c, pl) in planes.iter_mut().enumerate().take(want) { + let mut out = Vec::with_capacity(rowb * h); + for row in 0..h { + unpack_bits(r.bytes(counts[c * h + row])?, rowb, &mut out)?; + } + *pl = Some(p.samples(&out, w, h)); + } + } + 2 | 3 => { + let rest = r.bytes(r.remaining())?; + let v = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, want * rowb * h).map_err(|e| Error::Invalid(format!("zip: {e:?}")))?; + for (c, pl) in planes.iter_mut().enumerate().take(want) { + let mut plane = v.get(c * rowb * h..(c + 1) * rowb * h).ok_or(Error::Truncated)?.to_vec(); + if comp == 3 { + ref_unpredict(p.depth, &mut plane, w, h); + } + *pl = Some(p.samples(&plane, w, h)); + } + } + c => return Err(Error::Unsupported(format!("compression {c}"))), + } + let alpha = if p.merged_alpha && nch > cc { planes[cc].take() } else { None }; + let mut data = Vec::with_capacity(w * h); + for i in 0..w * h { + let [r, g, b] = p.to_rgb(&planes, i); + let a = alpha.as_ref().map_or(1.0, |a| a[i]); + data.push([r, g, b, a]); + } + Ok(Pixels { width: w as u32, height: h as u32, data }) +} + +fn ref_layer_pixels(p: &Psd, index: usize, canvas: bool) -> Result { + let l = p.layers.get(index).ok_or_else(|| Error::Invalid(format!("no layer {index}")))?; + let doc = Rect { top: 0, left: 0, bottom: p.height as i32, right: p.width as i32 }; + let fill_rect = + if l.fill.is_some() && !l.has_pixels() { l.mask.filter(|m| !m.rect.is_empty() && m.default_color == 0).map_or(doc, |m| m.rect) } else { l.rect }; + let out_rect = if canvas { + doc + } else if fill_rect.is_empty() { + Rect { top: 0, left: 0, bottom: 1, right: 1 } + } else { + fill_rect + }; + let (ow, oh) = (out_rect.width() as usize, out_rect.height() as usize); + let mut out = vec![[0.0f32; 4]; decode_pixels(ow, oh)?]; + let src = fill_rect; + let (sw, sh) = (src.width() as usize, src.height() as usize); + if sw > 0 && sh > 0 { + let mut planes: Vec>> = vec![None; p.mode.color_channels()]; + let mut alpha = None; + if l.has_pixels() { + for c in &l.channels { + match c.id { + -1 => alpha = Some(ref_decode_channel(p, c.offset, c.len, sw, sh)?), + id if id >= 0 && (id as usize) < planes.len() => planes[id as usize] = Some(ref_decode_channel(p, c.offset, c.len, sw, sh)?), + _ => {} + } + } + } + let fill = l.fill.map(|c| c.map(|v| v as f32)); + let span = |s0: i32, n: usize, o0: i32, on: usize| { + let off = i64::from(o0) - i64::from(s0); + let lo = off.clamp(0, n as i64); + let hi = (off + on as i64).clamp(lo, n as i64); + (lo as usize..hi as usize, off) + }; + let (ys, oy) = span(src.top, sh, out_rect.top, oh); + let (xs, ox) = span(src.left, sw, out_rect.left, ow); + for y in ys { + let dy = (y as i64 - oy) as usize; + for x in xs.clone() { + let dx = (x as i64 - ox) as usize; + let i = y * sw + x; + let (rgb, a) = match fill { + Some(f) if !l.has_pixels() => (f, 1.0), + _ => (p.to_rgb(&planes, i), alpha.as_ref().map_or(1.0, |a| a[i])), + }; + out[dy * ow + dx] = [rgb[0], rgb[1], rgb[2], a]; + } + } + } + if let Some(m) = l.mask.filter(|m| !m.disabled) { + let mc = l.channels.iter().find(|c| c.id == -2); + let (mw, mh) = (m.rect.width() as usize, m.rect.height() as usize); + let mask = match mc { + Some(c) if mw > 0 && mh > 0 => Some(ref_decode_channel(p, c.offset, c.len, mw, mh)?), + _ => None, + }; + let def = m.default_color as f32 / 255.0; + for y in 0..oh { + let py = out_rect.top + y as i32; + for x in 0..ow { + let px = out_rect.left + x as i32; + let inside = py >= m.rect.top && py < m.rect.bottom && px >= m.rect.left && px < m.rect.right; + let mut v = match (&mask, inside) { + (Some(mk), true) => mk[(py - m.rect.top) as usize * mw + (px - m.rect.left) as usize], + _ => def, + }; + if m.inverted { + v = 1.0 - v; + } + out[y * ow + x][3] *= v; + } + } + } + Ok(Pixels { width: ow as u32, height: oh as u32, data: out }) +} + +fn ref_map(so: &SmartObject, p: [f64; 2], w: f64, h: f64) -> [f64; 2] { + let p = match &so.warp { + Some(wp) => wp.apply(p, w, h), + None => p, + }; + let m = square_to_quad(so.quad); + let (u, v) = (p[0] / w.max(1e-9), p[1] / h.max(1e-9)); + let z = m[2][0] * u + m[2][1] * v + m[2][2]; + let z = if z.abs() < 1e-12 { 1e-12 } else { z }; + [(m[0][0] * u + m[0][1] * v + m[0][2]) / z, (m[1][0] * u + m[1][1] * v + m[1][2]) / z] +} + +fn ref_placed_bounds(so: &SmartObject, w: f64, h: f64) -> [f64; 4] { + let n = 32; + let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN]; + for j in 0..=n { + for i in 0..=n { + let p = ref_map(so, [i as f64 / n as f64 * w, j as f64 / n as f64 * h], w, h); + b = [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])]; + } + } + [b[0].floor(), b[1].floor(), b[2].ceil(), b[3].ceil()] +} + +fn ref_placed_size(so: &SmartObject, w: f64, h: f64, k: f64) -> (u32, u32) { + let bb = ref_placed_bounds(so, w, h); + (((bb[2] - bb[0]) * k).ceil().clamp(1.0, 16384.0) as u32, ((bb[3] - bb[1]) * k).ceil().clamp(1.0, 16384.0) as u32) +} + +fn ref_render_placed(so: &SmartObject, content: &Pixels, k: f64, size: Option<(u32, u32)>) -> Option<(Pixels, [f64; 2])> { + let (cw, ch) = (content.width as f64, content.height as f64); + if content.width == 0 || content.height == 0 { + return None; + } + let bb = ref_placed_bounds(so, cw, ch); + let (ow, oh) = match size { + Some((w, h)) => (w.max(1), h.max(1)), + None => ref_placed_size(so, cw, ch, k), + }; + let (ow, oh) = (ow as usize, oh as usize); + let n = 48usize; + let mut dst = Vec::with_capacity((n + 1) * (n + 1)); + let mut src = Vec::with_capacity((n + 1) * (n + 1)); + for j in 0..=n { + for i in 0..=n { + let s = [i as f64 / n as f64 * cw, j as f64 / n as f64 * ch]; + let d = ref_map(so, s, cw, ch); + dst.push([(d[0] - bb[0]) * k, (d[1] - bb[1]) * k]); + src.push(s); + } + } + let mut out = vec![[0.0f32; 4]; ow * oh]; + let sample = |x: f64, y: f64| -> [f32; 4] { + let (fx, fy) = ((x - 0.5).clamp(0.0, cw - 1.0), (y - 0.5).clamp(0.0, ch - 1.0)); + let (x0, y0) = (fx.floor() as usize, fy.floor() as usize); + let (x1, y1) = ((x0 + 1).min(content.width as usize - 1), (y0 + 1).min(content.height as usize - 1)); + let (a, b) = ((fx - x0 as f64) as f32, (fy - y0 as f64) as f32); + let at = |x: usize, y: usize| { + let p = content.data[y * content.width as usize + x]; + [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]] + }; + let (p00, p10, p01, p11) = (at(x0, y0), at(x1, y0), at(x0, y1), at(x1, y1)); + let mut o = [0.0; 4]; + for c in 0..4 { + o[c] = (p00[c] * (1.0 - a) + p10[c] * a) * (1.0 - b) + (p01[c] * (1.0 - a) + p11[c] * a) * b; + } + o + }; + let idx = |i: usize, j: usize| j * (n + 1) + i; + for j in 0..n { + for i in 0..n { + for tri in [[idx(i, j), idx(i + 1, j), idx(i + 1, j + 1)], [idx(i, j), idx(i + 1, j + 1), idx(i, j + 1)]] { + let [a, b, c] = tri.map(|t| dst[t]); + let den = (b[1] - c[1]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[1] - c[1]); + if den.abs() < 1e-12 { + continue; + } + let x0 = a[0].min(b[0]).min(c[0]).floor().max(0.0) as usize; + let x1 = (a[0].max(b[0]).max(c[0]).ceil() as usize).min(ow); + let y0 = a[1].min(b[1]).min(c[1]).floor().max(0.0) as usize; + let y1 = (a[1].max(b[1]).max(c[1]).ceil() as usize).min(oh); + for y in y0..y1 { + for x in x0..x1 { + let (px, py) = (x as f64 + 0.5, y as f64 + 0.5); + let l1 = ((b[1] - c[1]) * (px - c[0]) + (c[0] - b[0]) * (py - c[1])) / den; + let l2 = ((c[1] - a[1]) * (px - c[0]) + (a[0] - c[0]) * (py - c[1])) / den; + let l3 = 1.0 - l1 - l2; + if l1 < -1e-9 || l2 < -1e-9 || l3 < -1e-9 { + continue; + } + let [sa, sb, sc] = tri.map(|t| src[t]); + let sx = sa[0] * l1 + sb[0] * l2 + sc[0] * l3; + let sy = sa[1] * l1 + sb[1] * l2 + sc[1] * l3; + out[y * ow + x] = sample(sx, sy); + } + } + } + } + } + for p in &mut out { + if p[3] > 0.0 { + for c in 0..3 { + p[c] /= p[3]; + } + } + } + Some((Pixels { width: ow as u32, height: oh as u32, data: out }, [bb[0], bb[1]])) +} + +// ------------------------------------------------------------------ fixtures + +fn bits(p: &Pixels) -> Vec<[u32; 4]> { + p.data.iter().map(|px| px.map(f32::to_bits)).collect() +} + +/// Deterministic samples in 0..1 for a `w`×`h` plane, as file bytes at `depth`. +fn synth_plane(depth: u16, w: usize, h: usize, seed: usize) -> Vec { + let mut out = vec![]; + for i in 0..w * h { + let v = ((i * 7 + seed * 13) % 101) as f32 / 100.0; + match depth { + 8 => out.push((v * 255.0).round() as u8), + 16 => out.extend(((v * 65535.0).round() as u16).to_be_bytes()), + _ => out.extend(v.to_be_bytes()), + } + } + out +} + +/// The delta coding that `comp` 3 undoes (the inverse of `Psd::unpredict`). +fn predict(raw: &[u8], w: usize, depth: u16) -> Vec { + let mut out = raw.to_vec(); + match depth { + 16 => { + for row in out.chunks_exact_mut(w * 2) { + let mut prev = 0u16; + for c in row.as_chunks_mut::<2>().0 { + let x = u16::from_be_bytes([c[0], c[1]]); + c.copy_from_slice(&x.wrapping_sub(prev).to_be_bytes()); + prev = x; + } + } + } + 32 => { + for row in out.chunks_exact_mut(w * 4) { + let inter = row.to_vec(); + for x in 0..w { + for b in 0..4 { + row[b * w + x] = inter[x * 4 + b]; + } + } + for i in (1..row.len()).rev() { + row[i] = row[i].wrapping_sub(row[i - 1]); + } + } + } + _ => { + for row in out.chunks_exact_mut(w) { + for i in (1..row.len()).rev() { + row[i] = row[i].wrapping_sub(row[i - 1]); + } + } + } + } + out +} + +/// A pixel-data stream (compression field first) of consecutive `w`×`h` planes. +fn stream(planes: &[Vec], w: usize, h: usize, depth: u16, comp: u16) -> Vec { + let mut out = comp.to_be_bytes().to_vec(); + let rowb = planes.first().map_or(0, |p| p.len() / h.max(1)); + match comp { + 0 => planes.iter().for_each(|p| out.extend(p)), + 1 => { + let rows: Vec> = planes.iter().flat_map(|p| p.chunks(rowb.max(1)).map(pack_bits)).collect(); + rows.iter().for_each(|r| out.extend((r.len() as u16).to_be_bytes())); + rows.iter().for_each(|r| out.extend(r)); + } + _ => { + let all: Vec = planes.iter().flat_map(|p| if comp == 3 { predict(p, w, depth) } else { p.clone() }).collect(); + out.extend(miniz_oxide::deflate::compress_to_vec_zlib(&all, 6)); + } + } + out +} + +/// A small RGB document with a background, a hidden box and a masked layer (8 bit, raw). +fn base() -> Psd { + let mut d = WDoc::new(40, 30); + d.rle = false; + let grad: Vec<[f32; 4]> = (0..1200).map(|i| [(i % 40) as f32 / 39.0, (i / 40) as f32 / 29.0, 0.25, 1.0]).collect(); + let bg = WLayer::pixels("Background", Rect::new(0, 0, 40, 30), grad); + let mut red = WLayer::solid("Red", Rect::new(5, 6, 10, 8), [1.0, 0.0, 0.0, 0.5]); + red.hidden = true; + let mut masked = WLayer::solid("Masked", Rect::new(20, 10, 12, 12), [0.0, 0.5, 1.0, 1.0]); + let data = (0..144).map(|i| if i % 12 < 6 { 1.0 } else { 0.25 }).collect(); + masked.mask = Some(WMask { rect: Rect::new(20, 10, 12, 12), data, default_color: 0, disabled: false }); + d.layers = vec![bg, red, masked]; + Psd::parse(write(&d)).unwrap() +} + +/// `p` with every channel and the merged image re-encoded at `depth` with compression `comp`. +fn reencode(p: &Psd, depth: u16, comp: u16) -> Psd { + let mut z = p.clone(); + z.depth = depth; + let mut data = p.data.to_vec(); + for (n, (l, zl)) in p.layers.iter().zip(&mut z.layers).enumerate() { + for (c, zc) in l.channels.iter().zip(&mut zl.channels) { + let r = if c.id == -2 { l.mask.map(|m| m.rect).unwrap_or(l.rect) } else { l.rect }; + let (w, h) = (r.width() as usize, r.height() as usize); + zc.offset = data.len(); + data.extend(stream(&[synth_plane(depth, w, h, n * 5 + (c.id + 2) as usize)], w, h, depth, comp)); + zc.len = data.len() - zc.offset; + } + } + let (w, h) = (p.width as usize, p.height as usize); + let planes: Vec> = (0..usize::from(p.channels)).map(|c| synth_plane(depth, w, h, 100 + c)).collect(); + z.merged_offset = data.len(); + data.extend(stream(&planes, w, h, depth, comp)); + z.data = data.into(); + z +} + +fn variants() -> impl Iterator { + [8u16, 16, 32].into_iter().flat_map(|d| [0u16, 1, 2, 3].into_iter().map(move |c| (d, c))) +} + +// ------------------------------------------------------------------ decoding + +#[test] +fn unpredict_matches_the_reference_and_allocates_one_scratch_row() { + for depth in [8u16, 16, 32] { + let p = Psd { depth, ..base() }; + let (w, h) = (13usize, 9usize); + let bps = usize::from(depth / 8); + let mut a: Vec = (0..w * h * bps).map(|i| (i * 31 % 251) as u8).collect(); + let mut b = a.clone(); + counters::take(&SCRATCH); + p.unpredict(&mut a, w, h); + let scratch = counters::take(&SCRATCH); + let old_allocs = ref_unpredict(depth, &mut b, w, h); + assert_eq!(a, b, "{depth} bit"); + // The reference copied every row; the new code uses one scratch row (32 bit only). + assert_eq!((scratch, old_allocs), if depth == 32 { (1, h) } else { (0, 0) }, "{depth} bit"); + } +} + +#[test] +fn raw_planes_are_borrowed_and_zip_planes_are_unpredicted_in_place() { + let p = base(); + // Raw: the sample bytes are a view into the file data. + let q = reencode(&p, 8, 0); + let c = q.layers[0].channels[0]; + let mut r = Reader::at(&q.data, c.offset); + let comp = r.u16().unwrap(); + let plane = q.plane_bytes(comp, &mut r, 40, 30).unwrap(); + let Cow::Borrowed(b) = plane else { panic!("raw plane was copied") }; + let file = q.data.as_ptr_range(); + assert!(file.contains(&b.as_ptr()) && b.len() == 1200); + // ZIP: the plane handed out is a sub-slice of the inflated buffer, not a copy. + let mut v: Vec = (0..3 * 40 * 30).map(|i| (i % 255) as u8).collect(); + let range = v.as_ptr_range(); + for comp in [2, 3] { + let got = q.zip_plane(comp, &mut v, 1, 40, 30).unwrap(); + assert_eq!(got.len(), 1200); + assert!(range.contains(&got.as_ptr()), "compression {comp}"); + } + assert!(q.zip_plane(2, &mut v, 3, 40, 30).is_err()); +} + +#[test] +fn decode_and_composite_match_the_reference() { + let p = base(); + for (depth, comp) in variants() { + let q = reencode(&p, depth, comp); + let what = format!("{depth} bit, compression {comp}"); + assert_eq!(bits(&q.composite().unwrap()), bits(&ref_composite(&q).unwrap()), "composite {what}"); + for i in 0..q.layers.len() { + for canvas in [false, true] { + assert_eq!(bits(&q.layer_pixels(i, canvas).unwrap()), bits(&ref_layer_pixels(&q, i, canvas).unwrap()), "layer {i} canvas {canvas}, {what}"); + } + } + } +} + +#[test] +fn errors_match_the_reference_on_damaged_data() { + let p = base(); + for (depth, comp) in variants() { + let mut q = reencode(&p, depth, comp); + // Cut the file in the middle of the merged image / the last channel. + let cut = q.data.len() - 7; + q.data = q.data[..cut].into(); + assert_eq!(q.composite().map(|p| bits(&p)), ref_composite(&q).map(|p| bits(&p)), "{depth} bit, compression {comp}"); + assert_eq!(q.layer_pixels(2, true).map(|p| bits(&p)), ref_layer_pixels(&q, 2, true).map(|p| bits(&p)), "{depth} bit, compression {comp}"); + } +} + +// ------------------------------------------------------------------ the layer mask + +fn rect_at(left: i32, top: i32) -> Rect { + Rect::new(left, top, 12, 12) +} + +#[test] +fn layer_mask_matches_the_reference_for_every_mask_shape() { + let p = base(); + for (depth, comp) in [(8, 1), (16, 2), (32, 3), (8, 0)] { + let q = reencode(&p, depth, comp); + // Moved so it overlaps the layer / canvas partly, wholly, or not at all (the data is 12×12). + for (left, top) in [(20, 10), (14, 4), (-5, -3), (34, 24), (25, 12), (300, 300), (-300, 5)] { + for default_color in [0u8, 255, 90] { + for inverted in [false, true] { + for disabled in [false, true] { + for with_data in [true, false] { + let mut r = q.clone(); + r.layers[2].mask = Some(LayerMask { rect: rect_at(left, top), default_color, disabled, inverted }); + if !with_data { + r.layers[2].channels.retain(|c| c.id != -2); + } + for canvas in [false, true] { + let what = format!( + "{depth} bit comp {comp}, mask at ({left},{top}) default {default_color} inverted {inverted} disabled {disabled} data {with_data} canvas {canvas}" + ); + assert_eq!(bits(&r.layer_pixels(2, canvas).unwrap()), bits(&ref_layer_pixels(&r, 2, canvas).unwrap()), "{what}"); + } + } + } + } + } + } + } +} + +#[test] +fn layer_mask_evaluates_inside_per_row_and_skips_no_ops() { + let q = reencode(&base(), 8, 0); + let with = |default_color, inverted, data: bool| { + let mut r = q.clone(); + r.layers[2].mask = Some(LayerMask { rect: rect_at(20, 10), default_color, disabled: false, inverted }); + if !data { + r.layers[2].channels.retain(|c| c.id != -2); + } + r + }; + // With mask data: one inside test per output row (the reference tested every pixel: 40 × 30). + counters::take(&INSIDE); + with(0, false, true).layer_pixels(2, true).unwrap(); + assert_eq!(counters::take(&INSIDE), 30); + // Own bounds: 12 rows. + with(0, false, true).layer_pixels(2, false).unwrap(); + assert_eq!(counters::take(&INSIDE), 12); + // No mask data: no per-pixel or per-row test at all (a constant factor, skipped when 1.0). + with(255, false, false).layer_pixels(2, true).unwrap(); + with(0, true, false).layer_pixels(2, true).unwrap(); + with(0, false, false).layer_pixels(2, true).unwrap(); + assert_eq!(counters::take(&INSIDE), 0); + // A no-op mask factor leaves the alpha untouched. + let plain = with(255, false, false).layer_pixels(2, false).unwrap(); + let mut none = q.clone(); + none.layers[2].mask = None; + assert_eq!(bits(&plain), bits(&none.layer_pixels(2, false).unwrap())); +} + +// ------------------------------------------------------------------ placed layers + +fn warps() -> Vec> { + let mut mesh = vec![]; + for j in 0..4 { + for i in 0..4 { + mesh.push([i as f64 * 20.0 / 3.0 + if j == 1 || j == 2 { 2.0 } else { 0.0 }, j as f64 * 10.0 / 3.0]); + } + } + vec![ + None, + Some(Warp { style: "warpArc".into(), bend: 40.0, bounds: [0.0, 0.0, 20.0, 10.0], ..Default::default() }), + Some(Warp { style: "warpCustom".into(), bounds: [0.0, 0.0, 20.0, 10.0], cols: 1, rows: 1, mesh, ..Default::default() }), + ] +} + +fn content(w: u32, h: u32) -> Pixels { + Pixels { + width: w, + height: h, + data: (0..w * h).map(|i| [(i % w) as f32 / w as f32, (i / w) as f32 / h as f32, 0.5, if i % 7 == 0 { 0.4 } else { 1.0 }]).collect(), + } +} + +#[test] +fn placed_bake_matches_the_reference() { + let quads = [ + [[10.0, 10.0], [50.0, 0.0], [50.0, 40.0], [10.0, 30.0]], + [[0.0, 0.0], [20.0, 0.0], [20.0, 10.0], [0.0, 10.0]], + [[3.0, 4.0], [33.0, 9.0], [28.0, 29.0], [-2.0, 21.0]], + [[0.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0]], + ]; + let c = content(20, 10); + for quad in quads { + for warp in warps() { + let so = SmartObject { uuid: "x".into(), quad, size: None, warp }; + let what = format!("{quad:?} {:?}", so.warp.as_ref().map(|w| &w.style)); + assert_eq!(so.placed_bounds(20.0, 10.0), ref_placed_bounds(&so, 20.0, 10.0), "{what}"); + for k in [0.5, 1.0, 2.0] { + assert_eq!(so.placed_size(20.0, 10.0, k), ref_placed_size(&so, 20.0, 10.0, k), "{what} k {k}"); + for size in [None, Some((30, 25)), Some((0, 0))] { + let got = so.render_placed(&c, k, size); + let want = ref_render_placed(&so, &c, k, size); + assert_eq!( + got.as_ref().map(|(p, o)| (p.width, p.height, bits(p), *o)), + want.as_ref().map(|(p, o)| (p.width, p.height, bits(p), *o)), + "{what} k {k} size {size:?}" + ); + } + } + for p in [[0.0, 0.0], [10.0, 5.0], [20.0, 10.0], [7.5, 2.25]] { + assert_eq!(so.map(p, 20.0, 10.0), ref_map(&so, p, 20.0, 10.0), "{what} {p:?}"); + } + } + } + assert!(SmartObject { uuid: "x".into(), quad: [[0.0; 2]; 4], size: None, warp: None }.render_placed(&content(0, 0), 1.0, None).is_none()); +} + +#[test] +fn placed_bake_solves_the_quad_once() { + let so = SmartObject { uuid: "x".into(), quad: [[10.0, 10.0], [50.0, 0.0], [50.0, 40.0], [10.0, 30.0]], size: None, warp: None }; + counters::take(&QUAD); + so.placed_bounds(20.0, 10.0); + assert_eq!(counters::take(&QUAD), 1, "33×33 mapped points, one solve (was 1089)"); + so.placed_size(20.0, 10.0, 1.0); + assert_eq!(counters::take(&QUAD), 1); + // The bounds, the size and the 49×49 grid of one bake share a single solve (was 1089 × 2 + 2401). + so.render_placed(&content(20, 10), 1.0, None).unwrap(); + assert_eq!(counters::take(&QUAD), 1); + so.render_placed(&content(20, 10), 1.0, Some((40, 40))).unwrap(); + assert_eq!(counters::take(&QUAD), 1); + // A single `map` still solves for itself. + so.map([1.0, 1.0], 20.0, 10.0); + assert_eq!(counters::take(&QUAD), 1); +} From 4101cef8b431cd28e4aef5066f4d5ba196ad496d Mon Sep 17 00:00:00 2001 From: Egon 5000 <210198019+elZanco@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:03:39 +0200 Subject: [PATCH 118/181] UI: add an Effects submenu to the layer context menu The layer context menu gains an Effects submenu between Blending Mode and Arrange, mirroring the After Effects layer menu: the effect registry grouped by category (sorted), synced with the Effect menu; picking one applies it to the selected layers. --- crates/ui-egui/src/panels/timeline.rs | 26 +++++++++++++++ .../ui-egui/tests/ui_blank_context_menus.rs | 33 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 72f104290..1b253e472 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3579,6 +3579,7 @@ pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], vector: bool, action } }) }); + ui.menu_button(crate::i18n::tr("Effects"), |ui| effects_submenu(ui, layers, &mut item)); ui.menu_button(crate::i18n::tr("Arrange"), |ui| { item(ui, "Bring Layer to Front", "layer.arrange", json!({"layers": layers, "to": "front"})); item(ui, "Bring Layer Forward", "layer.arrange", json!({"layers": layers, "to": "forward"})); @@ -3592,6 +3593,31 @@ pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], vector: bool, action item(ui, "Delete", "edit.clear", json!({"layers": layers})); } +/// The layer context menu's Effects submenu: every effect from the registry grouped by category, +/// in the same order as the Effect menu's palette, so the two stay in sync automatically. +fn effects_submenu(ui: &mut egui::Ui, layers: &[u64], item: &mut dyn FnMut(&mut egui::Ui, &str, &str, serde_json::Value)) { + let mut cats: Vec<(&str, Vec<&'static effectcraft_engine::effects::EffectSpec>)> = Vec::new(); + for e in effectcraft_engine::effects::all() { + match cats.iter_mut().find(|(c, _)| *c == e.category) { + Some((_, v)) => v.push(e), + None => cats.push((e.category, vec![e])), + } + } + cats.sort_by_key(|(c, _)| c.to_ascii_lowercase()); + widgets::menu_scroll(ui, |ui| { + for (cat, mut fx) in cats { + fx.sort_by_key(|e| e.name.to_ascii_lowercase()); + ui.menu_button(cat, |ui| { + widgets::menu_scroll(ui, |ui| { + for e in fx { + item(ui, e.name, "effect.apply", json!({"effect": e.id, "layers": layers})); + } + }); + }); + } + }); +} + fn fmt_num(v: f64, d: usize) -> String { format!("{v:.d$}") } diff --git a/crates/ui-egui/tests/ui_blank_context_menus.rs b/crates/ui-egui/tests/ui_blank_context_menus.rs index fcbf3d09c..c1533fa31 100644 --- a/crates/ui-egui/tests/ui_blank_context_menus.rs +++ b/crates/ui-egui/tests/ui_blank_context_menus.rs @@ -226,3 +226,36 @@ fn context_menu_rows_leave_no_gap_between_them() { } snapshot(&mut h, "project-rows"); } + +/// The layer context menu offers the whole effect palette as an "Effects" submenu (synced with +/// the Effect menu), between Blending Mode and Arrange; picking an effect applies it. +#[test] +fn timeline_layer_context_menu_has_the_effects_submenu_and_applies_an_effect() { + let mut h = harness(); + let layer = h.state_mut().session.execute("layer.newNull", json!({"name": "Null"})).unwrap()["layer"].as_u64().unwrap(); + h.run_steps(3); + let r = rect(&h, &format!("timeline.layer.{layer}.row")); + right_click(&mut h, pos2(r.min.x + 210.0, r.center().y)); + // The palette sits between Blending Mode and Arrange, as After Effects' layer menu does. + let blending = h.get_by_label("Blending Mode ⏵").rect(); + let effects = h.get_by_label("Effects ⏵").rect(); + let arrange = h.get_by_label("Arrange ⏵").rect(); + assert!(blending.bottom() <= effects.top() && effects.bottom() <= arrange.top(), "Effects between Blending Mode and Arrange"); + h.get_by_label("Effects ⏵").click(); + h.run_steps(3); + h.get_by_label("Blur & Sharpen ⏵").click(); + h.run_steps(3); + h.get_by_label("Gaussian Blur").click(); + h.run_steps(3); + let names: Vec = h + .state() + .session + .active_comp() + .unwrap() + .layer(effectcraft_engine::project::LayerId(layer)) + .unwrap() + .effects() + .map(|f| f.groups().map(|g| g.name.clone()).collect()) + .unwrap_or_default(); + assert_eq!(names, ["Gaussian Blur"]); +} From 4a08f8fdf404681c49c622370f938457fd3dcfec Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 23:06:55 +0200 Subject: [PATCH 119/181] PSD: a memory budget for one decode and a capped smart-object bake (#1138) The 2^28 pixel limit applied per image; one composite or layer decode kept the ZIP output, a Vec per channel and the result alive together, and the bake size came from the file's quad. A decode now charges every buffer against a byte budget (4 GiB, 1 GiB on 32-bit), checks its whole expected peak before allocating, writes channels straight into the result and returns an error when over budget. Bakes are at most 4x the document across and 2^24 pixels. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/psd_import.rs | 3 +- crates/psd/src/lib.rs | 358 ++++++++++++++++++++++------ crates/psd/src/memory_tests.rs | 398 ++++++++++++++++++++++++++++++++ crates/psd/src/placed.rs | 59 ++++- 4 files changed, 745 insertions(+), 73 deletions(-) create mode 100644 crates/psd/src/memory_tests.rs diff --git a/crates/engine/src/psd_import.rs b/crates/engine/src/psd_import.rs index b025a7a5f..32b9038e8 100644 --- a/crates/engine/src/psd_import.rs +++ b/crates/engine/src/psd_import.rs @@ -202,7 +202,8 @@ fn smart_object_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, index: usiz let bb = so.placed_bounds(cw as f64, ch as f64); let area = ((bb[2] - bb[0]) * (bb[3] - bb[1])).max(1.0); let k = ((cw as f64 * ch as f64) / area).sqrt().clamp(1.0, 4.0); - let (w, h) = so.placed_size(cw as f64, ch as f64, k); + // At most 4× the document across and 2^24 pixels, however far the file moves a corner. + let (k, (w, h)) = so.bake_scale(cw as f64, ch as f64, k, (cx.psd.width, cx.psd.height)); (w, h, k, bb) } else { (cw, ch, 1.0, [0.0; 4]) diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 1c0fd3d4d..a78165998 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -56,6 +56,79 @@ fn decode_pixels(w: usize, h: usize) -> Result { } } +fn too_large(w: usize, h: usize) -> Error { + Error::Unsupported(format!("{w}×{h} pixels is too large to decode")) +} + +/// What one decode (the composite, or one layer) may hold in memory at once: 4 GiB, 1 GiB where +/// pointers are 32 bits wide (WebAssembly). [`MAX_DECODE_PIXELS`] bounds one image; this bounds +/// the sum of the buffers a decode keeps alive together. +#[cfg(target_pointer_width = "32")] +const DECODE_BUDGET: u64 = 1 << 30; +#[cfg(not(target_pointer_width = "32"))] +const DECODE_BUDGET: u64 = 4 << 30; + +/// The bytes the buffers of one decode hold right now, against a limit. A decode charges a buffer +/// before it allocates it (and releases it when it drops it), so a file that would need more +/// than the limit fails with an error instead of an out-of-memory abort. +struct Budget { + limit: u64, + live: u64, +} + +impl Budget { + fn new(limit: u64) -> Budget { + Budget { limit, live: 0 } + } + + fn charge(&mut self, bytes: u64) -> Result<()> { + self.check(bytes)?; + self.live += bytes; + Ok(()) + } + + /// Whether `bytes` more would still fit, without taking them. A decode checks its whole + /// expected peak first, so one that cannot fit fails before it has allocated anything. + fn check(&self, bytes: u64) -> Result<()> { + match self.live.checked_add(bytes) { + Some(total) if total <= self.limit => Ok(()), + _ => { + const MIB: u64 = 1 << 20; + Err(Error::Unsupported(format!( + "decoding needs {} MiB more with {} MiB in use, over the {} MiB memory budget", + bytes / MIB, + self.live / MIB, + self.limit / MIB + ))) + } + } + } + + fn release(&mut self, bytes: u64) { + self.live = self.live.saturating_sub(bytes); + } + + /// An empty vector with room for `n` items, charged. + fn vec(&mut self, n: usize) -> Result> { + self.charge((n as u64).saturating_mul(std::mem::size_of::() as u64))?; + let mut v = Vec::new(); + v.try_reserve_exact(n).map_err(|_| Error::Unsupported(format!("out of memory for {n} items")))?; + Ok(v) + } + + /// `n` copies of `value`, charged. + fn filled(&mut self, n: usize, value: T) -> Result> { + let mut v = self.vec::(n)?; + v.resize(n, value); + Ok(v) + } +} + +/// Inflate a zlib stream to at most `limit` bytes. +fn inflate(src: &[u8], limit: usize) -> Result> { + miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(src, limit).map_err(|e| Error::Invalid(format!("zip: {e:?}"))) +} + /// Whether `bytes` start like a Photoshop document. pub fn is_psd(bytes: &[u8]) -> bool { bytes.len() >= 6 && &bytes[..4] == b"8BPS" && matches!(u16::from_be_bytes([bytes[4], bytes[5]]), 1 | 2) @@ -788,38 +861,59 @@ impl Psd { } } - /// Decode one channel of `w`×`h` samples at `offset` (compression field first). Values 0..1. - fn decode_channel(&self, offset: usize, len: usize, w: usize, h: usize) -> Result> { + /// The raw big-endian bytes of one channel of `w`×`h` samples at `offset` (compression field + /// first): `row_bytes(w) × h` of them with the compression and the prediction undone. The + /// buffer is charged to `budget`; the caller releases it once it is dropped. + fn channel_bytes(&self, offset: usize, len: usize, w: usize, h: usize, budget: &mut Budget) -> Result> { let end = offset.checked_add(len).filter(|&e| e <= self.data.len()).ok_or(Error::Truncated)?; - let mut r = Reader::at(&self.data[..end], offset); + let mut r = Reader::at(self.data.get(..end).ok_or(Error::Truncated)?, offset); let comp = r.u16()?; decode_pixels(w, h)?; let rowb = self.row_bytes(w); - let raw = match comp { - 0 => r.bytes(rowb * h)?.to_vec(), + let size = rowb.checked_mul(h).ok_or_else(|| too_large(w, h))?; + match comp { + 0 => { + let src = r.bytes(size)?; + let mut v = budget.vec::(size)?; + v.extend_from_slice(src); + Ok(v) + } 1 => { let mut counts = Vec::with_capacity(h.min(r.remaining() / 2)); for _ in 0..h { counts.push(if self.psb { r.u32()? as usize } else { r.u16()? as usize }); } - let mut out = Vec::with_capacity(rowb * h); + let mut out = budget.vec::(size)?; for c in counts { unpack_bits(r.bytes(c)?, rowb, &mut out)?; } - out + Ok(out) } 2 | 3 => { let rest = r.bytes(r.remaining())?; - let mut v = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, rowb * h).map_err(|e| Error::Invalid(format!("zip: {e:?}")))?; - v.resize(rowb * h, 0); + budget.charge(size as u64)?; + let mut v = inflate(rest, size)?; + v.resize(size, 0); if comp == 3 { self.unpredict(&mut v, w, h); } - v + Ok(v) } - c => return Err(Error::Unsupported(format!("compression {c}"))), - }; - Ok(self.samples(&raw, w, h)) + c => Err(Error::Unsupported(format!("compression {c}"))), + } + } + + /// One channel as `w * h` floats (0..1), charged to `budget` (the caller releases it). + fn channel_floats(&self, offset: usize, len: usize, w: usize, h: usize, budget: &mut Budget) -> Result> { + let raw = self.channel_bytes(offset, len, w, h, budget)?; + let mut out = budget.filled(decode_pixels(w, h)?, 0.0f32)?; + self.for_each_sample(&raw, w, h, |i, v| { + if let Some(o) = out.get_mut(i) { + *o = v; + } + }); + budget.release(raw.len() as u64); + Ok(out) } fn row_bytes(&self, w: usize) -> usize { @@ -862,29 +956,30 @@ impl Psd { } } - fn samples(&self, raw: &[u8], w: usize, h: usize) -> Vec { + /// Hand every sample of `raw` (`w`×`h`, row-major, in order) to `f` as a float. Samples a short + /// `raw` does not have are not reported. + fn for_each_sample(&self, raw: &[u8], w: usize, h: usize, mut f: impl FnMut(usize, f32)) { + let n = w.saturating_mul(h); match self.depth { 1 => { let rowb = w.div_ceil(8); - let mut out = Vec::with_capacity(w * h); for y in 0..h { for x in 0..w { let b = raw.get(y * rowb + x / 8).copied().unwrap_or(0); // 1 = black. - out.push(if b & (0x80 >> (x % 8)) != 0 { 0.0 } else { 1.0 }); + f(y * w + x, if b & (0x80 >> (x % 8)) != 0 { 0.0 } else { 1.0 }); } } - out } - 16 => raw.as_chunks::<2>().0.iter().take(w * h).map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(), - 32 => raw.as_chunks::<4>().0.iter().take(w * h).map(|c| f32::from_be_bytes([c[0], c[1], c[2], c[3]])).collect(), - _ => raw.iter().take(w * h).map(|&b| b as f32 / 255.0).collect(), + 16 => raw.as_chunks::<2>().0.iter().take(n).enumerate().for_each(|(i, c)| f(i, u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0)), + 32 => raw.as_chunks::<4>().0.iter().take(n).enumerate().for_each(|(i, c)| f(i, f32::from_be_bytes([c[0], c[1], c[2], c[3]]))), + _ => raw.iter().take(n).enumerate().for_each(|(i, &b)| f(i, b as f32 / 255.0)), } } - /// Convert colour channels (in the document's mode) to RGB. - fn to_rgb(&self, ch: &[Option>], i: usize) -> [f32; 3] { - let g = |k: usize| ch.get(k).and_then(|c| c.as_ref()).map_or(0.0, |c| c[i]); + /// Convert colour values (the document mode's channels, missing ones 0) to RGB. + fn rgb_from(&self, v: [f32; 4]) -> [f32; 3] { + let g = |k: usize| v.get(k).copied().unwrap_or(0.0); let rgb = match self.mode { ColorMode::Rgb => [g(0), g(1), g(2)], ColorMode::Cmyk => { @@ -902,55 +997,101 @@ impl Psd { if self.depth == 32 && self.mode != ColorMode::Lab { rgb.map(srgb_encode) } else { rgb } } + /// Write merged-image channel `c` (raw bytes) into the result: the first three colour channels + /// and the alpha channel into their slot of a pixel, the fourth CMYK channel into `black`. + fn store_merged_channel(&self, c: usize, raw: &[u8], data: &mut [[f32; 4]], black: &mut Option>) { + let (w, h, cc) = (self.width as usize, self.height as usize, self.mode.color_channels()); + if c < cc.min(3) { + self.for_each_sample(raw, w, h, |i, v| { + if let Some(p) = data.get_mut(i) { + p[c] = v; + } + }); + } else if c == 3 && c < cc { + if let Some(k) = black { + self.for_each_sample(raw, w, h, |i, v| { + if let Some(o) = k.get_mut(i) { + *o = v; + } + }); + } + } else if c == cc && self.merged_alpha { + self.for_each_sample(raw, w, h, |i, v| { + if let Some(p) = data.get_mut(i) { + p[3] = v; + } + }); + } + } + /// The merged (flattened) image. pub fn composite(&self) -> Result { + self.composite_with_budget(DECODE_BUDGET) + } + + /// [`Self::composite`] with at most `limit` bytes live at once. The channels are written + /// straight into the result, so the peak is the result (16 bytes a pixel), the CMYK black + /// channel, and the one buffer a channel (or, for ZIP, all channels) decodes through. + pub(crate) fn composite_with_budget(&self, limit: u64) -> Result { + let mut budget = Budget::new(limit); let (w, h) = (self.width as usize, self.height as usize); - decode_pixels(w, h)?; + let n = decode_pixels(w, h)?; let mut r = Reader::at(&self.data, self.merged_offset); let comp = r.u16()?; let nch = self.channels as usize; let cc = self.mode.color_channels(); let want = (cc + usize::from(self.merged_alpha)).min(nch); let rowb = self.row_bytes(w); - let mut planes: Vec>> = vec![None; nch]; + let size = rowb.checked_mul(h).ok_or_else(|| too_large(w, h))?; + // The peak: the result, the CMYK black channel and what the channels decode through. + let black_bytes = if self.mode == ColorMode::Cmyk && want > 3 { 4 * n as u64 } else { 0 }; + let transient = if matches!(comp, 2 | 3) { (size as u64).saturating_mul(want as u64) } else { size as u64 }; + budget.check((16 * n as u64).saturating_add(black_bytes).saturating_add(transient))?; + // Opaque black until a channel says otherwise. + let mut data = budget.filled(n, [0.0f32, 0.0, 0.0, 1.0])?; + let mut black = if self.mode == ColorMode::Cmyk && want > 3 { Some(budget.filled(n, 0.0f32)?) } else { None }; match comp { 0 => { - for p in planes.iter_mut().take(want) { - *p = Some(self.samples(r.bytes(rowb * h)?, w, h)); + for c in 0..want { + self.store_merged_channel(c, r.bytes(size)?, &mut data, &mut black); } } 1 => { - let mut counts = Vec::with_capacity((nch * h).min(r.remaining() / 2)); - for _ in 0..nch * h { + let total = nch.saturating_mul(h); + let mut counts = Vec::with_capacity(total.min(r.remaining() / 2)); + for _ in 0..total { counts.push(if self.psb { r.u32()? as usize } else { r.u16()? as usize }); } - for (c, p) in planes.iter_mut().enumerate().take(want) { - let mut out = Vec::with_capacity(rowb * h); + for c in 0..want { + let mut out = budget.vec::(size)?; for row in 0..h { - unpack_bits(r.bytes(counts[c * h + row])?, rowb, &mut out)?; + let count = *counts.get(c * h + row).ok_or(Error::Truncated)?; + unpack_bits(r.bytes(count)?, rowb, &mut out)?; } - *p = Some(self.samples(&out, w, h)); + self.store_merged_channel(c, &out, &mut data, &mut black); + budget.release(size as u64); } } 2 | 3 => { let rest = r.bytes(r.remaining())?; - let v = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, want * rowb * h).map_err(|e| Error::Invalid(format!("zip: {e:?}")))?; - for (c, p) in planes.iter_mut().enumerate().take(want) { - let mut plane = v.get(c * rowb * h..(c + 1) * rowb * h).ok_or(Error::Truncated)?.to_vec(); + // One stream holds every channel: what it may decode to is what they need. + let total = size.checked_mul(want).ok_or_else(|| too_large(w, h))?; + budget.charge(total as u64)?; + let mut v = inflate(rest, total)?; + for c in 0..want { + let plane = v.get_mut(c * size..(c + 1) * size).ok_or(Error::Truncated)?; if comp == 3 { - self.unpredict(&mut plane, w, h); + self.unpredict(plane, w, h); } - *p = Some(self.samples(&plane, w, h)); + self.store_merged_channel(c, plane, &mut data, &mut black); } } c => return Err(Error::Unsupported(format!("compression {c}"))), } - let alpha = if self.merged_alpha && nch > cc { planes[cc].take() } else { None }; - let mut data = Vec::with_capacity(w * h); - for i in 0..w * h { - let [r, g, b] = self.to_rgb(&planes, i); - let a = alpha.as_ref().map_or(1.0, |a| a[i]); - data.push([r, g, b, a]); + for (i, p) in data.iter_mut().enumerate() { + let k = black.as_ref().and_then(|k| k.get(i)).copied().unwrap_or(0.0); + let [r, g, b] = self.rgb_from([p[0], p[1], p[2], k]); + *p = [r, g, b, p[3]]; } Ok(Pixels { width: w as u32, height: h as u32, data }) } @@ -958,6 +1099,14 @@ impl Psd { /// One layer's pixels with its layer mask (and vector-less fill colour) applied to alpha. /// `canvas`: document-sized with the layer in place; otherwise the layer's own bounds. pub fn layer_pixels(&self, index: usize, canvas: bool) -> Result { + self.layer_pixels_with_budget(index, canvas, DECODE_BUDGET) + } + + /// [`Self::layer_pixels`] with at most `limit` bytes live at once: the result (16 bytes a + /// pixel) with the channels written straight into it, the CMYK black channel, the one channel + /// being decoded, and the mask. + pub(crate) fn layer_pixels_with_budget(&self, index: usize, canvas: bool, limit: u64) -> Result { + let mut budget = Budget::new(limit); let l = self.layers.get(index).ok_or_else(|| Error::Invalid(format!("no layer {index}")))?; let doc = Rect { top: 0, left: 0, bottom: self.height as i32, right: self.width as i32 }; // Fill layers often have no pixel data: they cover the canvas (or the mask). @@ -971,22 +1120,26 @@ impl Psd { fill_rect }; let (ow, oh) = (out_rect.width() as usize, out_rect.height() as usize); - let mut out = vec![[0.0f32; 4]; decode_pixels(ow, oh)?]; + let n = decode_pixels(ow, oh)?; let src = fill_rect; let (sw, sh) = (src.width() as usize, src.height() as usize); - if sw > 0 && sh > 0 { - let mut planes: Vec>> = vec![None; self.mode.color_channels()]; - let mut alpha = None; - if l.has_pixels() { - for c in &l.channels { - match c.id { - -1 => alpha = Some(self.decode_channel(c.offset, c.len, sw, sh)?), - id if id >= 0 && (id as usize) < planes.len() => planes[id as usize] = Some(self.decode_channel(c.offset, c.len, sw, sh)?), - _ => {} - } - } + // The peak: the result, then either the CMYK black channel with the channel being decoded, + // or the mask as floats with its bytes. Checked before anything is allocated. + let plane = |w: usize, h: usize| (self.row_bytes(w) as u64).saturating_mul(h as u64); + let mut pixels_peak = 0u64; + if l.has_pixels() && sw > 0 && sh > 0 { + pixels_peak = plane(sw, sh).saturating_add(if self.mode == ColorMode::Cmyk { 4 * n as u64 } else { 0 }); + } + let mut mask_peak = 0u64; + if let Some(m) = l.mask.filter(|m| !m.disabled) { + let (mw, mh) = (m.rect.width() as usize, m.rect.height() as usize); + if mw > 0 && mh > 0 && l.channels.iter().any(|c| c.id == -2) { + mask_peak = plane(mw, mh).saturating_add((mw as u64).saturating_mul(mh as u64).saturating_mul(4)); } - let fill = l.fill.map(|c| c.map(|v| v as f32)); + } + budget.check((16 * n as u64).saturating_add(pixels_peak.max(mask_peak)))?; + let mut out = budget.filled(n, [0.0f32; 4])?; + if sw > 0 && sh > 0 { // The source rows / columns that land inside the output (64-bit: file bounds can span // the whole i32 range). let span = |s0: i32, n: usize, o0: i32, on: usize| { @@ -997,16 +1150,77 @@ impl Psd { }; let (ys, oy) = span(src.top, sh, out_rect.top, oh); let (xs, ox) = span(src.left, sw, out_rect.left, ow); - for y in ys { - let dy = (y as i64 - oy) as usize; - for x in xs.clone() { - let dx = (x as i64 - ox) as usize; - let i = y * sw + x; - let (rgb, a) = match fill { - Some(f) if !l.has_pixels() => (f, 1.0), - _ => (self.to_rgb(&planes, i), alpha.as_ref().map_or(1.0, |a| a[i])), - }; - out[dy * ow + dx] = [rgb[0], rgb[1], rgb[2], a]; + let dest = |y: usize, x: usize| (y as i64 - oy) as usize * ow + (x as i64 - ox) as usize; + let fill = l.fill.map(|c| c.map(|v| v as f32)); + match fill { + Some(f) if !l.has_pixels() => { + for y in ys { + for x in xs.clone() { + if let Some(p) = out.get_mut(dest(y, x)) { + *p = [f[0], f[1], f[2], 1.0]; + } + } + } + } + _ => { + // Opaque unless there is an alpha channel. + for y in ys.clone() { + for x in xs.clone() { + if let Some(p) = out.get_mut(dest(y, x)) { + p[3] = 1.0; + } + } + } + let cc = self.mode.color_channels(); + let mut black: Option> = None; + if l.has_pixels() { + for c in &l.channels { + let slot = match c.id { + -1 => 3, + id if id >= 0 && (id as usize) < cc => id as usize, + _ => continue, + }; + let raw = self.channel_bytes(c.offset, c.len, sw, sh, &mut budget)?; + // The fourth CMYK channel is the black plate, not a slot of the result. + let to_black = c.id == 3; + if to_black && black.is_none() { + black = Some(budget.filled(out.len(), 0.0f32)?); + } + // Samples arrive in row-major order of the source. + let (mut sy, mut sx) = (0usize, 0usize); + self.for_each_sample(&raw, sw, sh, |_, v| { + if ys.contains(&sy) && xs.contains(&sx) { + let d = dest(sy, sx); + if to_black { + if let Some(o) = black.as_mut().and_then(|k| k.get_mut(d)) { + *o = v; + } + } else if let Some(p) = out.get_mut(d) { + p[slot] = v; + } + } + sx += 1; + if sx == sw { + sx = 0; + sy += 1; + } + }); + budget.release(raw.len() as u64); + } + } + for y in ys { + for x in xs.clone() { + let d = dest(y, x); + let k = black.as_ref().and_then(|k| k.get(d)).copied().unwrap_or(0.0); + if let Some(p) = out.get_mut(d) { + let [r, g, b] = self.rgb_from([p[0], p[1], p[2], k]); + *p = [r, g, b, p[3]]; + } + } + } + if let Some(k) = black { + budget.release(k.len() as u64 * 4); + } } } } @@ -1015,7 +1229,7 @@ impl Psd { let mc = l.channels.iter().find(|c| c.id == -2); let (mw, mh) = (m.rect.width() as usize, m.rect.height() as usize); let mask = match mc { - Some(c) if mw > 0 && mh > 0 => Some(self.decode_channel(c.offset, c.len, mw, mh)?), + Some(c) if mw > 0 && mh > 0 => Some(self.channel_floats(c.offset, c.len, mw, mh, &mut budget)?), _ => None, }; let def = m.default_color as f32 / 255.0; @@ -1025,13 +1239,15 @@ impl Psd { let px = out_rect.left + x as i32; let inside = py >= m.rect.top && py < m.rect.bottom && px >= m.rect.left && px < m.rect.right; let mut v = match (&mask, inside) { - (Some(mk), true) => mk[(py - m.rect.top) as usize * mw + (px - m.rect.left) as usize], + (Some(mk), true) => mk.get((py - m.rect.top) as usize * mw + (px - m.rect.left) as usize).copied().unwrap_or(def), _ => def, }; if m.inverted { v = 1.0 - v; } - out[y * ow + x][3] *= v; + if let Some(p) = out.get_mut(y * ow + x) { + p[3] *= v; + } } } } @@ -1147,5 +1363,7 @@ fn lab_to_rgb(l: f32, a: f32, b: f32) -> [f32; 3] { [r, g, bb].map(|v| srgb_encode(v.clamp(0.0, 1.0))) } +#[cfg(test)] +mod memory_tests; #[cfg(test)] mod tests; diff --git a/crates/psd/src/memory_tests.rs b/crates/psd/src/memory_tests.rs new file mode 100644 index 000000000..8fbe8307f --- /dev/null +++ b/crates/psd/src/memory_tests.rs @@ -0,0 +1,398 @@ +//! Tests for the decode memory budget, with the previous decoder (one `Vec` per channel and a +//! result vector, no budget) kept as the reference the new one must reproduce bit for bit. + +use super::write::*; +use super::*; + +/// The decoder as it was before the budget: copied unchanged except for the names. +impl Psd { + /// Decode one channel of `w`×`h` samples at `offset` (compression field first). Values 0..1. + fn ref_decode_channel(&self, offset: usize, len: usize, w: usize, h: usize) -> Result> { + let end = offset.checked_add(len).filter(|&e| e <= self.data.len()).ok_or(Error::Truncated)?; + let mut r = Reader::at(&self.data[..end], offset); + let comp = r.u16()?; + decode_pixels(w, h)?; + let rowb = self.row_bytes(w); + let raw = match comp { + 0 => r.bytes(rowb * h)?.to_vec(), + 1 => { + let mut counts = Vec::with_capacity(h.min(r.remaining() / 2)); + for _ in 0..h { + counts.push(if self.psb { r.u32()? as usize } else { r.u16()? as usize }); + } + let mut out = Vec::with_capacity(rowb * h); + for c in counts { + unpack_bits(r.bytes(c)?, rowb, &mut out)?; + } + out + } + 2 | 3 => { + let rest = r.bytes(r.remaining())?; + let mut v = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, rowb * h).map_err(|e| Error::Invalid(format!("zip: {e:?}")))?; + v.resize(rowb * h, 0); + if comp == 3 { + self.unpredict(&mut v, w, h); + } + v + } + c => return Err(Error::Unsupported(format!("compression {c}"))), + }; + Ok(self.ref_samples(&raw, w, h)) + } + + fn ref_samples(&self, raw: &[u8], w: usize, h: usize) -> Vec { + match self.depth { + 1 => { + let rowb = w.div_ceil(8); + let mut out = Vec::with_capacity(w * h); + for y in 0..h { + for x in 0..w { + let b = raw.get(y * rowb + x / 8).copied().unwrap_or(0); + // 1 = black. + out.push(if b & (0x80 >> (x % 8)) != 0 { 0.0 } else { 1.0 }); + } + } + out + } + 16 => raw.as_chunks::<2>().0.iter().take(w * h).map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(), + 32 => raw.as_chunks::<4>().0.iter().take(w * h).map(|c| f32::from_be_bytes([c[0], c[1], c[2], c[3]])).collect(), + _ => raw.iter().take(w * h).map(|&b| b as f32 / 255.0).collect(), + } + } + + /// Convert colour channels (in the document's mode) to RGB. + fn ref_to_rgb(&self, ch: &[Option>], i: usize) -> [f32; 3] { + let g = |k: usize| ch.get(k).and_then(|c| c.as_ref()).map_or(0.0, |c| c[i]); + let rgb = match self.mode { + ColorMode::Rgb => [g(0), g(1), g(2)], + ColorMode::Cmyk => { + // Stored inverted (1 = no ink). + let k = g(3); + [g(0) * k, g(1) * k, g(2) * k] + } + ColorMode::Indexed if self.palette.len() >= 768 => { + let idx = (g(0) * 255.0).round() as usize & 255; + [self.palette[idx] as f32 / 255.0, self.palette[256 + idx] as f32 / 255.0, self.palette[512 + idx] as f32 / 255.0] + } + ColorMode::Lab => lab_to_rgb(g(0) * 100.0, g(1) * 255.0 - 128.0, g(2) * 255.0 - 128.0), + _ => [g(0); 3], + }; + if self.depth == 32 && self.mode != ColorMode::Lab { rgb.map(srgb_encode) } else { rgb } + } + + /// The merged (flattened) image. + fn ref_composite(&self) -> Result { + let (w, h) = (self.width as usize, self.height as usize); + decode_pixels(w, h)?; + let mut r = Reader::at(&self.data, self.merged_offset); + let comp = r.u16()?; + let nch = self.channels as usize; + let cc = self.mode.color_channels(); + let want = (cc + usize::from(self.merged_alpha)).min(nch); + let rowb = self.row_bytes(w); + let mut planes: Vec>> = vec![None; nch]; + match comp { + 0 => { + for p in planes.iter_mut().take(want) { + *p = Some(self.ref_samples(r.bytes(rowb * h)?, w, h)); + } + } + 1 => { + let mut counts = Vec::with_capacity((nch * h).min(r.remaining() / 2)); + for _ in 0..nch * h { + counts.push(if self.psb { r.u32()? as usize } else { r.u16()? as usize }); + } + for (c, p) in planes.iter_mut().enumerate().take(want) { + let mut out = Vec::with_capacity(rowb * h); + for row in 0..h { + unpack_bits(r.bytes(counts[c * h + row])?, rowb, &mut out)?; + } + *p = Some(self.ref_samples(&out, w, h)); + } + } + 2 | 3 => { + let rest = r.bytes(r.remaining())?; + let v = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, want * rowb * h).map_err(|e| Error::Invalid(format!("zip: {e:?}")))?; + for (c, p) in planes.iter_mut().enumerate().take(want) { + let mut plane = v.get(c * rowb * h..(c + 1) * rowb * h).ok_or(Error::Truncated)?.to_vec(); + if comp == 3 { + self.unpredict(&mut plane, w, h); + } + *p = Some(self.ref_samples(&plane, w, h)); + } + } + c => return Err(Error::Unsupported(format!("compression {c}"))), + } + let alpha = if self.merged_alpha && nch > cc { planes[cc].take() } else { None }; + let mut data = Vec::with_capacity(w * h); + for i in 0..w * h { + let [r, g, b] = self.ref_to_rgb(&planes, i); + let a = alpha.as_ref().map_or(1.0, |a| a[i]); + data.push([r, g, b, a]); + } + Ok(Pixels { width: w as u32, height: h as u32, data }) + } + + /// One layer's pixels with its layer mask (and vector-less fill colour) applied to alpha. + /// `canvas`: document-sized with the layer in place; otherwise the layer's own bounds. + fn ref_layer_pixels(&self, index: usize, canvas: bool) -> Result { + let l = self.layers.get(index).ok_or_else(|| Error::Invalid(format!("no layer {index}")))?; + let doc = Rect { top: 0, left: 0, bottom: self.height as i32, right: self.width as i32 }; + // Fill layers often have no pixel data: they cover the canvas (or the mask). + let fill_rect = + if l.fill.is_some() && !l.has_pixels() { l.mask.filter(|m| !m.rect.is_empty() && m.default_color == 0).map_or(doc, |m| m.rect) } else { l.rect }; + let out_rect = if canvas { + doc + } else if fill_rect.is_empty() { + Rect { top: 0, left: 0, bottom: 1, right: 1 } + } else { + fill_rect + }; + let (ow, oh) = (out_rect.width() as usize, out_rect.height() as usize); + let mut out = vec![[0.0f32; 4]; decode_pixels(ow, oh)?]; + let src = fill_rect; + let (sw, sh) = (src.width() as usize, src.height() as usize); + if sw > 0 && sh > 0 { + let mut planes: Vec>> = vec![None; self.mode.color_channels()]; + let mut alpha = None; + if l.has_pixels() { + for c in &l.channels { + match c.id { + -1 => alpha = Some(self.ref_decode_channel(c.offset, c.len, sw, sh)?), + id if id >= 0 && (id as usize) < planes.len() => planes[id as usize] = Some(self.ref_decode_channel(c.offset, c.len, sw, sh)?), + _ => {} + } + } + } + let fill = l.fill.map(|c| c.map(|v| v as f32)); + // The source rows / columns that land inside the output (64-bit: file bounds can span + // the whole i32 range). + let span = |s0: i32, n: usize, o0: i32, on: usize| { + let off = i64::from(o0) - i64::from(s0); + let lo = off.clamp(0, n as i64); + let hi = (off + on as i64).clamp(lo, n as i64); + (lo as usize..hi as usize, off) + }; + let (ys, oy) = span(src.top, sh, out_rect.top, oh); + let (xs, ox) = span(src.left, sw, out_rect.left, ow); + for y in ys { + let dy = (y as i64 - oy) as usize; + for x in xs.clone() { + let dx = (x as i64 - ox) as usize; + let i = y * sw + x; + let (rgb, a) = match fill { + Some(f) if !l.has_pixels() => (f, 1.0), + _ => (self.ref_to_rgb(&planes, i), alpha.as_ref().map_or(1.0, |a| a[i])), + }; + out[dy * ow + dx] = [rgb[0], rgb[1], rgb[2], a]; + } + } + } + // Layer mask: multiplies alpha (also outside the mask rect, by its default colour). + if let Some(m) = l.mask.filter(|m| !m.disabled) { + let mc = l.channels.iter().find(|c| c.id == -2); + let (mw, mh) = (m.rect.width() as usize, m.rect.height() as usize); + let mask = match mc { + Some(c) if mw > 0 && mh > 0 => Some(self.ref_decode_channel(c.offset, c.len, mw, mh)?), + _ => None, + }; + let def = m.default_color as f32 / 255.0; + for y in 0..oh { + let py = out_rect.top + y as i32; + for x in 0..ow { + let px = out_rect.left + x as i32; + let inside = py >= m.rect.top && py < m.rect.bottom && px >= m.rect.left && px < m.rect.right; + let mut v = match (&mask, inside) { + (Some(mk), true) => mk[(py - m.rect.top) as usize * mw + (px - m.rect.left) as usize], + _ => def, + }; + if m.inverted { + v = 1.0 - v; + } + out[y * ow + x][3] *= v; + } + } + } + Ok(Pixels { width: ow as u32, height: oh as u32, data: out }) + } +} + +// --------------------------------------------------------------------------------------------- +// Tests + +fn grad(w: u32, h: u32) -> Vec<[f32; 4]> { + (0..w * h) + .map(|i| [(i % w) as f32 / (w - 1).max(1) as f32, (i / w) as f32 / (h - 1).max(1) as f32, 0.25 + (i % 7) as f32 / 16.0, 0.3 + (i % 5) as f32 / 8.0]) + .collect() +} + +/// Layers covering: full canvas pixels with alpha, a partly off-canvas layer, a masked layer with +/// an inverted mask and a default colour, a solid layer. +fn doc(depth: u16, mode: ColorMode, rle: bool) -> WDoc { + let mut d = WDoc::new(40, 30); + d.depth = depth; + d.mode = mode; + d.rle = rle; + let bg = WLayer::pixels("bg", Rect::new(0, 0, 40, 30), grad(40, 30)); + let off = WLayer::pixels("off", Rect::new(-5, -4, 18, 13), grad(18, 13)); + let solid = WLayer::solid("solid", Rect::new(30, 20, 15, 15), [0.2, 0.6, 0.9, 1.0]); + let mut masked = WLayer::pixels("masked", Rect::new(10, 8, 12, 12), grad(12, 12)); + masked.mask = + Some(WMask { rect: Rect::new(12, 10, 12, 12), data: (0..144).map(|i| (i % 12) as f32 / 11.0).collect(), default_color: 255, disabled: false }); + d.layers = vec![bg, off, solid, masked]; + d +} + +fn assert_same_as_reference(p: &Psd, what: &str) { + assert_eq!(p.composite().unwrap(), p.ref_composite().unwrap(), "composite {what}"); + for i in 0..p.layers.len() { + for canvas in [false, true] { + assert_eq!(p.layer_pixels(i, canvas).unwrap(), p.ref_layer_pixels(i, canvas).unwrap(), "layer {i} canvas={canvas} {what}"); + } + } +} + +#[test] +fn pixels_match_the_one_buffer_per_channel_reference() { + for depth in [8u16, 16] { + for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk] { + for rle in [true, false] { + let p = Psd::parse(write(&doc(depth, mode, rle))).unwrap(); + assert_same_as_reference(&p, &format!("{depth}-bit {mode:?} rle={rle}")); + } + } + } +} + +/// A copy of `bytes` with a different size, depth and merged-image section (`comp`, then `body`). +fn with_merged(bytes: &[u8], size: Option<(u32, u32)>, depth: Option, comp: u16, body: &[u8]) -> Vec { + let offset = Psd::parse(bytes.to_vec()).unwrap().merged_offset; + let mut v = bytes[..offset].to_vec(); + if let Some((w, h)) = size { + v[14..18].copy_from_slice(&h.to_be_bytes()); + v[18..22].copy_from_slice(&w.to_be_bytes()); + } + if let Some(d) = depth { + v[22..24].copy_from_slice(&d.to_be_bytes()); + } + v.extend_from_slice(&comp.to_be_bytes()); + v.extend_from_slice(body); + v +} + +/// Channel planes for a 6×5 image: values that exercise the whole range. +fn planes(nch: usize, bytes_per_sample: usize, n: usize) -> Vec { + let mut v = vec![]; + for c in 0..nch { + for i in 0..n { + let t = ((i * 37 + c * 91) % 251) as f32 / 250.0; + match bytes_per_sample { + 1 => v.push((t * 255.0) as u8), + 2 => v.extend_from_slice(&((t * 65535.0) as u16).to_be_bytes()), + _ => v.extend_from_slice(&(t * 1.5 - 0.1).to_be_bytes()), + } + } + } + v +} + +#[test] +fn merged_image_matches_the_reference_for_32_bit_and_zip() { + for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk] { + let mut small = WDoc::new(6, 5); + small.mode = mode; + small.rle = false; + let base = write(&small); + let info = Psd::parse(base.clone()).unwrap(); + // The merged image only reads the colour channels and, when flagged, the alpha channel. + let nch = (mode.color_channels() + usize::from(info.merged_alpha)).min(info.channels as usize); + let n = 30; + // 32-bit float, raw. + let p = Psd::parse(with_merged(&base, None, Some(32), 0, &planes(nch, 4, n))).unwrap(); + assert_eq!(p.depth, 32); + assert_eq!(p.composite().unwrap(), p.ref_composite().unwrap(), "32-bit {mode:?}"); + // ZIP without and with prediction, 8 and 16 bit; 32-bit ZIP without prediction. + for (depth, bps) in [(8u16, 1usize), (16, 2), (32, 4)] { + let raw = planes(nch, bps, n); + let zip = miniz_oxide::deflate::compress_to_vec_zlib(&raw, 6); + let p = Psd::parse(with_merged(&base, None, Some(depth), 2, &zip)).unwrap(); + assert_eq!(p.composite().unwrap(), p.ref_composite().unwrap(), "zip {depth}-bit {mode:?}"); + if depth == 32 { + continue; + } + // Prediction: each row stores differences to its left neighbour. + let mut pred = raw.clone(); + for plane in pred.chunks_exact_mut(n * bps) { + for row in plane.chunks_exact_mut(6 * bps) { + if bps == 1 { + for i in (1..6).rev() { + row[i] = row[i].wrapping_sub(row[i - 1]); + } + } else { + for i in (1..6).rev() { + let a = u16::from_be_bytes([row[i * 2], row[i * 2 + 1]]); + let b = u16::from_be_bytes([row[i * 2 - 2], row[i * 2 - 1]]); + row[i * 2..i * 2 + 2].copy_from_slice(&a.wrapping_sub(b).to_be_bytes()); + } + } + } + } + let zip = miniz_oxide::deflate::compress_to_vec_zlib(&pred, 6); + let p = Psd::parse(with_merged(&base, None, Some(depth), 3, &zip)).unwrap(); + assert_eq!(p.composite().unwrap(), p.ref_composite().unwrap(), "zip+prediction {depth}-bit {mode:?}"); + } + } +} + +#[test] +fn budget_counts_the_sum_of_the_buffers_of_a_decode() { + let mut b = Budget::new(100); + b.charge(60).unwrap(); + assert!(b.charge(41).is_err(), "60 + 41 is over 100 although each alone fits"); + b.check(40).unwrap(); + assert!(b.check(41).is_err()); + b.release(30); + b.charge(70).unwrap(); + assert!(b.vec::(1).unwrap_err().to_string().contains("memory budget")); + assert!(Budget::new(u64::MAX).vec::(usize::MAX / 2).is_err(), "a size that cannot be reserved is an error, not an abort"); + + // The same for a real decode: the result (16 bytes a pixel) and a channel's bytes together. + let p = Psd::parse(write(&doc(8, ColorMode::Rgb, true))).unwrap(); + let n = 40 * 30; + let peak = 16 * n + n; + assert!(p.composite_with_budget(peak).is_ok()); + for limit in [peak - 1, 16 * n, 16 * n - 1, 1000, 0] { + let e = p.composite_with_budget(limit).unwrap_err(); + assert!(e.to_string().contains("memory budget"), "{limit}: {e}"); + } + // A layer: the result plus its channel; the mask comes after the channels are gone. + let bg = p.layers.iter().position(|l| l.name == "bg").unwrap(); + assert!(p.layer_pixels_with_budget(bg, true, 16 * n + n).is_ok()); + assert!(p.layer_pixels_with_budget(bg, true, 16 * n).unwrap_err().to_string().contains("memory budget")); + assert_eq!(p.layer_pixels_with_budget(bg, true, 16 * n + n).unwrap(), p.layer_pixels(bg, true).unwrap()); +} + +#[test] +fn a_huge_header_with_a_tiny_zip_stream_is_refused_before_it_allocates() { + // 16384 × 16384, RGB + alpha: a few hundred bytes of zlib that inflate to gigabytes of zeros. + let mut small = WDoc::new(2, 2); + small.rle = false; + let base = write(&small); + let zeros = vec![0u8; 1 << 20]; + let stream = miniz_oxide::deflate::compress_to_vec_zlib(&zeros, 9); + let bytes = with_merged(&base, Some((16384, 16384)), None, 2, &stream); + assert!(bytes.len() < 8192); + let p = Psd::parse(bytes).unwrap(); + assert_eq!((p.width, p.height), (16384, 16384)); + let e = p.composite().unwrap_err(); + assert!(e.to_string().contains("memory budget"), "{e}"); + // The same file with the compression types that read straight from the file. + for comp in [0u16, 1, 3] { + let p = Psd::parse(with_merged(&base, Some((16384, 16384)), None, comp, &stream)).unwrap(); + let e = p.composite().unwrap_err(); + assert!(e.to_string().contains("memory budget"), "comp {comp}: {e}"); + } + // And a smaller image under a small budget: the stream is not inflated. + let p = Psd::parse(with_merged(&base, Some((4096, 4096)), None, 2, &stream)).unwrap(); + assert!(p.composite_with_budget(1 << 20).unwrap_err().to_string().contains("memory budget")); +} diff --git a/crates/psd/src/placed.rs b/crates/psd/src/placed.rs index 156a8e271..25490e4da 100644 --- a/crates/psd/src/placed.rs +++ b/crates/psd/src/placed.rs @@ -12,6 +12,10 @@ use crate::descriptor::{DValue, Descriptor}; use crate::{Pixels, SmartObject}; +/// The most pixels a baked smart object may have: 2^24 (256 MiB as float RGBA). The size comes +/// from the file's transform, which is untrusted. +pub const MAX_BAKE_PIXELS: u64 = 1 << 24; + /// A placed layer's warp. #[derive(Clone, Debug, Default, PartialEq)] pub struct Warp { @@ -252,9 +256,27 @@ impl SmartObject { (((bb[2] - bb[0]) * k).ceil().clamp(1.0, 16384.0) as u32, ((bb[3] - bb[1]) * k).ceil().clamp(1.0, 16384.0) as u32) } + /// The scale and size to bake a `w`×`h` content at, wanting `k` pixels per document pixel but + /// never more than [`MAX_BAKE_PIXELS`] pixels, nor more than 4× the `doc` size across either + /// way: a quad corner far outside the document lowers `k` (possibly below 1) instead of asking + /// for a gigantic image. Returns the scale actually used and the size in pixels. + pub fn bake_scale(&self, w: f64, h: f64, k: f64, doc: (u32, u32)) -> (f64, (u32, u32)) { + let bb = self.placed_bounds(w, h); + let (bw, bh) = ((bb[2] - bb[0]).max(1.0), (bb[3] - bb[1]).max(1.0)); + if !(bw.is_finite() && bh.is_finite() && k.is_finite() && k > 0.0) { + return (1.0, (1, 1)); + } + let (max_w, max_h) = ((f64::from(doc.0) * 4.0).max(1.0), (f64::from(doc.1) * 4.0).max(1.0)); + let capped = k.min(max_w / bw).min(max_h / bh).min((MAX_BAKE_PIXELS as f64 / (bw * bh)).sqrt()); + // Rounding up could step over a cap, so a lowered scale rounds down. + let side = |len: f64| if capped < k { (len * capped).floor() } else { (len * capped).ceil() }.clamp(1.0, 16384.0) as u32; + (capped, (side(bw), side(bh))) + } + /// Bake the content (straight RGBA) as placed: warped and mapped by the quad, rendered at /// `k` pixels per document pixel (into `size` pixels when given; see [`Self::placed_size`]). - /// Returns the pixels and their document-space origin. + /// Returns the pixels and their document-space origin. `None` when the content is empty or + /// the baked image would exceed [`MAX_BAKE_PIXELS`]. pub fn render_placed(&self, content: &Pixels, k: f64, size: Option<(u32, u32)>) -> Option<(Pixels, [f64; 2])> { let (cw, ch) = (content.width as f64, content.height as f64); if content.width == 0 || content.height == 0 { @@ -266,6 +288,7 @@ impl SmartObject { None => self.placed_size(cw, ch, k), }; let (ow, oh) = (ow as usize, oh as usize); + let count = ow.checked_mul(oh).filter(|&c| c as u64 <= MAX_BAKE_PIXELS)?; // A forward grid, filled triangle by triangle with inverse barycentric sampling. let n = 48usize; let mut dst = Vec::with_capacity((n + 1) * (n + 1)); @@ -278,7 +301,9 @@ impl SmartObject { src.push(s); } } - let mut out = vec![[0.0f32; 4]; ow * oh]; + let mut out: Vec<[f32; 4]> = Vec::new(); + out.try_reserve_exact(count).ok()?; + out.resize(count, [0.0; 4]); let sample = |x: f64, y: f64| -> [f32; 4] { let (fx, fy) = ((x - 0.5).clamp(0.0, cw - 1.0), (y - 0.5).clamp(0.0, ch - 1.0)); let (x0, y0) = (fx.floor() as usize, fy.floor() as usize); @@ -407,4 +432,34 @@ mod tests { assert!(at(35, 10)[1] > 0.9, "{:?}", at(35, 10)); assert_eq!(at(38, 1)[3], 0.0, "outside the trapezoid"); } + #[test] + fn an_absurd_quad_corner_bakes_within_four_times_the_document_and_2_pow_24_pixels() { + let doc = (1920u32, 1080u32); + for far in [1e4, 1e6, 1e12] { + let s = SmartObject { uuid: "x".into(), quad: [[0.0, 0.0], [far, 5.0], [far, far], [0.0, 20.0]], size: None, warp: None }; + assert!(s.needs_bake()); + // What the importer asks for: the unbounded size is the 16384² clamp, 4 GiB as float RGBA. + assert_eq!(s.placed_size(100.0, 100.0, 4.0), (16384, 16384)); + let (k, (w, h)) = s.bake_scale(100.0, 100.0, 4.0, doc); + assert!(k > 0.0 && k <= 4.0, "{k}"); + assert!(w <= doc.0 * 4 && h <= doc.1 * 4, "{w}x{h}"); + assert!(u64::from(w) * u64::from(h) <= MAX_BAKE_PIXELS, "{w}x{h}"); + } + // A quad inside the document keeps the scale and the size it always had. + let s = SmartObject { uuid: "x".into(), quad: [[0.0, 0.0], [400.0, 50.0], [400.0, 150.0], [0.0, 200.0]], size: None, warp: None }; + let (k, size) = s.bake_scale(100.0, 100.0, 2.0, doc); + assert_eq!((k, size), (2.0, s.placed_size(100.0, 100.0, 2.0))); + // Not-a-number corners stay small too. + let s = SmartObject { uuid: "x".into(), quad: [[f64::NAN; 2]; 4], size: None, warp: None }; + let (_, (w, h)) = s.bake_scale(100.0, 100.0, 4.0, doc); + assert!(u64::from(w) * u64::from(h) <= MAX_BAKE_PIXELS); + } + + #[test] + fn render_placed_refuses_an_output_over_the_pixel_cap() { + let content = Pixels { width: 2, height: 1, data: vec![[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]] }; + let s = SmartObject { uuid: "x".into(), quad: [[0.0, 0.0], [40.0, 5.0], [40.0, 15.0], [0.0, 20.0]], size: None, warp: None }; + assert!(s.render_placed(&content, 1.0, Some((16384, 16384))).is_none()); + assert!(s.render_placed(&content, 1.0, Some((u32::MAX, u32::MAX))).is_none()); + } } From b4e7f8615fc0a04163867eec7bf1c0fff0b7eb6c Mon Sep 17 00:00:00 2001 From: FredApps Date: Mon, 12 Oct 2026 23:30:14 +0200 Subject: [PATCH 120/181] Timeline: the Video-off notice takes the frames/fps line instead of overlapping it (#1118) --- crates/ui-egui/src/panels/timeline.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d4ed05d48..d00837176 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1320,13 +1320,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let tc_rect = Rect::from_min_size(pos2(rect.min.x + 12.0, top + 6.0), vec2(150.0, 24.0)); let tc_resp = ui.interact(tc_rect, egui::Id::new("tl-timecode"), Sense::click_and_drag()); p.text(tc_rect.left_center(), Align2::LEFT_CENTER, &tc, Tokens::semibold(16.0), t.timecode); - // Audio-only preview (Video off in the Preview panel): the current time stays put, so the - // header says why and where the sound is (#1118). - if let Some(af) = app.video_preview_off() { - let at = af.map(|f| format!(" · {}", effectcraft_engine::time::format_timecode_ae(f + fr.frame_at(comp.display_start), fr, false))).unwrap_or_default(); - let msg = format!("{}{at}", crate::i18n::tr("Video preview off: audio only")); - p.text(pos2(tc_rect.min.x, top + 40.0), Align2::LEFT_CENTER, msg, Tokens::ui(11.0), t.cache_green); - } // Under it the other style (frames under timecode, timecode under frames) and the rate. let settings = &app.session.project.settings; let other = if settings.time_display == effectcraft_engine::project::TimeDisplayStyle::Timecode { @@ -1334,7 +1327,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } else { effectcraft_engine::time::format_timecode_ae(fr.frame_at(time + comp.display_start), fr, false) }; - p.text(pos2(tc_rect.min.x, tc_rect.max.y + 8.0), Align2::LEFT_CENTER, format!("{other} ({:.2} fps)", fr.as_f64()), Tokens::ui(10.5), t.text_faint); + // Audio-only preview (Video off in the Preview panel): the current time stays put, so that + // line says why and where the sound is instead (#1118). + if let Some(af) = app.video_preview_off() { + let at = af.map(|f| format!(" · {}", effectcraft_engine::time::format_timecode_ae(f + fr.frame_at(comp.display_start), fr, false))).unwrap_or_default(); + let msg = format!("{}{at}", crate::i18n::tr("Video preview off: audio only")); + p.text(pos2(tc_rect.min.x, tc_rect.max.y + 8.0), Align2::LEFT_CENTER, msg, Tokens::ui(10.5), t.cache_green); + } else { + p.text(pos2(tc_rect.min.x, tc_rect.max.y + 8.0), Align2::LEFT_CENTER, format!("{other} ({:.2} fps)", fr.as_f64()), Tokens::ui(10.5), t.text_faint); + } app.auto.add("timeline.timecode", tc_rect, &tc); super::toggle_time_display(app, &tc_resp); if tc_resp.dragged() { From 7643f803e0f4e3956847ebf21f785e78a0b8747f Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 12 Oct 2026 17:37:29 -0400 Subject: [PATCH 121/181] Keyframes: moving keys keeps the selection order keys.move (and nudging) rebuilt the key selection in layer and property order, so the order the keys were selected in was lost after a move. A Quick Offset that follows staggers layers in that order (#1012). The move now goes through move_key_refs, which maps each selected key to its new time and returns the selection in its original order, with the same collisions (a key landing on another replaces it), roving retiming and locked-layer handling as before. key_gesture is the drag-step logic (start again from the keys and project as they were before the drag) split out of shift_keys so other key drags can share it. Test: moving_keys_keeps_the_selection_order (fails before: the keys come back sorted by layer). --- crates/engine/src/commands/prop.rs | 115 +++++++++++------- crates/engine/src/tests_key_metadata_order.rs | 13 ++ 2 files changed, 81 insertions(+), 47 deletions(-) diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 64c166fd5..903f9e131 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -862,17 +862,78 @@ pub(crate) fn edit_keys(s: &mut Session, label: &str, merge: Option<&str>, f: im }) } +/// The keys a key drag's step applies to, the drag's total and the project as it was before the +/// drag. Steps of one drag (one `merge` key) start again from the keys as they were then, so a key +/// passed over survives; `d` adds to the drag's total, or is that total (`absolute`). +pub(crate) fn key_gesture( + s: &Session, + merge: Option<&str>, + d: Tick, + absolute: bool, +) -> (Vec, Tick, Option>) { + let gesture = merge.is_some_and(|m| s.history.merge_key.as_deref() == Some(m)) && s.key_move.as_ref().map(|g| g.merge.as_str()) == merge; + match (&s.key_move, s.history.undo.last()) { + (Some(g), Some((_, base))) if gesture => (g.from.clone(), if absolute { d } else { g.total + d }, Some(base.clone())), + _ => (s.state.selected_keys.clone(), d, None), + } +} + +/// Move keys `from` to the layer times `to` gives (from a key's layer and layer time). A key +/// landing on another key of its property replaces it, roving keys follow their neighbours and +/// keys of locked layers stay. Returns the keys' new references in `from`'s order: the selection +/// keeps the order it was made in. +pub(crate) fn move_key_refs(comp: &mut effectcraft_project::Comp, from: &[KeyRef], to: impl Fn(&Layer, Tick) -> Result) -> Result> { + let mut remapped: std::collections::HashMap = Default::default(); + let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec> = Default::default(); + for k in from { + groups.entry((k.layer, k.prop)).or_default().push(k.time); + } + for ((lid, uid), times) in groups { + let Some(l) = comp.layer_mut(lid) else { continue }; + let at = |time| KeyRef { layer: lid, prop: uid, time }; + if l.switches.locked { + remapped.extend(times.iter().map(|t| (at(*t), at(*t)))); + continue; + } + let moves: Vec<(Tick, Tick)> = times.iter().map(|t| Ok((*t, to(l, *t)?))).collect::>()?; + let Some(pr) = l.props.find_mut(uid) else { continue }; + // Take the moving keys out, then put them back at their new times. + let mut moved = vec![]; + for (old, new) in &moves { + if let Some(i) = key_at(&pr.keys, *old) { + let mut k = pr.keys.remove(i); + k.time = *new; + moved.push((*old, k)); + } + } + // Target time -> the key's time before the move. + let mut targets: std::collections::HashMap = Default::default(); + for (old, k) in moved { + targets.insert(k.time, old); + match pr.keys.binary_search_by(|x| x.time.cmp(&k.time)) { + Ok(j) => pr.keys[j] = k, + Err(j) => pr.keys.insert(j, k), + } + } + // Resolve stored indices before roving keys change their times. + let tracked: Vec<(Tick, usize)> = targets.into_iter().filter_map(|(t, old)| key_at(&pr.keys, t).map(|i| (old, i))).collect(); + effectcraft_keyframe::retime_roving(&mut pr.keys, pr.spatial); + for (old, i) in tracked { + if let Some(key) = pr.keys.get(i) { + remapped.insert(at(old), at(key.time)); + } + } + } + let mut seen = std::collections::HashSet::new(); + Ok(from.iter().filter_map(|k| remapped.get(k).copied()).filter(|k| seen.insert(*k)).collect()) +} + /// Move the selected keys by `d` comp seconds, snapped to the comp's frames in comp time (keys /// of time-stretched layers move with the pointer, not by the layer-time distance). Keys of /// locked layers stay. A key landing on another key of its property replaces it. pub(crate) fn shift_keys(s: &mut Session, label: &str, merge: Option<&str>, d: Tick) -> Result { let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; - // A drag (steps with one merge key) starts again from the keys before it each step. - let gesture = merge.is_some_and(|m| s.history.merge_key.as_deref() == Some(m)) && s.key_move.as_ref().map(|g| g.merge.as_str()) == merge; - let (from, total, base) = match (&s.key_move, s.history.undo.last()) { - (Some(g), Some((_, base))) if gesture => (g.from.clone(), g.total + d, Some(base.clone())), - _ => (s.state.selected_keys.clone(), d, None), - }; + let (from, total, base) = key_gesture(s, merge, d, false); s.key_move = merge.map(|m| crate::KeyMove { merge: m.to_string(), from: from.clone(), total }); s.edit(label, merge, |proj, st| { if let Some(b) = base { @@ -880,47 +941,7 @@ pub(crate) fn shift_keys(s: &mut Session, label: &str, merge: Option<&str>, d: T } let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let fr = comp.frame_rate; - let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec> = Default::default(); - for k in &from { - groups.entry((k.layer, k.prop)).or_default().push(k.time); - } - let mut sel = vec![]; - for ((lid, uid), times) in groups { - let Some(l) = comp.layer_mut(lid) else { continue }; - let to = |t: Tick| l.layer_time(fr.snap_nearest(l.comp_time(t) + total)); - let moves: Vec<(Tick, Tick)> = times.iter().map(|t| (*t, to(*t))).collect(); - if l.switches.locked { - sel.extend(times.iter().map(|t| KeyRef { layer: lid, prop: uid, time: *t })); - continue; - } - let Some(pr) = l.props.find_mut(uid) else { continue }; - // Take the moving keys out, then put them back at their new times. - let mut moved = vec![]; - for (old, new) in &moves { - if let Some(i) = key_at(&pr.keys, *old) { - let mut k = pr.keys.remove(i); - k.time = *new; - moved.push(k); - } - } - for k in moved { - sel.push(KeyRef { layer: lid, prop: uid, time: k.time }); - match pr.keys.binary_search_by(|x| x.time.cmp(&k.time)) { - Ok(j) => pr.keys[j] = k, - Err(j) => pr.keys.insert(j, k), - } - } - // Roving keys follow their neighbours: keep the selection on them. - let before: Vec = pr.keys.iter().map(|k| k.time).collect(); - if effectcraft_keyframe::retime_roving(&mut pr.keys, pr.spatial) { - for r in sel.iter_mut().filter(|r| r.layer == lid && r.prop == uid) { - if let Some(i) = before.iter().position(|t| *t == r.time) { - r.time = pr.keys[i].time; - } - } - } - } - st.selected_keys = sel; + st.selected_keys = move_key_refs(comp, &from, |l, t| Ok(l.layer_time(fr.snap_nearest(l.comp_time(t) + total))))?; Ok(json!(st.selected_keys.len())) }) } diff --git a/crates/engine/src/tests_key_metadata_order.rs b/crates/engine/src/tests_key_metadata_order.rs index fe2337c2c..18d4a1a9c 100644 --- a/crates/engine/src/tests_key_metadata_order.rs +++ b/crates/engine/src/tests_key_metadata_order.rs @@ -111,3 +111,16 @@ fn attribute_edit_deduplicates_legacy_refs_and_drops_stale_refs_without_snapping let opacity = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap(); assert!(opacity.keys.iter().all(|k| k.out_interp == Interp::Linear)); } + +#[test] +fn moving_keys_keeps_the_selection_order() { + let (mut s, first) = setup(); + let second = s.execute_checked("layer.newSolid", json!({"name": "Other", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap(); + for layer in [first, second] { + add(&mut s, layer, "transform/opacity", 0, json!(0)); + } + let (a, b) = (uid(&s, first, "transform/opacity"), uid(&s, second, "transform/opacity")); + s.execute_checked("keys.select", json!({"keys": [{"layer": second, "prop": b, "time": 0}, {"layer": first, "prop": a, "time": 0}]})).unwrap(); + s.execute_checked("keys.move", json!({"delta": 2.0 / 30.0})).unwrap(); + assert_eq!(s.state.selected_keys, vec![key(second, b, 2), key(first, a, 2)], "not sorted by layer"); +} From 28e9e5229ecb3069c5152a579ad6ceec57ca1034 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 12 Oct 2026 17:37:31 -0400 Subject: [PATCH 122/181] Quick Offset: layer.quickOffset and keys.quickOffset commands (#1012) After Effects 25.4's Quick Offset staggers selected layers, or each layer's selected keyframes, in the order they were selected: the first stays, the last moves by the drag and the ones between by equal steps, landing on subframes. layer.quickOffset {delta, layers?} moves each layer's start, in and out point by total * i / (n - 1), worked out once in ticks with integer arithmetic, so 1 frame over 4 layers gives 0, 1/3, 2/3 and 1 frame exactly. keys.quickOffset {delta} does the same per layer for the selected keys, the layers in the order their first key was selected; every selected key of a layer, across its properties, moves by that layer's comp-time offset through its start and stretch (reversed too), keeping values, interpolation, easing and labels. Locked layers are skipped and at least two layers are needed. With merge and fromStart, delta is the whole drag and each step starts again from before it, so the drag is one undo step and a key passed over survives. A delta or a destination out of the range of times is an error, not a clamped move. Docs: agents.md; architecture.md notes Quick Offset as the exception to times snapping to frames. --- crates/engine/src/commands/mod.rs | 2 + crates/engine/src/commands/quick_offset.rs | 144 ++++++++++++ crates/engine/src/lib.rs | 2 + crates/engine/src/tests_quick_offset.rs | 254 +++++++++++++++++++++ docs/agents.md | 6 +- docs/architecture.md | 4 +- 6 files changed, 410 insertions(+), 2 deletions(-) create mode 100644 crates/engine/src/commands/quick_offset.rs create mode 100644 crates/engine/src/tests_quick_offset.rs diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 99b2d642a..fc027c7bc 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -49,6 +49,7 @@ mod prop_groups; mod proxy; pub mod puppet; mod query; +mod quick_offset; mod relink; mod render_queue; pub(crate) mod rig3d; @@ -190,6 +191,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(focus::specs()); v.extend(key_labels::specs()); v.extend(key_transform::specs()); + v.extend(quick_offset::specs()); v.extend(file_more::specs()); v.extend(relink::specs()); v.extend(lottie::specs()); diff --git a/crates/engine/src/commands/quick_offset.rs b/crates/engine/src/commands/quick_offset.rs new file mode 100644 index 000000000..aeda6c781 --- /dev/null +++ b/crates/engine/src/commands/quick_offset.rs @@ -0,0 +1,144 @@ +//! Quick Offset (After Effects 25.4): Ctrl/Cmd+Alt dragging selected layers or keyframes in the +//! Timeline staggers them in the order they were selected. The first stays, the last moves by the +//! whole drag and the ones between spread evenly, so they land on subframes; each layer's +//! selected keys move together. + +use std::collections::HashMap; + +use effectcraft_project::LayerId; +use effectcraft_time::{TICKS_PER_SECOND, Tick}; +use serde_json::{Value, json}; + +use super::prop::{key_gesture, move_key_refs}; +use super::{CommandSpec, b_p, bad, f_p, has_keys, has_layers, layers_p, merge_p}; +use crate::{EngineError, Result, Session, cmd}; + +/// `delta` (comp seconds) in ticks; out of range is an error, not a clamped drag. +fn delta_p(p: &Value, name: &str) -> Result { + let d = f_p(p, "delta").ok_or_else(|| bad(name, "missing `delta` (comp seconds)"))?; + let t = (d * TICKS_PER_SECOND as f64).round(); + if !t.is_finite() || t.abs() > Tick::MAX.0 as f64 { + return Err(bad(name, "`delta` is out of range")); + } + Ok(Tick(t as i64)) +} + +/// How far each of `n` layers moves, in order, when the last moves by `total`: `total × i / (n − 1)` +/// to the nearest tick, so the steps between them are equal. +fn shares(total: Tick, n: usize) -> Vec { + let last = n.saturating_sub(1).max(1) as i128; + (0..n) + .map(|i| { + let x = total.0 as i128 * i as i128; + Tick(((x + x.signum() * (last / 2)) / last) as i64) + }) + .collect() +} + +/// `t + d`, or an error (`what` names the time) when that leaves the range of times. +fn shifted(t: Tick, d: Tick, name: &str, what: &str) -> Result { + let v = t.0 as i128 + d.0 as i128; + if v < Tick::MIN.0 as i128 || v > Tick::MAX.0 as i128 { + return Err(bad(name, format!("{what} would move out of range"))); + } + Ok(Tick(v as i64)) +} + +/// The layers to stagger, in order: unlocked, each once. At least two. +fn order(name: &str, layers: impl IntoIterator, unlocked: impl Fn(LayerId) -> bool) -> Result> { + let mut out: Vec = vec![]; + for l in layers { + if unlocked(l) && !out.contains(&l) { + out.push(l); + } + } + if out.len() < 2 { + return Err(bad(name, "select at least two unlocked layers (or keys on two layers), in the order to offset them")); + } + Ok(out) +} + +/// `layer.quickOffset`: move the layers (`layers`, else the selection, in that order) by their +/// share of `delta`, the last layer's move. With `merge` and `fromStart`, `delta` is the whole drag +/// and every step applies to the layers as they were before it. +fn layers(s: &mut Session, p: &Value) -> Result { + const NAME: &str = "layer.quickOffset"; + let d = delta_p(p, NAME)?; + let (cid, ids) = layers_p(s, p)?; + let merge = merge_p(p); + let gesture = b_p(p, "fromStart").unwrap_or(false) && merge.is_some_and(|m| s.history.merge_key.as_deref() == Some(m)); + let base = s.history.undo.last().filter(|_| gesture).map(|(_, b)| b.clone()); + let start = base.clone().unwrap_or_else(|| s.project.clone()); + let comp = start.comp(cid).ok_or(EngineError::NoComp)?; + let order = order(NAME, ids, |id| comp.layer(id).is_some_and(|l| !l.switches.locked))?; + let moves: Vec<(LayerId, Tick)> = order.iter().copied().zip(shares(d, order.len())).collect(); + s.edit("Quick Offset", merge, |proj, _| { + if let Some(b) = &base { + *proj = (**b).clone(); + } + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + for (id, d) in &moves { + let l = comp.layer_mut(*id).ok_or(EngineError::NoComp)?; + let what = format!("layer \"{}\"", l.name); + let times = (shifted(l.start_time, *d, NAME, &what)?, shifted(l.in_point, *d, NAME, &what)?, shifted(l.out_point, *d, NAME, &what)?); + (l.start_time, l.in_point, l.out_point) = times; + } + Ok(json!({"layers": moves.iter().map(|(id, d)| json!({"layer": id.0, "offset": d.seconds()})).collect::>()})) + }) +} + +/// `keys.quickOffset`: the selected keys of each layer move together by the layer's share of +/// `delta` (comp seconds, the last layer's move), the layers in the order their first key was +/// selected. Like `keys.move`, steps sharing `merge` are one drag; with `fromStart`, `delta` is the +/// whole drag. +fn keys(s: &mut Session, p: &Value) -> Result { + const NAME: &str = "keys.quickOffset"; + let d = delta_p(p, NAME)?; + let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; + let merge = merge_p(p); + let (from, total, base) = key_gesture(s, merge, d, b_p(p, "fromStart").unwrap_or(false)); + let start = base.clone().unwrap_or_else(|| s.project.clone()); + let comp = start.comp(cid).ok_or(EngineError::NoComp)?; + let order = order(NAME, from.iter().map(|k| k.layer), |id| comp.layer(id).is_some_and(|l| !l.switches.locked))?; + let by_layer: HashMap = order.iter().copied().zip(shares(total, order.len())).collect(); + s.key_move = merge.map(|m| crate::KeyMove { merge: m.to_string(), from: from.clone(), total }); + s.edit("Quick Offset Keyframes", merge, |proj, st| { + if let Some(b) = &base { + *proj = (**b).clone(); + } + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + st.selected_keys = move_key_refs(comp, &from, |l, t| { + let Some(d) = by_layer.get(&l.id) else { return Ok(t) }; + let what = format!("a key of \"{}\"", l.name); + let at = l.layer_time(shifted(l.comp_time(t), *d, NAME, &what)?); + if at < Tick::MIN || at > Tick::MAX { + return Err(bad(NAME, format!("{what} would move out of range"))); + } + Ok(at) + })?; + Ok(json!({"keys": st.selected_keys.len(), "layers": order.iter().map(|id| json!({"layer": id.0, "offset": by_layer.get(id).copied().unwrap_or_default().seconds()})).collect::>()})) + }) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "layer.quickOffset", + "Quick Offset", + [], + None, + "{delta (comp s: the last layer's move), layers? (in order; default the selection), merge?, fromStart?: bool (with merge: delta is the whole drag)}", + has_layers, + layers + ), + cmd!( + "keys.quickOffset", + "Quick Offset Keyframes", + [], + None, + "{delta (comp s: the last layer's keys' move), merge?, fromStart?: bool (with merge: delta is the whole drag)}", + has_keys, + keys + ), + ] +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a6243e44f..787df4f70 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1158,6 +1158,8 @@ mod tests_project_items; #[cfg(test)] mod tests_proxy; #[cfg(test)] +mod tests_quick_offset; +#[cfg(test)] mod tests_rig3d; #[cfg(test)] mod tests_sequence; diff --git a/crates/engine/src/tests_quick_offset.rs b/crates/engine/src/tests_quick_offset.rs new file mode 100644 index 000000000..7ccf75f1b --- /dev/null +++ b/crates/engine/src/tests_quick_offset.rs @@ -0,0 +1,254 @@ +//! Quick Offset (#1012): `layer.quickOffset` and `keys.quickOffset` stagger layers and each layer's +//! selected keys in selection order, onto subframes, as one undo step. + +use effectcraft_keyframe::Interp; +use effectcraft_project::{Layer, LayerId}; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +use crate::{KeyRef, Session}; + +/// One frame at 30 fps. +const F: i64 = 8_467_200_000; + +fn setup(n: usize) -> (Session, Vec) { + let mut s = Session::default(); + s.execute_checked("comp.new", json!({"width": 64, "height": 64, "frameRate": 30, "duration": 10})).unwrap(); + let ids = + (0..n).map(|i| s.execute_checked("layer.newSolid", json!({"name": format!("L{i}"), "color": "#fff"})).unwrap()["layer"].as_u64().unwrap()).collect(); + (s, ids) +} + +fn layer(s: &Session, id: u64) -> &Layer { + s.active_comp().unwrap().layer(LayerId(id)).unwrap() +} + +/// (start, in, out) in ticks. +fn timing(s: &Session, id: u64) -> (i64, i64, i64) { + let l = layer(s, id); + (l.start_time.0, l.in_point.0, l.out_point.0) +} + +/// Click the first, Ctrl/Cmd-click the rest. +fn select(s: &mut Session, ids: &[u64]) { + for (i, id) in ids.iter().enumerate() { + s.execute_checked("layer.select", json!({"layers": [id], "add": i > 0})).unwrap(); + } +} + +fn add_key(s: &mut Session, id: u64, path: &str, seconds: f64, value: Value) { + s.execute_checked("prop.addKey", json!({"layer": id, "path": path, "time": seconds, "value": value})).unwrap(); +} + +fn select_keys(s: &mut Session, keys: &[(u64, &str, f64)]) { + for (i, (id, path, t)) in keys.iter().enumerate() { + s.execute_checked("keys.select", json!({"keys": [{"layer": id, "path": path, "time": t}], "add": i > 0})).unwrap(); + } +} + +/// The stored key times (ticks) of a property. +fn key_times(s: &Session, id: u64, path: &str) -> Vec { + layer(s, id).props.prop(path).unwrap().keys.iter().map(|k| k.time.0).collect() +} + +fn uid(s: &Session, id: u64, path: &str) -> u64 { + layer(s, id).props.prop(path).unwrap().uid +} + +#[test] +fn layers_stagger_in_selection_order_onto_subframes_as_one_undo() { + let (mut s, ids) = setup(4); + let order = [ids[2], ids[0], ids[3], ids[1]]; + select(&mut s, &order); + let before = s.project.clone(); + let reply = s.execute_checked("layer.quickOffset", json!({"delta": 1.0 / 30.0})).unwrap(); + // The first selected stays, the last moves the whole frame, the ones between a third each. + for (i, id) in order.iter().enumerate() { + let d = F * i as i64 / 3; + let (st, inp, out) = timing(&s, *id); + assert_eq!((st, inp, out - inp), (d, d, 10 * 30 * F), "layer {i} in selection order"); + } + assert_eq!(reply["layers"].as_array().unwrap().len(), 4); + // Negative drags mirror it. + s.execute_checked("layer.quickOffset", json!({"delta": -2.0 / 30.0})).unwrap(); + for (i, id) in order.iter().enumerate() { + assert_eq!(timing(&s, *id).0, -F * i as i64 / 3, "layer {i} after dragging back two frames"); + } + s.execute_checked("edit.undo", json!({})).unwrap(); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert!(s.project.same_content(&before), "two offsets, two undo steps"); + s.execute_checked("edit.redo", json!({})).unwrap(); + assert_eq!(timing(&s, order[3]).0, F); +} + +#[test] +fn a_drag_restarts_from_the_layers_before_it_and_is_one_undo_step() { + let (mut s, ids) = setup(3); + select(&mut s, &ids); + // An offset layer, a stretched one and a reversed one move their whole timing; their keys + // (in layer time) come along untouched. + s.execute_checked("layer.timing", json!({"layers": [ids[0]], "start": 1.0})).unwrap(); + s.execute_checked("layer.timeStretch", json!({"layers": [ids[1]], "percent": 200})).unwrap(); + s.execute_checked("layer.timeStretch", json!({"layers": [ids[2]], "op": "reverse"})).unwrap(); + add_key(&mut s, ids[2], "transform/opacity", 1.0, json!(50)); + let keys = key_times(&s, ids[2], "transform/opacity"); + let start: Vec<_> = ids.iter().map(|id| timing(&s, *id)).collect(); + let before = s.project.clone(); + for frames in [2.0, 5.0, -3.0, 2.0] { + s.execute_checked("layer.quickOffset", json!({"delta": frames / 30.0, "merge": "qo", "fromStart": true})).unwrap(); + } + for (i, id) in ids.iter().enumerate() { + let d = 2 * F * i as i64 / 2; + let (a, b, c) = start[i]; + assert_eq!(timing(&s, *id), (a + d, b + d, c + d), "layer {i}: only the last step's whole drag counts"); + } + assert_eq!(key_times(&s, ids[2], "transform/opacity"), keys); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert!(s.project.same_content(&before), "the drag is one undo step"); +} + +#[test] +fn locked_layers_bad_arguments_and_out_of_range_moves_change_nothing() { + let (mut s, ids) = setup(3); + select(&mut s, &ids); + // A locked layer is skipped: the other two are first and last. + s.execute_checked("layer.setSwitch", json!({"layers": [ids[1]], "switch": "locked", "value": true})).unwrap(); + s.execute_checked("layer.quickOffset", json!({"layers": ids, "delta": 0.1})).unwrap(); + assert_eq!((timing(&s, ids[0]).0, timing(&s, ids[1]).0), (0, 0)); + assert_eq!(timing(&s, ids[2]).0, Tick::from_seconds_f64(0.1).0); + let before = (s.project.clone(), s.revision); + for p in [ + json!({"layers": [ids[0]], "delta": 0.1}), + json!({"layers": [ids[0], ids[1]], "delta": 0.1}), + json!({"layers": ids}), + json!({"layers": ids, "delta": "far"}), + json!({"layers": ids, "delta": 1e12}), + json!({"layers": [ids[0], 999], "delta": 0.1}), + ] { + assert!(s.execute_checked("layer.quickOffset", p.clone()).is_err(), "{p}"); + } + assert!(s.project.same_content(&before.0) && s.revision == before.1, "refused calls change nothing"); + // The last layer's destination is past the end of time: nothing moves. + s.execute_checked("layer.timing", json!({"layers": [ids[2]], "start": 9.0e6})).unwrap(); + let before = s.project.clone(); + assert!(s.execute_checked("layer.quickOffset", json!({"layers": [ids[0], ids[2]], "delta": 1.0e5})).is_err()); + assert!(s.project.same_content(&before)); +} + +#[test] +fn keys_stagger_per_layer_in_the_order_their_layers_were_selected() { + let (mut s, ids) = setup(3); + for id in &ids { + add_key(&mut s, *id, "transform/opacity", 0.0, json!(0)); + add_key(&mut s, *id, "transform/opacity", 1.0, json!(100)); + } + add_key(&mut s, ids[0], "transform/position", 0.5, json!([10, 20])); + s.execute_checked("keys.select", json!({"keys": [{"layer": ids[0], "path": "transform/opacity", "time": 1.0}]})).unwrap(); + s.execute_checked("keys.interpolation", json!({"interpolation": "hold"})).unwrap(); + // Layer 1's keys first, then layer 0's two properties, then layer 2's. + let picks = [ + (ids[1], "transform/opacity", 0.0), + (ids[0], "transform/opacity", 1.0), + (ids[1], "transform/opacity", 1.0), + (ids[0], "transform/position", 0.5), + (ids[2], "transform/opacity", 1.0), + ]; + select_keys(&mut s, &picks); + let before = s.project.clone(); + let reply = s.execute_checked("keys.quickOffset", json!({"delta": 3.0 / 30.0})).unwrap(); + assert_eq!(reply["keys"], 5); + let sec = |t: f64| Tick::from_seconds_f64(t).0; + assert_eq!(key_times(&s, ids[1], "transform/opacity"), vec![0, sec(1.0)], "the first layer stays"); + assert_eq!(key_times(&s, ids[0], "transform/opacity"), vec![0, sec(1.0) + 3 * F / 2], "the middle layer: 1.5 frames"); + assert_eq!(key_times(&s, ids[0], "transform/position"), vec![sec(0.5) + 3 * F / 2], "both its properties move as one group"); + assert_eq!(key_times(&s, ids[2], "transform/opacity"), vec![0, sec(1.0) + 3 * F], "the last layer: the whole drag"); + // The keys keep their values and interpolation, and the selection its order. + let moved = layer(&s, ids[0]).props.prop("transform/opacity").unwrap().keys[1].clone(); + assert_eq!((moved.value.components(), moved.out_interp), (vec![100.0], Interp::Hold)); + let order: Vec<(u64, u64)> = s.state.selected_keys.iter().map(|k| (k.layer.0, k.prop)).collect(); + let want: Vec<(u64, u64)> = picks.iter().map(|(id, path, _)| (*id, uid(&s, *id, path))).collect(); + assert_eq!(order, want); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert!(s.project.same_content(&before)); +} + +#[test] +fn keys_of_stretched_and_reversed_layers_follow_the_comp_time_offset() { + let (mut s, ids) = setup(3); + for id in &ids { + add_key(&mut s, *id, "transform/opacity", 1.0, json!(10)); + } + s.execute_checked("layer.timeStretch", json!({"layers": [ids[1]], "percent": 200})).unwrap(); + s.execute_checked("layer.timeStretch", json!({"layers": [ids[2]], "op": "reverse"})).unwrap(); + let comp_time = |s: &Session, id: u64| { + let l = layer(s, id); + l.comp_time(l.props.prop("transform/opacity").unwrap().keys[0].time).0 + }; + let before: Vec = ids.iter().map(|id| comp_time(&s, *id)).collect(); + select_keys(&mut s, &ids.iter().map(|id| (*id, "transform/opacity", 1.0)).collect::>()); + s.execute_checked("keys.quickOffset", json!({"delta": 4.0 / 30.0})).unwrap(); + for (i, id) in ids.iter().enumerate() { + let moved = comp_time(&s, *id) - before[i]; + assert!((moved - 2 * F * i as i64).abs() <= 1, "layer {i}: moved {moved} ticks in comp time"); + } +} + +#[test] +fn a_key_drag_over_another_key_and_back_keeps_it() { + let (mut s, ids) = setup(2); + for id in &ids { + add_key(&mut s, *id, "transform/opacity", 0.0, json!(0)); + } + add_key(&mut s, ids[1], "transform/opacity", 0.5, json!(50)); + select_keys(&mut s, &[(ids[0], "transform/opacity", 0.0), (ids[1], "transform/opacity", 0.0)]); + let before = s.project.clone(); + s.execute_checked("keys.quickOffset", json!({"delta": 0.5, "merge": "qk", "fromStart": true})).unwrap(); + assert_eq!(key_times(&s, ids[1], "transform/opacity").len(), 1, "the dragged key replaces the one it lands on"); + s.execute_checked("keys.quickOffset", json!({"delta": 0.0, "merge": "qk", "fromStart": true})).unwrap(); + assert!(s.project.same_content(&before), "dragged back, the key passed over is there again"); + assert_eq!(s.state.selected_keys.len(), 2); +} + +#[test] +fn keys_need_two_unlocked_layers() { + let (mut s, ids) = setup(3); + for id in &ids { + add_key(&mut s, *id, "transform/opacity", 0.0, json!(0)); + add_key(&mut s, *id, "transform/opacity", 1.0, json!(100)); + } + select_keys(&mut s, &[(ids[0], "transform/opacity", 0.0), (ids[0], "transform/opacity", 1.0)]); + let before = s.project.clone(); + assert!(s.execute_checked("keys.quickOffset", json!({"delta": 0.1})).is_err(), "keys on one layer"); + // Locked after selecting: that layer's keys stay; the other two still stagger. + select_keys(&mut s, &ids.iter().map(|id| (*id, "transform/opacity", 1.0)).collect::>()); + s.execute_checked("layer.setSwitch", json!({"layers": [ids[1]], "switch": "locked", "value": true})).unwrap(); + s.execute_checked("keys.quickOffset", json!({"delta": 0.1})).unwrap(); + let sec = |t: f64| Tick::from_seconds_f64(t).0; + assert_eq!(key_times(&s, ids[0], "transform/opacity")[1], sec(1.0)); + assert_eq!(key_times(&s, ids[1], "transform/opacity")[1], sec(1.0)); + assert_eq!(key_times(&s, ids[2], "transform/opacity")[1], sec(1.1)); + assert!(s.state.selected_keys.contains(&KeyRef { layer: LayerId(ids[1]), prop: uid(&s, ids[1], "transform/opacity"), time: Tick(sec(1.0)) })); + assert!(!before.same_content(&s.project)); +} + +#[test] +fn roving_keys_follow_and_the_selection_stays_on_the_moved_keys() { + let (mut s, ids) = setup(2); + for (t, x) in [(0.0, 0), (1.0, 50), (2.0, 100)] { + add_key(&mut s, ids[0], "transform/position", t, json!([x, 0])); + } + add_key(&mut s, ids[1], "transform/position", 2.0, json!([0, 0])); + s.execute_checked("keys.select", json!({"keys": [{"layer": ids[0], "path": "transform/position", "time": 1.0}]})).unwrap(); + s.execute_checked("keys.interpolation", json!({"roving": true})).unwrap(); + // Only the last key of the roving layer is selected, with a key on the other layer first. + select_keys(&mut s, &[(ids[1], "transform/position", 2.0), (ids[0], "transform/position", 2.0)]); + s.execute_checked("keys.quickOffset", json!({"delta": 1.0})).unwrap(); + let pos = layer(&s, ids[0]).props.prop("transform/position").unwrap(); + let times: Vec = pos.keys.iter().map(|k| k.time.0).collect(); + assert_eq!((times[0], times[2]), (0, Tick::from_seconds_f64(3.0).0)); + assert!(times[1] > 0 && times[1] < times[2], "the roving key retimed between its neighbours: {times:?}"); + for k in &s.state.selected_keys { + assert!(layer(&s, k.layer.0).props.find(k.prop).unwrap().keys.iter().any(|x| x.time == k.time), "{k:?} points at a key"); + } + assert_eq!(s.state.selected_keys.iter().map(|k| k.layer.0).collect::>(), vec![ids[1], ids[0]]); +} diff --git a/docs/agents.md b/docs/agents.md index 01738edd1..b63e69d7c 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -248,7 +248,11 @@ build real projects through these interfaces; they are worked examples of everyt / `selectPrevious` / `selectFollowing`, `keys.move {delta, merge}` (steps sharing a merge key are one drag: each applies to the keys as they were before it, so a key passed over survives) and `keys.transform {…, merge, fromStart: true}` (values are the whole transform since the drag - started). Keys of locked layers can't be selected. Copy / paste: `keys.copy`, then `keys.paste` + started). **Quick Offset** (Ctrl/Cmd+Alt drag in the Timeline): `layer.quickOffset {delta, + layers?}` and `keys.quickOffset {delta}` stagger the selected layers, or each layer's selected + keys together, in selection order: the first stays, the last moves by `delta` (comp seconds) and + the ones between by equal steps, onto subframes; `merge` + `fromStart: true` make `delta` the whole + drag. Keys of locked layers can't be selected. Copy / paste: `keys.copy`, then `keys.paste` at the current time (keys copied from several layers go to as many selected layers in order). `time.nextKey` / `time.previousKey` / `keys.selectAll` take `visible: [{layer, prop}]` to act only on those properties (the Timeline passes its revealed ones, as J / K and Ctrl+Alt+A do). diff --git a/docs/architecture.md b/docs/architecture.md index 72d8b7341..e5564c22a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -72,7 +72,9 @@ All time is an integer `Tick`, 254 016 000 000 per second, the least common mult broadcast frame rate (×1001) and audio sample rate, so frame and sample positions are exact. Keyframe times are in **layer time**; `layer_time = (comp_time - start_time) / stretch`, and a Time Remap property overrides the source time. Commands snap layer times, keyframe times and -comp durations to whole frames, as After Effects does. Timecode is only a display. +comp durations to whole frames, as After Effects does, except Quick Offset (`layer.quickOffset`, +`keys.quickOffset`), which staggers layers and keys onto subframes, as After Effects 25.4 does. +Timecode is only a display. ## 3. Document model From 94b4d0ca34b1d1bedc3dde5b9ee86c9e92481691 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 12 Oct 2026 17:37:34 -0400 Subject: [PATCH 123/181] Timeline: Quick Offset layers and keyframes in selection order (#1012) Ctrl/Cmd+Alt dragging a selected layer bar or a selected key now runs a Quick Offset (layer.quickOffset / keys.quickOffset with fromStart, one undo step). The mode is chosen when the drag starts and needs at least two unlocked layers; the last selected layer moves by whole frames with the pointer. A readout by the pointer shows the Total Offset and the offset Per Layer, translated. A plain drag still moves the selection together, Alt alone still scales a key group in time (it now leaves Ctrl/Cmd+Alt to Quick Offset), and Ctrl/Cmd+Alt+click still toggles Hold. The key drag's readout is drawn by a shared drag_tip. Keys also register timeline.keyAt.., exact where keys on subframes can share a frame. Tests (ui_quick_offset): staggering layer bars in selection order and keys per layer, one undo each; plain drags and Alt scaling unchanged; the readout while dragging. --- crates/ui-egui/src/i18n/ui.rs | 12 ++ crates/ui-egui/src/panels/timeline.rs | 105 +++++++++++++-- crates/ui-egui/tests/ui_quick_offset.rs | 162 ++++++++++++++++++++++++ docs/control-protocol.md | 3 +- 4 files changed, 270 insertions(+), 12 deletions(-) create mode 100644 crates/ui-egui/tests/ui_quick_offset.rs diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index f31d0951c..ce2c9a431 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -525,6 +525,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("⌘/Ctrl-drag a pin to record its motion", "⌘/Ctrl+ドラッグでピンの動きを記録"), ("▣ {}", "▣ {}"), ("⚠ {} is used by:", "⚠ {}は次のコマンドで使用中:"), + ("Total Offset", "合計オフセット"), + ("Per Layer", "レイヤーごと"), ]; const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ @@ -1060,6 +1062,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("After Effects project", "After Effects 项目"), ("Lottie animation", "Lottie 动画"), ("Project", "项目"), + ("Total Offset", "总偏移"), + ("Per Layer", "每个图层"), ]; const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ @@ -1590,6 +1594,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("After Effects project", "After Effects 專案"), ("Lottie animation", "Lottie 動畫"), ("Project", "專案"), + ("Total Offset", "總偏移"), + ("Per Layer", "每個圖層"), ]; /// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs. @@ -2146,6 +2152,8 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("After Effects project", "Проєкт After Effects"), ("Lottie animation", "Анімація Lottie"), ("Project", "Проєкт"), + ("Total Offset", "Загальний зсув"), + ("Per Layer", "На шар"), ]; // Brazilian Portuguese. @@ -2580,6 +2588,8 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("⌘/Ctrl-drag a pin to record its motion", "⌘/Ctrl+arraste um pino para gravar seu movimento"), ("▣ {}", "▣ {}"), ("⚠ {} is used by:", "⚠ {} é usado por:"), + ("Total Offset", "Deslocamento total"), + ("Per Layer", "Por camada"), ]; /// Czech (cs): original translations covering every row in the Chinese panel catalogs. @@ -3115,6 +3125,8 @@ const CZECH: &[(&str, &str)] = &[ "Tyto sousední klíčové snímky nelze interpolovat, protože mají různý počet bodů přechodu.", ), ("Variable fonts only", "Jen proměnná písma"), + ("Total Offset", "Celkový posun"), + ("Per Layer", "Na vrstvu"), ]; #[cfg(test)] diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 72f104290..802913c47 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1127,18 +1127,61 @@ fn layer_drag_id() -> egui::Id { } /// A keyframe drag in progress: where the pointer started, the grabbed key's comp time -/// (seconds) and how many frames the selected keys have moved so far. +/// (seconds), how many frames the selected keys have moved so far and, for a Quick Offset +/// (Ctrl/Cmd+Alt), how many layers it staggers. #[derive(Clone, Copy, Debug)] struct KeyDrag { origin_x: f32, anchor: f64, applied: i64, + quick: Option, } fn key_drag_id() -> egui::Id { egui::Id::new("tl-key-drag") } +/// A Quick Offset drag of selected layer bars (Ctrl/Cmd+Alt, #1012): where the pointer started, +/// how many layers it staggers and how many frames the last one has moved so far. +#[derive(Clone, Copy, Debug)] +struct QuickBar { + origin_x: f32, + layers: usize, + applied: i64, +} + +fn quick_bar_id(layer: LayerId) -> egui::Id { + egui::Id::new(("tl-quick-bar", layer.0)) +} + +/// How many layers a Quick Offset of `layers` staggers (#1012): the unlocked ones, each once, if +/// there are at least two. +fn quick_offset_layers(comp: &Comp, layers: impl IntoIterator) -> Option { + let mut seen: Vec = vec![]; + for id in layers { + if !seen.contains(&id) && comp.layer(id).is_some_and(|l| !l.switches.locked) { + seen.push(id); + } + } + (seen.len() >= 2).then_some(seen.len()) +} + +/// A Quick Offset's feedback: the last layer's move and the step from one layer to the next. +fn quick_offset_note(frames: i64, layers: usize) -> String { + let per = frames as f64 / layers.saturating_sub(1).max(1) as f64; + format!("{}: {:+} f {}: {:+.2} f", tr("Total Offset"), frames, tr("Per Layer"), per) +} + +/// A Timeline drag's readout by the pointer (After Effects shows it in the Info panel). +fn drag_tip(ctx: &egui::Context, t: &Tokens, ptr: Pos2, note: String) { + let fg = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("tl-key-drag-tip"))); + let g = fg.layout_no_wrap(note, Tokens::ui(11.5), t.text); + let r = Rect::from_min_size(ptr + vec2(14.0, 14.0), g.size() + vec2(10.0, 6.0)); + fg.rect_filled(r, 3.0, t.panel_bg); + fg.rect_stroke(r, 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + fg.galley(r.min + vec2(5.0, 3.0), g, t.text); +} + /// A switch pressed and dragged over other rows: the switch, the state the press gave it, and /// the undo step the drag folds into. #[derive(Clone, Debug)] @@ -2126,7 +2169,41 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } layer_context_menu(&body, &comp, layer, &selected, vector_footage_layer(&app.session.project, layer), &mut actions); let drag_key = format!("bar-{}", layer.id.0); + // Ctrl/Cmd+Alt dragging a selected bar: Quick Offset (#1012), chosen as the + // drag starts. The last selected layer moves by whole frames with the pointer. + let mods = ui.input(|i| i.modifiers); + if body.drag_started() + && is_sel + && mods.command + && mods.alt + && let Some(n) = quick_offset_layers(&comp, selected.iter().copied()) + && let Some(p) = ctx.input(|i| i.pointer.press_origin()) + { + ctx.data_mut(|d| d.insert_temp(quick_bar_id(layer.id), QuickBar { origin_x: p.x, layers: n, applied: 0 })); + } + let quick = ctx.data(|d| d.get_temp::(quick_bar_id(layer.id))); + if let Some(mut q) = quick { + if body.dragged() + && let Some(ptr) = ctx.input(|i| i.pointer.latest_pos()) + { + let frames = (((ptr.x - q.origin_x) as f64 / pps) / fd).round() as i64; + if frames != q.applied { + q.applied = frames; + ctx.data_mut(|d| d.insert_temp(quick_bar_id(layer.id), q)); + let merge = format!("bar-quick-{}", layer.id.0); + actions.push(("layer.quickOffset".into(), json!({"delta": frames as f64 * fd, "merge": merge, "fromStart": true}))); + } + drag_tip(&ctx, &t, ptr, quick_offset_note(q.applied, q.layers)); + } + if body.drag_stopped() { + ctx.data_mut(|d| d.remove::(quick_bar_id(layer.id))); + ui_actions.push(UiAct::EndMerge); + } + } for (resp, kind) in [(&body, "move"), (&lin, "in"), (&lout, "out")] { + if kind == "move" && quick.is_some() { + continue; + } if resp.dragged() { let acc_id = egui::Id::new(("bar-acc", layer.id.0, kind)); let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0)); @@ -2564,6 +2641,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let kr = Rect::from_center_size(c, vec2(12.0, 14.0)); let kresp = ui.interact(kr, egui::Id::new(("key", uid, k.time.0)), Sense::click_and_drag()); app.auto.add(&format!("timeline.key.{uid}.{}", fr.frame_at(ct)), kr, &format!("{} key", prop.name)); + // Exact (layer-time ticks): keys a Quick Offset puts on subframes can share a frame. + app.auto.add(&format!("timeline.keyAt.{uid}.{}", k.time.0), kr, &format!("{} key", prop.name)); let mods = ui.input(|i| i.modifiers); let this_key = json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}], "selectProperties": true}); if kresp.clicked() && mods.command { @@ -2658,7 +2737,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { }); // Alt-drag the first or last key of a selected group: scale the group in time. if kresp.drag_started() - && ui.input(|i| i.modifiers.alt) + && mods.alt + && !mods.command && let Some(c) = app.session.active_comp_arc() { super::graph_tools::alt_scale_begin(app, &ctx, &c, ct.seconds()); @@ -2671,7 +2751,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { && !super::graph_tools::alt_scale_active(&ctx) && let Some(p) = ctx.input(|i| i.pointer.press_origin()) { - ctx.data_mut(|d| d.insert_temp(key_drag_id(), KeyDrag { origin_x: p.x, anchor: ct.seconds(), applied: 0 })); + // Ctrl/Cmd+Alt: Quick Offset of the selected keys' layers (#1012). + let quick = (mods.command && mods.alt && ks).then(|| quick_offset_layers(&comp, sel_keys.iter().map(|k| k.layer))).flatten(); + ctx.data_mut(|d| d.insert_temp(key_drag_id(), KeyDrag { origin_x: p.x, anchor: ct.seconds(), applied: 0, quick })); } } } @@ -2875,7 +2957,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let total = ((target - kd.anchor) / fd).round() as i64; if total != kd.applied { - actions.push(("keys.move".into(), json!({"delta": (total - kd.applied) as f64 * fd, "merge": "key-drag"}))); + actions.push(match kd.quick { + Some(_) => ("keys.quickOffset".into(), json!({"delta": total as f64 * fd, "merge": "key-quick-offset", "fromStart": true})), + None => ("keys.move".into(), json!({"delta": (total - kd.applied) as f64 * fd, "merge": "key-drag"})), + }); kd.applied = total; ctx.data_mut(|d| d.insert_temp(key_drag_id(), kd)); } @@ -2883,13 +2968,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Where the grabbed key is and how far the keys moved (After Effects shows this // in the Info panel). let at = Tick::from_seconds_f64(kd.anchor + kd.applied as f64 * fd); - let note = format!("{} {:+} f", super::timecode(&app.session, &comp, at), kd.applied); - let fg = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("tl-key-drag-tip"))); - let g = fg.layout_no_wrap(note, Tokens::ui(11.5), t.text); - let r = Rect::from_min_size(ptr + vec2(14.0, 14.0), g.size() + vec2(10.0, 6.0)); - fg.rect_filled(r, 3.0, t.panel_bg); - fg.rect_stroke(r, 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); - fg.galley(r.min + vec2(5.0, 3.0), g, t.text); + let note = match kd.quick { + Some(n) => quick_offset_note(kd.applied, n), + None => format!("{} {:+} f", super::timecode(&app.session, &comp, at), kd.applied), + }; + drag_tip(&ctx, &t, ptr, note); app.ui.status = format!("Keyframe at {}", super::timecode(&app.session, &comp, at)); } None => { diff --git a/crates/ui-egui/tests/ui_quick_offset.rs b/crates/ui-egui/tests/ui_quick_offset.rs new file mode 100644 index 000000000..d8e164339 --- /dev/null +++ b/crates/ui-egui/tests/ui_quick_offset.rs @@ -0,0 +1,162 @@ +//! Quick Offset in the Timeline (#1012): Ctrl/Cmd+Alt dragging selected layer bars or keys +//! staggers the layers in selection order, onto subframes, as one undo step; a plain drag still +//! moves them together and Alt alone still scales a key group. + +use effectcraft_engine::Session; +use effectcraft_engine::project::LayerId; +use effectcraft_engine::time::Tick; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Modifiers, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +/// One frame at 30 fps. +const F: i64 = 8_467_200_000; + +/// A 4 s, 30 fps comp with three solids, each with Opacity keys at 0 s and 1 s, revealed. +fn harness() -> (Harness<'static, EffectcraftApp>, Vec) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Stagger", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap(); + let mut ids = vec![]; + for i in 0..3 { + let id = + s.execute("layer.newSolid", json!({"name": format!("L{i}"), "color": "#e04020", "width": 80, "height": 80})).unwrap()["layer"].as_u64().unwrap(); + for (t, v) in [(0.0, 0.0), (1.0, 100.0)] { + s.execute("prop.addKey", json!({"layer": id, "path": "transform/opacity", "time": t, "value": v})).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) +} + +fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); + pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0) +} + +/// Press at `from`, move to `to` in small steps with `modifiers` held (a real drag), release. +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, modifiers: Modifiers) { + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers }); + h.step(); + for i in 1..=24 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 24.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::default())); + h.run_steps(2); +} + +fn ctrl_alt() -> Modifiers { + Modifiers { alt: true, ..Modifiers::COMMAND } +} + +fn start(h: &Harness<'_, EffectcraftApp>, id: u64) -> i64 { + h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().start_time.0 +} + +/// The comp times (ticks) of a layer's Opacity keys. +fn key_times(h: &Harness<'_, EffectcraftApp>, id: u64) -> Vec { + let l = h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().clone(); + l.props.prop("transform/opacity").unwrap().keys.iter().map(|k| l.comp_time(k.time).0).collect() +} + +fn opacity_uid(h: &Harness<'_, EffectcraftApp>, id: u64) -> u64 { + h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().props.prop("transform/opacity").unwrap().uid +} + +#[test] +fn ctrl_alt_dragging_layer_bars_staggers_them_in_selection_order() { + let (mut h, ids) = harness(); + let order = [ids[2], ids[0], ids[1]]; + for (i, id) in order.iter().enumerate() { + h.state_mut().session.execute("layer.select", json!({"layers": [id], "add": i > 0})).unwrap(); + } + h.run_steps(2); + let before = h.state().session.project.clone(); + // Drag the middle layer's bar: the drag moves the last selected layer. + let from = center(&h, &format!("timeline.layer.{}.bar", ids[0])); + drag(&mut h, from, from + vec2(160.0, 0.0), ctrl_alt()); + let d = start(&h, order[2]); + assert!(d > 0 && d % F == 0, "the last selected layer moves by whole frames: {d}"); + assert_eq!([start(&h, order[0]), start(&h, order[1])], [0, d / 2], "the first stays, the middle one moves half"); + assert_eq!(key_times(&h, order[1]), vec![d / 2, Tick::from_seconds_f64(1.0).0 + d / 2], "its keys come along"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert!(h.state().session.project.same_content(&before), "one undo step"); + // A plain drag still moves every selected layer together. + h.run_steps(2); + drag(&mut h, from, from + vec2(160.0, 0.0), Modifiers::default()); + let moved: Vec = order.iter().map(|id| start(&h, *id)).collect(); + assert!(moved[0] > 0 && moved.iter().all(|m| *m == moved[0]), "{moved:?}"); +} + +#[test] +fn ctrl_alt_dragging_keys_staggers_each_layers_keys_and_alt_still_scales() { + let (mut h, ids) = harness(); + // Layer 1's keys first, then layer 0's, then layer 2's. + let mut picks = vec![]; + for id in [ids[1], ids[0], ids[2]] { + for t in [0.0, 1.0] { + picks.push(json!({"layer": id, "path": "transform/opacity", "time": t})); + } + } + for (i, k) in picks.iter().enumerate() { + h.state_mut().session.execute("keys.select", json!({"keys": [k], "add": i > 0})).unwrap(); + } + h.run_steps(2); + let before = h.state().session.project.clone(); + let one = Tick::from_seconds_f64(1.0).0; + let grab = center(&h, &format!("timeline.keyAt.{}.{one}", opacity_uid(&h, ids[0]))); + drag(&mut h, grab, grab + vec2(160.0, 0.0), ctrl_alt()); + let d = key_times(&h, ids[2])[0]; + assert!(d > 0 && d % F == 0, "the last layer's keys move by whole frames: {d}"); + assert_eq!(key_times(&h, ids[1]), vec![0, one], "the first layer's keys stay"); + assert_eq!(key_times(&h, ids[0]), vec![d / 2, one + d / 2], "the middle layer's keys move half, together"); + assert_eq!(key_times(&h, ids[2]), vec![d, one + d]); + let layers: Vec = h.state().session.state.selected_keys.iter().map(|k| k.layer.0).collect(); + assert_eq!(layers, vec![ids[1], ids[1], ids[0], ids[0], ids[2], ids[2]], "the selection keeps its order"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert!(h.state().session.project.same_content(&before), "one undo step"); + // Alt alone still scales a key group in time: the first key stays, the last follows. + h.state_mut().session.execute("keys.select", json!({"keys": [picks[2], picks[3]]})).unwrap(); + h.run_steps(2); + let last = center(&h, &format!("timeline.keyAt.{}.{one}", opacity_uid(&h, ids[0]))); + drag(&mut h, last, last + vec2(160.0, 0.0), Modifiers { alt: true, ..Default::default() }); + let scaled = key_times(&h, ids[0]); + assert!(scaled[0] == 0 && scaled[1] > one, "Alt scales: {scaled:?}"); + assert_eq!(key_times(&h, ids[1]), vec![0, one], "and staggers nothing"); +} + +#[test] +fn a_quick_offset_drag_shows_the_total_and_per_layer_offset() { + let (mut h, ids) = harness(); + let from = center(&h, &format!("timeline.layer.{}.bar", ids[1])); + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(ctrl_alt())); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: ctrl_alt() }); + h.step(); + for i in 1..=12 { + h.input_mut().events.push(Event::PointerMoved(from + vec2(10.0 * i as f32, 0.0))); + h.step(); + } + let shapes = h.output().shapes.clone(); + let text: Vec = shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Text(t) => Some(t.galley.text().to_string()), + _ => None, + }) + .collect(); + assert!(text.iter().any(|t| t.starts_with("Total Offset: +") && t.contains("Per Layer: +")), "{text:?}"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f54879817..61f014439 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -118,7 +118,8 @@ one), `panel.tab.Viewer()` and `viewers.` (View ▸ New Viewer's other Com renames it), `viewer.comp`, `viewer.handle..`, `timeline.layer..bar`, `timeline.layer..nestedMarker.` (a precomp's comp markers), `timeline.layer..twirl`, `timeline.layer..row` (click selects, Enter or a double-click renames, drag reorders), `timeline.prop..stopwatch`, -`timeline.key..`, `timeline.cti`, `effectControls.prop..value` (an angle's +`timeline.key..` (and `timeline.keyAt..`, exact in layer-time ticks, for +keys on subframes), `timeline.cti`, `effectControls.prop..value` (an angle's revolutions are `timeline.prop..revolutions`, `effectControls.prop..revolutions` and `properties.prop..revolutions`) and `effects.item.`. The Composition viewer adds `viewer.magnification`, `viewer.resolution`, `viewer.roi`, `viewer.grid`, `viewer.channel`, `viewer.exposure`, `viewer.snapshot`, From d85601f8eb9bfb6b86d4c48c2926e1c43a7619be Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 23:38:01 +0200 Subject: [PATCH 124/181] UI: a Simplified Chinese interface picks a face with the simplified hanzi (fixes #1141) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Chinese UI fallback was chosen by probing 文, which Japanese faces cover too. Fallback takes a face already loaded first, so after the English interface had loaded a Japanese face for kana, switching to Simplified Chinese reused it, and the simplified-only hanzi (选, 删, 层, 设, 页 ...) drew as boxes. A Simplified Chinese interface now probes 页, as wordcraft does for its interface font (storytold/wordcraft#248). --- crates/ui-egui/src/theme.rs | 41 +++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index abc6f5762..2ed02c3ab 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -397,7 +397,6 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) // Kana is unambiguously Japanese; Han ideographs are shared, so the order decides which // language's face draws them. const KANA: (char, &str) = ('あ', "japanese-system"); - const HAN: (char, &str) = ('文', "chinese-system"); // Hangul is probed last: with no Korean font installed the lookup can land on a catch-all // face (Unifont) that also covers Arabic or Indic text but can't shape it, so the // script-specific faces must be registered ahead of it. A face already seen is skipped. @@ -405,7 +404,8 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) // Han text follows the interface language, not the operating system's locale. fonts::set_cjk_locale(cjk_locale(language)); let base = fonts::resolve("Inter", "Regular").face; - let cjk = if is_chinese(language) { [HAN, KANA] } else { [KANA, HAN] }; + let han = (han_probe(language), "chinese-system"); + let cjk = if is_chinese(language) { [han, KANA] } else { [KANA, han] }; let mut names = Vec::new(); let mut seen = Vec::new(); for &(probe, name) in cjk.iter().chain(SCRIPT_PROBES).chain(std::iter::once(&HANGUL)) { @@ -442,6 +442,15 @@ fn cjk_locale(language: &str) -> &str { } } +/// The Han character that picks the Chinese fallback face. A Simplified Chinese interface asks for +/// a simplified-only hanzi: a Japanese face covers the shared ideographs (文) but not 页, 选 or 删, +/// and the fallback takes a face already loaded first, so the Japanese face loaded for kana would +/// draw the Chinese interface with boxes (#1141). +#[cfg(not(target_arch = "wasm32"))] +fn han_probe(language: &str) -> char { + if language == "zh-hans" { '页' } else { '文' } +} + /// Whether `language` is one of the Chinese catalogs. #[cfg(not(target_arch = "wasm32"))] fn is_chinese(language: &str) -> bool { @@ -583,6 +592,34 @@ mod script_font_tests { } } + /// #1141: switching the interface to Simplified Chinese drew most labels as boxes. The English + /// interface had already loaded a Japanese face for kana, and that face covers the Han probe, + /// so it became the Chinese fallback too, without the simplified-only hanzi. + #[test] + fn simplified_chinese_interface_draws_simplified_hanzi_after_another_language() { + let ctx = egui::Context::default(); + let tokens = super::Tokens::for_kind(super::ThemeKind::Dark); + super::install(&ctx, &tokens, "en"); + super::install(&ctx, &tokens, "zh-hans"); + let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); + out.textures_delta.clear(); + let font = egui::FontId::new(13.0, egui::FontFamily::Proportional); + let missing: String = ctx.fonts_mut(|fonts| { + let replacement = fonts.layout_no_wrap("\u{10ffff}".into(), font.clone(), egui::Color32::WHITE).rows[0].glyphs[0].uv_rect; + // Simplified-only hanzi of the zh-hans catalog (选择, 删除, 图层, 设置, 导入, 页面). + "选删层设导页" + .chars() + .filter(|ch| { + let uv = fonts.layout_no_wrap(ch.to_string(), font.clone(), egui::Color32::WHITE).rows[0].glyphs[0].uv_rect; + (uv.min, uv.max) == (replacement.min, replacement.max) + }) + .collect() + }); + // Asked after the layout: the coverage lookup itself loads a face with these hanzi. + let drawable: String = missing.chars().filter(|&ch| installed(ch)).collect(); + assert!(drawable.is_empty(), "an installed face covers {drawable}, but the Simplified Chinese interface draws boxes"); + } + /// egui shapes each run of one face: Arabic letters take their joined forms, and an Arabic /// word reads right to left (its first letter drawn rightmost). #[test] From 5d9bab268d5b5c726c7f285148111e206f1604a8 Mon Sep 17 00:00:00 2001 From: storybored379 Date: Mon, 12 Oct 2026 14:40:10 -0700 Subject: [PATCH 125/181] M6.7: reject Roto Brush span edits past trimmed strokes Validate saved stroke frames against the current layer limits before clamping a span edit. Keep project and undo state intact when trimming has left strokes outside the layer. Co-Authored-By: OpenAI Codex --- ROADMAP.md | 5 ++++- crates/engine/src/commands/roto_cmds.rs | 12 +++++++--- crates/engine/src/tests_roto.rs | 29 +++++++++++++++++++++++++ docs/agents.md | 5 ++++- 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 72240b5b0..79c39dd15 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~30 points of "ready for real work" and ~350–620 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (full re-measure against After Effects 2026 26.5; standard progress-docs layout) · **Target:** Adobe After Effects 2026 (26.5.0.89) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** patch (Roto Brush span validation after trimming) · **Target:** Adobe After Effects 2026 (26.5.0.89) EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written from scratch in Rust. This page is the one-page summary; the evidence is in @@ -117,6 +117,8 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). ## Progress log +- 2026-10-12: Roto Brush span edits report strokes outside a trimmed layer without panicking; + strokes and undo history are preserved so restoring the timing allows the span edit again. - 2026-10-11: Ukrainian panels caught up with the Chinese catalogs. The 95 rows the panel catalog gained since 9 October are translated: the Home screen (recent files, their Kind and Opened columns, the empty and no-match states), the viewer's toolbar tooltips, magnification, @@ -148,6 +150,7 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | patch | Roto Brush span validation after trimming; command regressions for both layer edges | | 2026-10-10 | minor | Hours to ≈ 95% for each audience | | 2026-10-10 | minor | Headline adds mainstream practitioner (≈ 47%) and essentials user (≈ 52%) | | 2026-10-10 | minor | Stage checked against the new core-workflow gate (all six pass; still alpha) | diff --git a/crates/engine/src/commands/roto_cmds.rs b/crates/engine/src/commands/roto_cmds.rs index 95326b423..1df62bcb9 100644 --- a/crates/engine/src/commands/roto_cmds.rs +++ b/crates/engine/src/commands/roto_cmds.rs @@ -268,16 +268,22 @@ fn span(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "the Roto Brush is frozen: Unfreeze it to change the span")); } let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; - let lim = rt::frame_limits(comp, l); + let [lo, hi] = rt::frame_limits(comp, l); let mut d = data_of(g); let base = d.base.ok_or_else(|| bad(cmd, "no strokes yet"))?; let first = d.strokes.iter().map(|s| s.frame).min().unwrap_or(base); let last = d.strokes.iter().map(|s| s.frame).max().unwrap_or(base); + // Trimming or shortening the comp can leave saved strokes outside the layer. No span + // inside its new bounds can contain those strokes; keep them for undo or a later untrim. + if first < lo || last > hi { + return Err(bad(cmd, "strokes outside the layer: restore the layer/comp timing or clear those strokes before changing the span")); + } + let [start, end] = &mut d.span; if let Some(a) = f_p(p, "start") { - d.span[0] = (a.round() as i64).clamp(lim[0], first); + *start = (a.round() as i64).clamp(lo, first); } if let Some(b) = f_p(p, "end") { - d.span[1] = (b.round() as i64).clamp(last, lim[1]); + *end = (b.round() as i64).clamp(last, hi); } write_data(s, "Segmentation Span", cid, lid, uid, &d)?; Ok(json!({"layer": lid.0, "effect": uid, "span": d.span, "base": base})) diff --git a/crates/engine/src/tests_roto.rs b/crates/engine/src/tests_roto.rs index c1cbd2c8d..bef0936f4 100644 --- a/crates/engine/src/tests_roto.rs +++ b/crates/engine/src/tests_roto.rs @@ -27,6 +27,35 @@ pub(crate) fn hold_roto_cache_test_lock() { } const R: f64 = 40.0; +fn span_after_trimming_strokes(trim: Value, span: Value, expected: [i64; 2]) { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 64, "height": 64, "frameRate": 25, "duration": 1})).unwrap(); + let layer = s.execute("layer.newSolid", json!({"width": 64, "height": 64})).unwrap()["layer"].as_u64().unwrap(); + for frame in [0, 20] { + s.execute("roto.stroke", json!({"layer": layer, "frame": frame, "points": [[32, 32]], "radius": 4})).unwrap(); + } + s.execute("layer.timing", trim).unwrap(); + let before = s.project.to_json(); + let error = s.execute("roto.span", span.clone()).unwrap_err().to_string(); + assert!(error.contains("strokes outside the layer"), "{error}"); + assert_eq!(s.project.to_json(), before, "refusing the span must preserve strokes and project state"); + assert!(s.undo(), "the failed command must not add an undo step"); + let result = s.execute("roto.span", span).unwrap(); + assert_eq!(result["span"], json!(expected)); + let status = s.execute("roto.status", json!({"layer": layer})).unwrap(); + assert_eq!(status["strokeFrames"], json!([0, 20])); +} + +#[test] +fn roto_span_rejects_strokes_before_trimmed_in_point() { + span_after_trimming_strokes(json!({"in": 0.2}), json!({"start": 0}), [0, 20]); +} + +#[test] +fn roto_span_rejects_strokes_after_trimmed_out_point() { + span_after_trimming_strokes(json!({"out": 0.5}), json!({"end": 24}), [0, 24]); +} + fn noise(x: f64, y: f64, seed: u32) -> f64 { fn h(i: i64, j: i64, s: u32) -> f64 { let mut v = (i.wrapping_mul(73856093) ^ j.wrapping_mul(19349663) ^ (s as i64).wrapping_mul(83492791)) as u64; diff --git a/docs/agents.md b/docs/agents.md index 01738edd1..94294cb5d 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -448,7 +448,10 @@ direction?: forward|backward|both, to?, wait?}` segments the span (in the backgr `wait`); strokes on any other frame correct it and propagation restarts from there. `roto.span {start?, end?}`, `roto.freeze {wait?}` / `roto.unfreeze`, `roto.clearStrokes {frame?, kind?}`, `roto.cancel` and `roto.options {diameter?, refineDiameter?, view?: alphaBoundary|alpha| -alphaOverlay|none}` complete the set; every edit is one undo step. `roto.status {layer, frame?, +alphaOverlay|none}` complete the set; every edit is one undo step. If trimming the layer or +shortening the comp leaves strokes outside the layer, `roto.span` returns an error without +changing the strokes: restore the timing or remove those strokes with `roto.clearStrokes` first. +`roto.status {layer, frame?, matte?, compute?, compareTo?}` reports the base frame, span, computed and stroked frames, frozen state, job progress and, for a frame, the matte's area, centroid, RLE matte and IoU against a reference. Matte settings are ordinary properties (`effects/#1/rotoBrushMatte/searchRadius`, From e2d50ec926c1ac55ac859e3b2587aac5b3d60ef9 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 12 Oct 2026 17:45:30 -0400 Subject: [PATCH 126/181] Timeline: Shift ranges and key boxes stagger in comp order (#1012) In After Effects a marquee selection staggers in the comp's layer order, not in the direction the box was drawn. A Shift-click range of layer bars and a box of keys already come out top layer first; this test makes both with the pointer (the range from the bottom layer up, the box from the bottom right to the top left) and checks the Quick Offset that follows: the top layer stays and the bottom one moves the whole delta. --- crates/ui-egui/tests/ui_quick_offset.rs | 39 +++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/crates/ui-egui/tests/ui_quick_offset.rs b/crates/ui-egui/tests/ui_quick_offset.rs index d8e164339..311615176 100644 --- a/crates/ui-egui/tests/ui_quick_offset.rs +++ b/crates/ui-egui/tests/ui_quick_offset.rs @@ -160,3 +160,42 @@ fn a_quick_offset_drag_shows_the_total_and_per_layer_offset() { .collect(); assert!(text.iter().any(|t| t.starts_with("Total Offset: +") && t.contains("Per Layer: +")), "{text:?}"); } + +fn click(h: &mut Harness<'_, EffectcraftApp>, at: Pos2, modifiers: Modifiers) { + h.input_mut().events.push(Event::PointerMoved(at)); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::default())); + h.run_steps(2); +} + +/// A Shift-click range of layers and a box of keys, drawn from the bottom right up to the top left, +/// stagger in the comp's layer order (top first), whichever way they were made (#1012). +#[test] +fn shift_ranges_and_key_boxes_stagger_in_comp_order() { + let (mut h, ids) = harness(); + // ids[2] is the top layer, ids[0] the bottom one. Click the bottom bar, Shift-click the top. + let bar = |h: &Harness<'_, EffectcraftApp>, id: u64| center(h, &format!("timeline.layer.{id}.bar")); + let (bottom, top) = (bar(&h, ids[0]), bar(&h, ids[2])); + click(&mut h, bottom, Modifiers::default()); + click(&mut h, top, Modifiers::SHIFT); + let selected: Vec = h.state().session.state.selected_layers.iter().map(|l| l.0).collect(); + assert_eq!(selected, vec![ids[2], ids[1], ids[0]], "a Shift range is in comp order"); + h.state_mut().session.execute("layer.quickOffset", json!({"delta": 2.0 / 30.0})).unwrap(); + assert_eq!([start(&h, ids[2]), start(&h, ids[1]), start(&h, ids[0])], [0, F, 2 * F]); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + // A box around the 1 s keys, drawn upwards and to the left. + let one = Tick::from_seconds_f64(1.0).0; + let key = |h: &Harness<'_, EffectcraftApp>, id: u64| center(h, &format!("timeline.keyAt.{}.{one}", opacity_uid(h, id))); + let (low, high) = (key(&h, ids[0]), key(&h, ids[2])); + drag(&mut h, low + vec2(30.0, 5.0), high - vec2(30.0, 5.0), Modifiers::default()); + let order: Vec = h.state().session.state.selected_keys.iter().map(|k| k.layer.0).collect(); + assert_eq!(order, vec![ids[2], ids[1], ids[0]], "a key box is in comp order"); + h.state_mut().session.execute("keys.quickOffset", json!({"delta": 2.0 / 30.0})).unwrap(); + assert_eq!([key_times(&h, ids[2])[1], key_times(&h, ids[1])[1], key_times(&h, ids[0])[1]], [one, one + F, one + 2 * F]); +} From a69c7d7cd21c4775dcc3c9cf59db3d484898637a Mon Sep 17 00:00:00 2001 From: redonion23 <199118131+redonion23@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:52:44 +0200 Subject: [PATCH 127/181] Timeline: Reset keeps an animated property's keyframes and keys the default (#1078) prop.reset cleared every keyframe before setting the default value. As in After Effects (and as effect.reset already does for effect parameters), an animated property now keeps its keyframes and gets the default as a key at the current time; a static property just takes the default. The expression is still removed. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/prop.rs | 9 +++++---- crates/engine/src/tests_timeline.rs | 31 +++++++++++++++++++++++++++-- docs/agents.md | 5 +++-- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 64c166fd5..7ac5f788e 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -631,11 +631,12 @@ fn reset(s: &mut Session, p: &Value) -> Result { let (cid, lid, uid) = prop_ref(s, p, "prop.reset")?; let def = p.get("default").cloned(); let own = default_value(s, cid, lid, uid); - with_prop(s, "Reset Property", None, cid, lid, uid, |pr, _| { - pr.keys.clear(); + with_prop(s, "Reset Property", None, cid, lid, uid, |pr, t| { pr.expr = None; + // An animated property keeps its keyframes and gets the default as a key at the + // current time, as in After Effects (#1078). if let Some(d) = def.as_ref().and_then(|d| pr.value.coerce_json(d)).or(own) { - pr.value = d; + pr.set_value_at(t, d); } Ok(Value::Null) }) @@ -1207,7 +1208,7 @@ pub fn specs() -> Vec { "Reset Property", [], None, - "{layer?, path|prop, default?} → removes keyframes and expression; the value becomes `default`, else the property's own (an effect parameter's, a Transform property's)", + "{layer?, path|prop, default?} → removes the expression; the value becomes `default`, else the property's own (an effect parameter's, a Transform property's), as a keyframe at the current time when the property is animated", has_layers, reset ), diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 009af9e52..531ddc147 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -597,8 +597,10 @@ fn reset_gives_a_property_its_default() { assert_eq!(v(&s, "transform/anchor"), KV::Vec3([50.0, 50.0, 0.0]), "the solid's centre"); assert_eq!(v(&s, "transform/position"), KV::Vec3([200.0, 150.0, 0.0]), "the comp's centre"); assert_eq!(v(&s, "transform/scale"), KV::Vec3([100.0; 3])); - assert_eq!(v(&s, "transform/rotation"), KV::Scalar(0.0)); - assert!(!prop(&s, l, "transform/rotation").is_animated()); + // Animated: the default becomes the key at the current time (0 s), the 1 s key stays (#1078). + let rot = prop(&s, l, "transform/rotation"); + assert_eq!(rot.keys.len(), 2); + assert_eq!(rot.value_at(effectcraft_time::Tick::ZERO), KV::Scalar(0.0)); assert_eq!(v(&s, "transform/opacity"), KV::Scalar(100.0)); assert!(prop(&s, l, "transform/opacity").expr.is_none()); assert_eq!(v(&s, "effects/#1/blurriness"), blur); @@ -607,6 +609,31 @@ fn reset_gives_a_property_its_default() { assert_eq!(v(&s, "transform/opacity"), KV::Scalar(30.0)); } +/// #1078: resetting an animated property keeps its keyframes and keys the default at the +/// current time, as in After Effects; undo brings the old keys back. +#[test] +fn reset_keys_the_default_on_an_animated_property() { + let (mut s, l) = setup(); + animate(&mut s, l, "transform/scale", &[(0.0, json!([50, 50, 100])), (2.0, json!([150, 150, 100]))]); + s.execute("time.set", json!({"time": 1})).unwrap(); + s.execute("prop.reset", json!({"layer": l, "path": "transform/scale"})).unwrap(); + let pr = prop(&s, l, "transform/scale"); + let at = |sec: f64| pr.value_at(effectcraft_time::Tick::from_seconds_f64(sec)); + assert_eq!(pr.keys.len(), 3, "the 0 s and 2 s keys stay, one is added at 1 s"); + assert_eq!(at(0.0), KV::Vec3([50.0, 50.0, 100.0])); + assert_eq!(at(1.0), KV::Vec3([100.0; 3])); + assert_eq!(at(2.0), KV::Vec3([150.0, 150.0, 100.0])); + // On a key, the key takes the default. + s.execute("time.set", json!({"time": 2})).unwrap(); + s.execute("prop.reset", json!({"layer": l, "path": "transform/scale"})).unwrap(); + let pr = prop(&s, l, "transform/scale"); + assert_eq!(pr.keys.len(), 3); + assert_eq!(pr.value_at(effectcraft_time::Tick::from_seconds_f64(2.0)), KV::Vec3([100.0; 3])); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/scale").keys.len(), 2); +} + /// #363: dropped on one of the target's values the pick whip picks that dimension /// (`position[0]`), as in After Effects; a multi-dimension property follows it on every axis. #[test] diff --git a/docs/agents.md b/docs/agents.md index 01738edd1..ffea88deb 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -566,8 +566,9 @@ Extension points and the bundled extensions: [plugins.md](plugins.md). (`effectControls.prop..value[.]`), it runs `prop.pickWhip`: the reference replaces the property's expression, or is added as one, and the expression is enabled. * A property's menu (right-click its name; entries `timeline.prop..menu.`): Reset - (`prop.reset`: no keyframes or expression, and the property's default value, an effect - parameter's or a Transform property's), Edit Value… (`prop.set {layer, prop}` without `value` + (`prop.reset`: no expression, and the property's default value, an effect parameter's or a + Transform property's; an animated property keeps its keyframes and gets the default as a + keyframe at the current time), Edit Value… (`prop.set {layer, prop}` without `value` opens the value dialog), Separate Dimensions (Position: `prop.separateDimensions {layer, value}`), Add / Remove Expression (`prop.setExpression {layer, prop}`) and Add Property to Essential Graphics (`essential.addProperty`). From 41f7736959e10d7e5968d2202598c687499b27b5 Mon Sep 17 00:00:00 2001 From: fabian-co Date: Mon, 12 Oct 2026 17:45:45 -0500 Subject: [PATCH 128/181] macOS: open the window on Intel Metal by skipping indirect-call validation wgpu validates indirect draw and dispatch arguments with a compute shader it compiles when the device is created. Metal on an Intel Iris Pro (MacBookPro11,3, macOS 12.7.6) fails to compile it ("Compiler encountered an internal error" in MTLCompilerService), the device is lost, and the window never opens. EffectCraft draws and dispatches nothing indirectly, so leave the check out of the instance flags. WGPU_VALIDATION_INDIRECT_CALL=1 turns it back on. VectorCraft does the same (storytold/vectorcraft#651). Co-Authored-By: Claude Opus 5.5 --- apps/effectcraft/src/main.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 83015f1a2..3135fc351 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -308,6 +308,11 @@ fn wgpu_options(backends: Option) -> eframe::WgpuConfigu if let Some(b) = backends { setup.instance_descriptor.backends = b; } + // Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only costs + // a compute shader when the device is created, one that Metal can't compile on an Intel + // Iris Pro: the device is lost and the window never opens. + // `WGPU_VALIDATION_INDIRECT_CALL=1` turns the check back on. + setup.instance_descriptor.flags = setup.instance_descriptor.flags.difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env(); setup.device_descriptor = std::sync::Arc::new(|adapter| eframe::wgpu::DeviceDescriptor { label: Some("EffectCraft device"), required_limits: device_limits(adapter.limits()), @@ -442,6 +447,18 @@ mod tests { } /// The window's device never asks for more than the adapter offers (#198). + #[test] + fn the_window_instance_skips_indirect_call_validation() { + if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_some() { + return; + } + let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = super::wgpu_options(None).wgpu_setup else { + panic!("the default setup creates its own instance"); + }; + // Intel Metal can't compile the validation's compute shader, and the device is lost. + assert!(!setup.instance_descriptor.flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL)); + } + #[test] fn device_limits_fit_the_adapter() { use eframe::wgpu::Limits; From f72b8993ef4bf57c97859aa5524422b191539230 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 12 Oct 2026 18:49:34 -0400 Subject: [PATCH 129/181] Keyframes: moving keys keeps subframe spacing (#1012) keys.move and the nudges snapped each key's destination to the nearest frame, so moving keys a Quick Offset had staggered onto subframes put them back on frames: four keys at 0, 1/3, 2/3 and 1 frame, moved one frame, came out at 1, 1, 2 and 2. Layer bars kept theirs, because layer.timing snaps the move, not the times. The move is now rounded to whole frames and every key translated by it. layer_shift turns a comp-time move into a layer-time move through the layer's start and stretch (reversed too) without a round trip through comp time; keys.quickOffset uses it as well. Test: moving_staggered_keys_keeps_their_subframe_spacing (Quick Offset, then keys.move and keys.nudgeForward; fails before: 1/3 frame lands on a frame). --- crates/engine/src/commands/prop.rs | 17 ++++++++++++----- crates/engine/src/commands/quick_offset.rs | 9 +++------ crates/engine/src/tests_quick_offset.rs | 16 ++++++++++++++++ 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 903f9e131..57ea8deb4 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -928,9 +928,16 @@ pub(crate) fn move_key_refs(comp: &mut effectcraft_project::Comp, from: &[KeyRef Ok(from.iter().filter_map(|k| remapped.get(k).copied()).filter(|k| seen.insert(*k)).collect()) } -/// Move the selected keys by `d` comp seconds, snapped to the comp's frames in comp time (keys -/// of time-stretched layers move with the pointer, not by the layer-time distance). Keys of -/// locked layers stay. A key landing on another key of its property replaces it. +/// A move of `d` in comp time as the move of `l`'s layer times (through its stretch, reversed +/// too), without a round trip through comp time. +pub(crate) fn layer_shift(l: &Layer, d: Tick) -> Tick { + l.layer_time(l.start_time + d) +} + +/// Move the selected keys by `d` comp seconds, rounded to whole frames: the keys keep their +/// spacing, subframe offsets (Quick Offset) included, and keys of time-stretched layers move with +/// the pointer, not by the layer-time distance. Keys of locked layers stay. A key landing on +/// another key of its property replaces it. pub(crate) fn shift_keys(s: &mut Session, label: &str, merge: Option<&str>, d: Tick) -> Result { let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; let (from, total, base) = key_gesture(s, merge, d, false); @@ -940,8 +947,8 @@ pub(crate) fn shift_keys(s: &mut Session, label: &str, merge: Option<&str>, d: T *proj = (*b).clone(); } let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - let fr = comp.frame_rate; - st.selected_keys = move_key_refs(comp, &from, |l, t| Ok(l.layer_time(fr.snap_nearest(l.comp_time(t) + total))))?; + let d = comp.frame_rate.snap_nearest(total); + st.selected_keys = move_key_refs(comp, &from, |l, t| Ok(t + layer_shift(l, d)))?; Ok(json!(st.selected_keys.len())) }) } diff --git a/crates/engine/src/commands/quick_offset.rs b/crates/engine/src/commands/quick_offset.rs index aeda6c781..c3c377f7f 100644 --- a/crates/engine/src/commands/quick_offset.rs +++ b/crates/engine/src/commands/quick_offset.rs @@ -9,7 +9,7 @@ use effectcraft_project::LayerId; use effectcraft_time::{TICKS_PER_SECOND, Tick}; use serde_json::{Value, json}; -use super::prop::{key_gesture, move_key_refs}; +use super::prop::{key_gesture, layer_shift, move_key_refs}; use super::{CommandSpec, b_p, bad, f_p, has_keys, has_layers, layers_p, merge_p}; use crate::{EngineError, Result, Session, cmd}; @@ -110,11 +110,8 @@ fn keys(s: &mut Session, p: &Value) -> Result { st.selected_keys = move_key_refs(comp, &from, |l, t| { let Some(d) = by_layer.get(&l.id) else { return Ok(t) }; let what = format!("a key of \"{}\"", l.name); - let at = l.layer_time(shifted(l.comp_time(t), *d, NAME, &what)?); - if at < Tick::MIN || at > Tick::MAX { - return Err(bad(NAME, format!("{what} would move out of range"))); - } - Ok(at) + shifted(l.start_time, *d, NAME, &what)?; + shifted(t, layer_shift(l, *d), NAME, &what) })?; Ok(json!({"keys": st.selected_keys.len(), "layers": order.iter().map(|id| json!({"layer": id.0, "offset": by_layer.get(id).copied().unwrap_or_default().seconds()})).collect::>()})) }) diff --git a/crates/engine/src/tests_quick_offset.rs b/crates/engine/src/tests_quick_offset.rs index 7ccf75f1b..aeff851a7 100644 --- a/crates/engine/src/tests_quick_offset.rs +++ b/crates/engine/src/tests_quick_offset.rs @@ -252,3 +252,19 @@ fn roving_keys_follow_and_the_selection_stays_on_the_moved_keys() { } assert_eq!(s.state.selected_keys.iter().map(|k| k.layer.0).collect::>(), vec![ids[1], ids[0]]); } + +#[test] +fn moving_staggered_keys_keeps_their_subframe_spacing() { + let (mut s, ids) = setup(4); + for id in &ids { + add_key(&mut s, *id, "transform/opacity", 0.0, json!(0)); + } + select_keys(&mut s, &ids.iter().map(|id| (*id, "transform/opacity", 0.0)).collect::>()); + s.execute_checked("keys.quickOffset", json!({"delta": 1.0 / 30.0})).unwrap(); + // A plain move (and a nudge) translates them: it used to put each key on its nearest frame. + s.execute_checked("keys.move", json!({"delta": 1.0 / 30.0})).unwrap(); + s.execute_checked("keys.nudgeForward", json!({})).unwrap(); + for (i, id) in ids.iter().enumerate() { + assert_eq!(key_times(&s, *id, "transform/opacity"), vec![2 * F + F * i as i64 / 3], "layer {i}"); + } +} From e1c9c33fb150d38777e957913fa9baafcda7ad8e Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 12 Oct 2026 18:49:48 -0400 Subject: [PATCH 130/181] Keyframes: a refused key drag step doesn't count towards the drag (#1012) keys.quickOffset and keys.move stored the drag's new total before the edit ran. When the edit refused the step (keys.quickOffset with a key landing past the end of time), the project stayed as it was but the total kept the refused delta, and with incremental steps (merge without fromStart) the next step moved by it too. The drag state is now stored only after the edit applies. Test: a_refused_step_does_not_count_towards_the_drag (+1 s, a refused step, -1 s: the keys end where they started; fails before: the last key ends near the end of time). --- crates/engine/src/commands/prop.rs | 9 ++++++--- crates/engine/src/commands/quick_offset.rs | 9 ++++++--- crates/engine/src/tests_quick_offset.rs | 17 +++++++++++++++++ 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 57ea8deb4..5248949a4 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -941,8 +941,8 @@ pub(crate) fn layer_shift(l: &Layer, d: Tick) -> Tick { pub(crate) fn shift_keys(s: &mut Session, label: &str, merge: Option<&str>, d: Tick) -> Result { let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; let (from, total, base) = key_gesture(s, merge, d, false); - s.key_move = merge.map(|m| crate::KeyMove { merge: m.to_string(), from: from.clone(), total }); - s.edit(label, merge, |proj, st| { + let next = merge.map(|m| crate::KeyMove { merge: m.to_string(), from: from.clone(), total }); + let moved = s.edit(label, merge, |proj, st| { if let Some(b) = base { *proj = (*b).clone(); } @@ -950,7 +950,10 @@ pub(crate) fn shift_keys(s: &mut Session, label: &str, merge: Option<&str>, d: T let d = comp.frame_rate.snap_nearest(total); st.selected_keys = move_key_refs(comp, &from, |l, t| Ok(t + layer_shift(l, d)))?; Ok(json!(st.selected_keys.len())) - }) + })?; + // Only a step that applied counts towards the drag. + s.key_move = next; + Ok(moved) } fn move_keys(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/commands/quick_offset.rs b/crates/engine/src/commands/quick_offset.rs index c3c377f7f..312013455 100644 --- a/crates/engine/src/commands/quick_offset.rs +++ b/crates/engine/src/commands/quick_offset.rs @@ -101,8 +101,8 @@ fn keys(s: &mut Session, p: &Value) -> Result { let comp = start.comp(cid).ok_or(EngineError::NoComp)?; let order = order(NAME, from.iter().map(|k| k.layer), |id| comp.layer(id).is_some_and(|l| !l.switches.locked))?; let by_layer: HashMap = order.iter().copied().zip(shares(total, order.len())).collect(); - s.key_move = merge.map(|m| crate::KeyMove { merge: m.to_string(), from: from.clone(), total }); - s.edit("Quick Offset Keyframes", merge, |proj, st| { + let next = merge.map(|m| crate::KeyMove { merge: m.to_string(), from: from.clone(), total }); + let moved = s.edit("Quick Offset Keyframes", merge, |proj, st| { if let Some(b) = &base { *proj = (**b).clone(); } @@ -114,7 +114,10 @@ fn keys(s: &mut Session, p: &Value) -> Result { shifted(t, layer_shift(l, *d), NAME, &what) })?; Ok(json!({"keys": st.selected_keys.len(), "layers": order.iter().map(|id| json!({"layer": id.0, "offset": by_layer.get(id).copied().unwrap_or_default().seconds()})).collect::>()})) - }) + })?; + // Only a step that applied counts towards the drag (a refused one is as if never sent). + s.key_move = next; + Ok(moved) } pub fn specs() -> Vec { diff --git a/crates/engine/src/tests_quick_offset.rs b/crates/engine/src/tests_quick_offset.rs index aeff851a7..164bf8753 100644 --- a/crates/engine/src/tests_quick_offset.rs +++ b/crates/engine/src/tests_quick_offset.rs @@ -268,3 +268,20 @@ fn moving_staggered_keys_keeps_their_subframe_spacing() { assert_eq!(key_times(&s, *id, "transform/opacity"), vec![2 * F + F * i as i64 / 3], "layer {i}"); } } + +#[test] +fn a_refused_step_does_not_count_towards_the_drag() { + let (mut s, ids) = setup(2); + for id in &ids { + add_key(&mut s, *id, "transform/opacity", 1.0, json!(0)); + } + select_keys(&mut s, &ids.iter().map(|id| (*id, "transform/opacity", 1.0)).collect::>()); + let before = s.project.clone(); + // The second step's last key would land past the end of time (one second short of it, its + // own delta isn't); with it counted, the third would still move the key that far. + let far = (Tick::MAX.seconds() - 1.0).floor(); + s.execute_checked("keys.quickOffset", json!({"delta": 1.0, "merge": "steps"})).unwrap(); + assert!(s.execute_checked("keys.quickOffset", json!({"delta": far, "merge": "steps"})).is_err()); + s.execute_checked("keys.quickOffset", json!({"delta": -1.0, "merge": "steps"})).unwrap(); + assert!(s.project.same_content(&before), "only the steps that applied count: {:?}", key_times(&s, ids[1], "transform/opacity")); +} From bfb7507565152ae2c95f135fd5404d2bca66990a Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 12 Oct 2026 18:49:51 -0400 Subject: [PATCH 131/181] Timeline: a Quick Offset of layer bars ends on release off screen (#1012) The layer bar Quick Offset was followed and ended in its row's code, and rows out of view aren't drawn. Released with its row scrolled away, it stayed active: the next drag of that bar, even a plain one, ran as a Quick Offset from the old origin, into the old undo step. Like the key drag, it now follows the pointer below the rows and ends when the button is released, wherever the bar's row is. Test: a_quick_offset_released_off_screen_ends (release with the row scrolled away; a plain drag after it moves both layers together as its own undo step; fails before). --- crates/ui-egui/src/panels/timeline.rs | 56 +++++++++++++------------ crates/ui-egui/tests/ui_quick_offset.rs | 42 +++++++++++++++++++ 2 files changed, 72 insertions(+), 26 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 802913c47..5b4226522 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1141,17 +1141,18 @@ fn key_drag_id() -> egui::Id { egui::Id::new("tl-key-drag") } -/// A Quick Offset drag of selected layer bars (Ctrl/Cmd+Alt, #1012): where the pointer started, -/// how many layers it staggers and how many frames the last one has moved so far. +/// A Quick Offset drag of selected layer bars (Ctrl/Cmd+Alt, #1012): the bar it started on, where +/// the pointer started, how many layers it staggers and how many frames the last one has moved. #[derive(Clone, Copy, Debug)] struct QuickBar { + layer: LayerId, origin_x: f32, layers: usize, applied: i64, } -fn quick_bar_id(layer: LayerId) -> egui::Id { - egui::Id::new(("tl-quick-bar", layer.0)) +fn quick_bar_id() -> egui::Id { + egui::Id::new("tl-quick-bar") } /// How many layers a Quick Offset of `layers` staggers (#1012): the unlocked ones, each once, if @@ -2170,7 +2171,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { layer_context_menu(&body, &comp, layer, &selected, vector_footage_layer(&app.session.project, layer), &mut actions); let drag_key = format!("bar-{}", layer.id.0); // Ctrl/Cmd+Alt dragging a selected bar: Quick Offset (#1012), chosen as the - // drag starts. The last selected layer moves by whole frames with the pointer. + // drag starts and followed below the rows until the button is released. let mods = ui.input(|i| i.modifiers); if body.drag_started() && is_sel @@ -2179,29 +2180,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { && let Some(n) = quick_offset_layers(&comp, selected.iter().copied()) && let Some(p) = ctx.input(|i| i.pointer.press_origin()) { - ctx.data_mut(|d| d.insert_temp(quick_bar_id(layer.id), QuickBar { origin_x: p.x, layers: n, applied: 0 })); - } - let quick = ctx.data(|d| d.get_temp::(quick_bar_id(layer.id))); - if let Some(mut q) = quick { - if body.dragged() - && let Some(ptr) = ctx.input(|i| i.pointer.latest_pos()) - { - let frames = (((ptr.x - q.origin_x) as f64 / pps) / fd).round() as i64; - if frames != q.applied { - q.applied = frames; - ctx.data_mut(|d| d.insert_temp(quick_bar_id(layer.id), q)); - let merge = format!("bar-quick-{}", layer.id.0); - actions.push(("layer.quickOffset".into(), json!({"delta": frames as f64 * fd, "merge": merge, "fromStart": true}))); - } - drag_tip(&ctx, &t, ptr, quick_offset_note(q.applied, q.layers)); - } - if body.drag_stopped() { - ctx.data_mut(|d| d.remove::(quick_bar_id(layer.id))); - ui_actions.push(UiAct::EndMerge); - } + ctx.data_mut(|d| d.insert_temp(quick_bar_id(), QuickBar { layer: layer.id, origin_x: p.x, layers: n, applied: 0 })); } + let quick = ctx.data(|d| d.get_temp::(quick_bar_id())).is_some_and(|q| q.layer == layer.id); for (resp, kind) in [(&body, "move"), (&lin, "in"), (&lout, "out")] { - if kind == "move" && quick.is_some() { + if kind == "move" && quick { continue; } if resp.dragged() { @@ -2937,6 +2920,27 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ctx.data_mut(|d| d.remove::>(layer_drag_id())); } } + // Quick Offset of layer bars: the last selected layer moves by whole frames with the pointer + // until the button is released, wherever the bar's row has scrolled to. + if let Some(mut q) = ctx.data(|d| d.get_temp::(quick_bar_id())) { + let (down, ptr) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos())); + match ptr.filter(|_| down) { + Some(ptr) => { + let frames = (((ptr.x - q.origin_x) as f64 / pps) / fd).round() as i64; + if frames != q.applied { + q.applied = frames; + ctx.data_mut(|d| d.insert_temp(quick_bar_id(), q)); + let merge = format!("bar-quick-{}", q.layer.0); + actions.push(("layer.quickOffset".into(), json!({"delta": frames as f64 * fd, "merge": merge, "fromStart": true}))); + } + drag_tip(&ctx, &t, ptr, quick_offset_note(q.applied, q.layers)); + } + None => { + ctx.data_mut(|d| d.remove::(quick_bar_id())); + ui_actions.push(UiAct::EndMerge); + } + } + } // Keyframe drag: the selected keys move by whole frames with the pointer until it is // released (one undo step); Shift snaps the grabbed key to the current time, the work area // and composition markers. diff --git a/crates/ui-egui/tests/ui_quick_offset.rs b/crates/ui-egui/tests/ui_quick_offset.rs index 311615176..0587e85f3 100644 --- a/crates/ui-egui/tests/ui_quick_offset.rs +++ b/crates/ui-egui/tests/ui_quick_offset.rs @@ -199,3 +199,45 @@ fn shift_ranges_and_key_boxes_stagger_in_comp_order() { h.state_mut().session.execute("keys.quickOffset", json!({"delta": 2.0 / 30.0})).unwrap(); assert_eq!([key_times(&h, ids[2])[1], key_times(&h, ids[1])[1], key_times(&h, ids[0])[1]], [one, one + F, one + 2 * F]); } + +/// Releasing a Quick Offset drag with its row scrolled out of view ends it: a later plain drag of +/// that bar moves the selection together, as its own undo step. +#[test] +fn a_quick_offset_released_off_screen_ends() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Many", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap(); + let ids: Vec = + (0..40).map(|i| s.execute("layer.newSolid", json!({"name": format!("L{i}"), "color": "#e04020"})).unwrap()["layer"].as_u64().unwrap()).collect(); + let (top, next) = (ids[39], ids[38]); + s.execute("layer.select", json!({"layers": [top, next]})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + let from = center(&h, &format!("timeline.layer.{top}.bar")); + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(ctrl_alt())); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: ctrl_alt() }); + h.step(); + for i in 1..=12 { + h.input_mut().events.push(Event::PointerMoved(from + vec2(10.0 * i as f32, 0.0))); + h.step(); + } + // Let go of Ctrl+Alt, scroll the row away, then release the button. + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::default())); + h.state_mut().ui.timeline.scroll_y = 10_000.0; + h.run_steps(2); + assert!(h.state().auto.find(&format!("timeline.layer.{top}.bar")).is_none(), "the row is out of view"); + let to = from + vec2(120.0, 0.0); + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::default() }); + h.run_steps(2); + let staggered = (start(&h, next), start(&h, top)); + assert!(staggered.0 > 0 && staggered.1 == 0, "the Quick Offset applied: {staggered:?}"); + h.state_mut().ui.timeline.scroll_y = 0.0; + h.run_steps(3); + let from = center(&h, &format!("timeline.layer.{top}.bar")); + drag(&mut h, from, from + vec2(160.0, 0.0), Modifiers::default()); + let d = start(&h, top); + assert!(d > 0 && start(&h, next) == staggered.0 + d, "a plain drag moves both by the same amount"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!((start(&h, next), start(&h, top)), staggered, "as its own undo step"); +} From be99fecfdc6e5598b0cc8973ae2dbbed02455448 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 12 Oct 2026 18:37:05 +0200 Subject: [PATCH 132/181] #1010: recover a static Orientation's Y angle without losing precision near gimbal lock uniform_parent_pose took the Y angle from asin, which loses precision within about 1e-6 degrees of +-90. The Y angle now comes from atan2 on cos(Y), as the X and Z angles already do. The gimbal branch and the principal-range output are unchanged. A new test parents a static Orientation with Y 1e-6 degrees off gimbal lock under a uniform parent. Its largest corner displacement was 3.3e-8 px before this change and 1.5e-14 px after. Co-Authored-By: Claude Haiku 5.5 --- crates/engine/src/commands/layer.rs | 7 +++--- crates/engine/tests/parenting_3d_position.rs | 23 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index d4cf1d90d..078c0d4f8 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -743,9 +743,10 @@ fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Opti return Some((orientation, factor)); } let r = (rotation * Mat4::orientation(Vec3::from(orientation))).0; - let sy = (-r[2][0]).clamp(-1.0, 1.0); - let y = sy.asin(); - let (x, z) = if (1.0 - sy * sy).sqrt() > 1e-9 { (r[2][1].atan2(r[2][2]), r[1][0].atan2(r[0][0])) } else { ((-r[1][2]).atan2(r[1][1]), 0.0) }; + // atan2 rather than asin: near ±90 degrees asin loses precision, while cos(y) stays exact. + let cy = r[0][0].hypot(r[1][0]); + let y = (-r[2][0]).atan2(cy); + let (x, z) = if cy > 1e-9 { (r[2][1].atan2(r[2][2]), r[1][0].atan2(r[0][0])) } else { ((-r[1][2]).atan2(r[1][1]), 0.0) }; Some(([x.to_degrees(), y.to_degrees(), z.to_degrees()], factor)) } diff --git a/crates/engine/tests/parenting_3d_position.rs b/crates/engine/tests/parenting_3d_position.rs index 8c06cf8ad..6bf0c4ff1 100644 --- a/crates/engine/tests/parenting_3d_position.rs +++ b/crates/engine/tests/parenting_3d_position.rs @@ -383,6 +383,29 @@ fn parenting_static_orientation_handles_signed_uniform_scale_and_gimbal_order() assert!(max_corners < 1e-8 && max_pixels < 2e-5, "static uniform pose must use the existing Euler order including signed scale and gimbal angles"); } +/// A static Orientation just off gimbal lock (Y within 1e-6 degrees of ±90) keeps its world pose +/// under a uniform parent. Recovering Euler angles from the matrix must not lose precision there. +#[test] +fn parenting_static_orientation_is_exact_just_off_gimbal_lock() { + let mut max_corners = 0.0f64; + for factor in [120.0, -120.0] { + for y in [90.0 - 1e-6, -90.0 + 1e-6] { + let (mut s, p, c) = fixture(true, true); + set(&mut s, p, "transform/orientation", json!([0.0, 0.0, 0.0])); + set(&mut s, p, "transform/rotationX", json!(0.0)); + set(&mut s, p, "transform/rotationY", json!(0.0)); + set(&mut s, p, "transform/rotation", json!(42.0)); + set(&mut s, p, "transform/scale", json!([factor, factor, factor])); + set(&mut s, c, "transform/orientation", json!([23.0, y, -37.0])); + set(&mut s, c, "transform/scale", json!([200.0, 160.0, 150.0])); + let before = world(&s, c, Tick::ZERO); + parent(&mut s, c, Some(p), true); + max_corners = max_corners.max(displacement(before, world(&s, c, Tick::ZERO))); + } + } + assert!(max_corners < 1e-9, "a static Orientation near gimbal lock must keep its world pose: corners {max_corners}"); +} + #[test] fn parenting_animated_orientation_and_nonuniform_parent_keep_authored_pose_boundary() { for animated in [true, false] { From b2cf845624a7065cc18ce26e505f6a3abf669967 Mon Sep 17 00:00:00 2001 From: redonion23 <199118131+redonion23@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:35:49 +0200 Subject: [PATCH 133/181] Scripting: key calls fetch only the key they need, so removeKey stays fast (#814) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every key call (removeKey, keyTime, keyValue, setInterpolationTypeAtKey…) looked its key up through `__keys()`, which serialised the whole key list, eases and tangents included, on each call. Removing keys one by one was therefore quadratic: about 100 ms per removeKey on a 2,000-key property. The `keys` query now takes an optional `index` and returns just that key with the count; `__key` uses it. removeKey on 2,000 keys drops from 103.7 ms to 0.4 ms (release, Windows 11 aarch64). Co-Authored-By: Claude Opus 5.5 --- crates/script/src/model.rs | 41 ++++++++++++++++++------------------ crates/script/src/prelude.js | 7 +++--- crates/script/src/tests.rs | 18 ++++++++++++++++ 3 files changed, 43 insertions(+), 23 deletions(-) diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index 4445fbbf2..419be0c1c 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -502,27 +502,28 @@ fn keys(s: &Session, a: &J) -> R { .collect(); J::Array(v) }; - let out: Vec = p - .keys - .iter() - .enumerate() - .map(|(i, k)| { - // Spatial tangents as the motion path plays (auto-Bezier and continuous keys too). - let (spatial_in, spatial_out) = if p.spatial { effectcraft_keyframe::spatial_tangents(&p.keys, i) } else { (k.spatial_in, k.spatial_out) }; - json!({ - "time": l.comp_time(k.time).seconds(), - "layerTime": k.time.seconds(), - "value": js_value(c, &k.value), - "inInterp": interp(k.in_interp), "outInterp": interp(k.out_interp), - "inEase": ease(i, false), "outEase": ease(i, true), - "continuous": k.continuous, "autoBezier": k.auto_bezier, "roving": k.roving, - "spatialIn": spatial_in, "spatialOut": spatial_out, - "spatialContinuous": k.spatial_continuous, "spatialAutoBezier": k.spatial_auto, - "selected": sel.contains(&k.time), - }) + let key = |i: usize, k: &effectcraft_keyframe::Keyframe| -> J { + // Spatial tangents as the motion path plays (auto-Bezier and continuous keys too). + let (spatial_in, spatial_out) = if p.spatial { effectcraft_keyframe::spatial_tangents(&p.keys, i) } else { (k.spatial_in, k.spatial_out) }; + json!({ + "time": l.comp_time(k.time).seconds(), + "layerTime": k.time.seconds(), + "value": js_value(c, &k.value), + "inInterp": interp(k.in_interp), "outInterp": interp(k.out_interp), + "inEase": ease(i, false), "outEase": ease(i, true), + "continuous": k.continuous, "autoBezier": k.auto_bezier, "roving": k.roving, + "spatialIn": spatial_in, "spatialOut": spatial_out, + "spatialContinuous": k.spatial_continuous, "spatialAutoBezier": k.spatial_auto, + "selected": sel.contains(&k.time), }) - .collect(); - Ok(J::Array(out)) + }; + // `index` (zero-based): only that key, as `{count, key}` (`key` null when out of range), so + // looking keys up one at a time doesn't serialise the whole list each time (#814). + if let Some(i) = u(a, "index") { + let k = usize::try_from(i).ok().and_then(|i| p.keys.get(i).map(|k| key(i, k))); + return Ok(json!({"count": p.keys.len(), "key": k})); + } + Ok(J::Array(p.keys.iter().enumerate().map(|(i, k)| key(i, k)).collect())) } /// Apply `layer.setText` attribute keys to the Source Text document at `time` and return the new diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index 9daac6ab7..b50957152 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -828,10 +828,11 @@ Property.prototype.property = function () { return null; }; }; P.__keys = function () { return __get("keys", { comp: this.__layer.__comp, layer: this.__layer.__id, uid: this.__uid }); }; P.__key = function (i) { - var k = this.__keys(); i = __num(i, "keyIndex"); - if (i < 1 || i > k.length || Math.floor(i) !== i) throw __err("keyIndex " + i + " is out of range 1.." + k.length); - return k[i - 1]; + // Only the key asked for: fetching the whole list made every key call linear (#814). + var r = i >= 1 && Math.floor(i) === i ? __get("keys", { comp: this.__layer.__comp, layer: this.__layer.__id, uid: this.__uid, index: i - 1 }) : null; + if (!r || !r.key) throw __err("keyIndex " + i + " is out of range 1.." + (r ? r.count : this.numKeys)); + return r.key; }; // Script value → engine JSON for `prop.set` / `prop.addKey`. P.__toEngine = function (v, info, time) { diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 303bd820a..2cccbf788 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -828,6 +828,24 @@ fn script_remove_keys_batches_a_range_as_one_edit() { assert!(invalid.error.is_some()); } +/// #814: one key is looked up without serialising the whole list (removeKey on a property with +/// thousands of keys was linear in the key count), and the key calls behave as before. +#[test] +fn script_key_lookup_fetches_only_that_key() { + let mut s = session(); + let o = ok( + &mut s, + "var c = app.project.items.addComp('Keys', 100, 100, 1, 100, 25); var p = c.layers.addNull().transform.rotation;\ + var ts = [], vs = []; for (var i = 0; i < 2000; i++) { ts.push(i / 25); vs.push(i); } p.setValuesAtTimes(ts, vs);\ + var q = { comp: p.__layer.__comp, layer: p.__layer.__id, uid: p.__uid };\ + q.index = 2; var one = __get('keys', q); q.index = 2000; var none = __get('keys', q);\ + p.removeKey(p.numKeys); p.removeKey(1);\ + var err = ''; try { p.keyTime(1999); } catch (e) { err = e.message; }\ + [one.count, one.key.time, none.key, p.numKeys, p.keyTime(1), p.keyValue(1998), err]", + ); + assert_eq!(o.result, json!([2000, 0.08, null, 1998, 0.04, 1998, "keyIndex 1999 is out of range 1..1998"])); +} + #[test] fn script_layer_comment_and_unnamed_marker_params_round_trip() { let mut s = session(); From 8eece41c7efed630ef0e66cc9153d4a22592ef67 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 12 Oct 2026 19:41:35 +0200 Subject: [PATCH 134/181] #1063: keep the Gradient Ramp inside the layer's alpha The Gradient Ramp wrote alpha 1.0 to every pixel, so a shape layer's transparent area filled up with the ramp and the layer's bounds showed instead of the shape. The ramp now uses the pixel's own alpha on the CPU and in the GPU kernel, as the #939 fix does for the noise effects. A regression test checks that transparent pixels stay transparent at two Blend With Original values. Co-Authored-By: Claude Haiku 5.5 --- crates/effects/src/generate.rs | 22 +++++++++++++++++++++- crates/gpu/src/shaders/kernels.wgsl | 3 ++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/effects/src/generate.rs b/crates/effects/src/generate.rs index 2b543352d..b29ff9151 100644 --- a/crates/effects/src/generate.rs +++ b/crates/effects/src/generate.rs @@ -44,7 +44,9 @@ fn gradient_ramp(ctx: &EffectCtx, mut b: Buf) -> Buf { } let t = t.clamp(0.0, 1.0); let c = [c0[0] + (c1[0] - c0[0]) * t, c0[1] + (c1[1] - c0[1]) * t, c0[2] + (c1[2] - c0[2]) * t]; - put(px, c, 1.0, blend); + // The ramp fills only where the layer has alpha (#1063), so it keeps the source alpha. + let a = px[3]; + put(px, c, a, blend); } }); b @@ -400,6 +402,24 @@ mod generator_tests { Image::filled(32, 32, [1.0, 0.0, 0.0, 1.0]) } + #[test] + fn gradient_ramp_stays_inside_the_layer_alpha() { + // #1063: a shape's transparent pixels stay transparent; the ramp fills only the shape. + let mut img = Image::filled(40, 24, [1.0, 0.0, 0.0, 1.0]); + for (i, p) in img.data.iter_mut().enumerate() { + let a = if i % 40 < 10 { 0.0 } else { 1.0 }; + *p = p.map(|c| c * a); + } + for blend in [0.0, 50.0] { + let o = run_fx("ec.generate.gradientramp", &[("blend", num(blend))], img.clone(), 0.0, EffectEnv::default()); + for (src, out) in img.data.iter().zip(&o.img.data) { + assert!((src[3] - out[3]).abs() < 1e-6, "blend {blend}: alpha {} became {}", src[3], out[3]); + assert!(out[..3].iter().all(|&c| c <= out[3] + 1e-6), "blend {blend}: premultiplied colour above alpha"); + } + assert!(o.img.data.iter().any(|p| p[3] > 0.99), "blend {blend}: the ramp is drawn inside the shape"); + } + } + #[test] fn checkerboard_blending_none_shows_pattern_only_and_size_from_width() { let set = [("width", num(8.0)), ("height", num(2.0))]; diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index 406472e72..defbb5bf4 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -806,7 +806,8 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { t = clamp(t, 0.0, 1.0); let c0 = P.f[1].xyz; let c1 = P.f[2].xyz; - px = put(px, c0 + (c1 - c0) * t, 1.0, P.f[3].z); + // Keep the source alpha, as the CPU path does (#1063): the ramp fills only the shape. + px = put(px, c0 + (c1 - c0) * t, px.w, P.f[3].z); } case 10u: { let contrast = P.f[0].x; From 5e97eaddba3678a4301aba3b694336b0b128e73a Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 12 Oct 2026 20:00:52 +0200 Subject: [PATCH 135/181] #1063: find a Levels alpha control on the effect by its name Pro Levels2 keeps its Alpha controls one group down, under Alpha, and a script's effect.property("Alpha Input White") only searched the effect's direct children, so it returned null. The lookup now searches the groups nested below the direct children, up to three levels, after the direct match fails. Direct children keep their priority. A regression test checks the name on the effect and the same control through the Alpha group. Co-Authored-By: Claude Haiku 5.5 --- crates/script/src/model.rs | 49 +++++++++++++++++++++++++------------- crates/script/src/tests.rs | 18 ++++++++++++++ 2 files changed, 51 insertions(+), 16 deletions(-) diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index 4445fbbf2..dfdf458c9 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -402,6 +402,38 @@ fn alias(k: &str) -> Option<&'static str> { }) } +/// How many groups below a property group a string key may search (#1063: Pro Levels2's Alpha +/// controls sit one group down, under Alpha). +const NESTED_CHILD_DEPTH: u8 = 3; + +/// The child of `g` that a string key names. The direct children are searched first, then the +/// groups nested below them, up to `depth` levels. +fn find_child<'a>(l: &'a Layer, g: &'a PropGroup, k: &str, depth: u8) -> Option<&'a Node> { + let chain_of = |n: &Node| -> String { + let mut ch = if g.uid == l.props.uid { vec![] } else { chain(l, g.uid).unwrap_or_default() }; + ch.push(n); + matchnames::node(&ch) + }; + let lk = loose(k); + g.children + .iter() + .find(|n| chain_of(n) == k) + .or_else(|| g.children.iter().find(|n| n.name() == k)) + .or_else(|| g.children.iter().find(|n| n.match_id() == k)) + .or_else(|| g.children.iter().find(|n| n.name().eq_ignore_ascii_case(k))) + .or_else(|| g.children.iter().find(|n| loose(n.name()) == lk || loose(n.match_id()) == lk)) + .or_else(|| alias(&lk).and_then(|m| g.children.iter().find(|n| n.match_id() == m))) + .or_else(|| { + if depth == 0 { + return None; + } + g.children.iter().find_map(|n| match n { + Node::Group(sub) => find_child(l, sub, k, depth - 1), + Node::Prop(_) => None, + }) + }) +} + fn child(s: &Session, a: &J) -> R { let (_, c, l) = layer_of(s, a)?; let uid = u(a, "uid").unwrap_or(0); @@ -409,22 +441,7 @@ fn child(s: &Session, a: &J) -> R { let key = a.get("key").cloned().unwrap_or(J::Null); let found = match &key { J::Number(n) => n.as_u64().and_then(|i| g.children.get((i as usize).checked_sub(1)?)), - J::String(k) => { - let chain_of = |n: &Node| -> String { - let mut ch = if uid == 0 || uid == l.props.uid { vec![] } else { chain(l, uid).unwrap_or_default() }; - ch.push(n); - matchnames::node(&ch) - }; - let lk = loose(k); - g.children - .iter() - .find(|n| chain_of(n) == *k) - .or_else(|| g.children.iter().find(|n| n.name() == k)) - .or_else(|| g.children.iter().find(|n| n.match_id() == k)) - .or_else(|| g.children.iter().find(|n| n.name().eq_ignore_ascii_case(k))) - .or_else(|| g.children.iter().find(|n| loose(n.name()) == lk || loose(n.match_id()) == lk)) - .or_else(|| alias(&lk).and_then(|m| g.children.iter().find(|n| n.match_id() == m))) - } + J::String(k) => find_child(l, g, k, NESTED_CHILD_DEPTH), _ => None, }; match found { diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 303bd820a..27812b825 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -1010,3 +1010,21 @@ fn scripted_text_range_selector_resolves_and_is_the_only_selector() { ); assert_eq!(o.result, json!([1, "ADBE Text Selector", 50, 100, "ADBE Text Percent Offset"])); } + +/// Pro Levels2's Alpha controls sit one group down (under Alpha), so a script finds them by their +/// name on the effect as well as through the group (#1063). +#[test] +fn scripted_levels_alpha_controls_resolve_from_the_effect() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Levels", 320, 180, 1, 3, 30); + var l = c.layers.addSolid([1, 0, 0], "S", 320, 180, 1); + var fx = l.property("ADBE Effect Parade").addProperty("ADBE Pro Levels2"); + fx.property("Alpha Input White").setValue(0.5); + [fx.property("Alpha").property("Alpha Input White").value, fx.property("Alpha Input White").value] + "#, + ); + assert_eq!(o.result, json!([0.5, 0.5])); +} From 64945f2ef91797c48a624c1fbc9f89ca79b1c0a7 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 12 Oct 2026 20:22:06 +0200 Subject: [PATCH 136/181] #1063: report an undefined name at its first use inside a function wrapper Inside a function wrapper such as (function () { ... })(), boa places a ReferenceError at the start of the function, so a script's error was reported at the wrapper's first line rather than the line that failed. The first use of the undefined name in the code is now the position, and boa's frames remain the fallback. Thrown errors and object-model errors already report their own lines. A regression test covers a thrown error, an undefined name, and an object-model error inside a wrapper. Co-Authored-By: Claude Haiku 5.5 --- crates/script/src/runtime.rs | 47 ++++++++++++++++++++++-------------- crates/script/src/tests.rs | 18 ++++++++++++++ 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index c75598565..5fad8a070 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -547,9 +547,9 @@ pub(crate) fn script_error(e: &boa_engine::JsError, ctx: &mut Context, file: &st ScriptError { message, line, column, file: file.to_string() } } -/// Line and column of an error: `at line N, col M` (syntax errors), else the innermost backtrace -/// frame in the user's script (`file:line:col`), else (names boa can't place) the first use of -/// the undefined name in `code`. +/// Line and column of an error: `at line N, col M` (syntax errors), else the first use of an +/// undefined name in `code` (boa places those at a function's start), else the innermost backtrace +/// frame in the user's script (`file:line:col`). fn position(msg: &str, file: &str, code: &str) -> (Option, Option) { if let Some(i) = msg.find("at line ") { let rest = &msg[i + 8..]; @@ -563,28 +563,39 @@ fn position(msg: &str, file: &str, code: &str) -> (Option, Option) { let mut it = l[i..].split([':', ')']); Some((it.next()?.parse().ok()?, it.next()?.parse().ok()?)) }; + // `ReferenceError: name is not defined`: boa places it at the start of the function that reads + // the name, so the first use of the name in the code is the better position (#1063). + let first = msg.lines().next().unwrap_or(""); + let undefined = first.split("ReferenceError: ").nth(1).and_then(|r| r.split(" is not defined").next()); + if let Some(at) = undefined.and_then(|name| first_use(name, code)) { + return (Some(at.0), Some(at.1)); + } let frames: Vec<&str> = msg.lines().skip(1).collect(); if let Some((a, b)) = frames.iter().find_map(|l| pick(l)).or_else(|| msg.lines().next().and_then(pick)) { return (Some(a), Some(b)); } - // `ReferenceError: name is not defined` without a position. - let first = msg.lines().next().unwrap_or(""); - if let Some(name) = first.split("ReferenceError: ").nth(1).and_then(|r| r.split(" is not defined").next()) { - for (n, line) in code.lines().enumerate() { - let b = line.as_bytes(); - let mut from = 0; - while let Some(i) = line[from..].find(name).map(|i| i + from) { - let before = i.checked_sub(1).map(|j| b[j]); - let after = b.get(i + name.len()).copied(); - let ident = |c: u8| c.is_ascii_alphanumeric() || c == b'_' || c == b'$'; - if !before.is_some_and(|c| ident(c) || c == b'.') && !after.is_some_and(ident) { - return (Some(n as u32 + 1), Some(i as u32 + 1)); - } - from = i + name.len(); + (None, None) +} + +/// The first use of `name` as a whole identifier in `code`, as a 1-based line and column. +fn first_use(name: &str, code: &str) -> Option<(u32, u32)> { + if name.is_empty() { + return None; + } + let ident = |c: u8| c.is_ascii_alphanumeric() || c == b'_' || c == b'$'; + for (n, line) in code.lines().enumerate() { + let b = line.as_bytes(); + let mut from = 0; + while let Some(i) = line.get(from..).and_then(|rest| rest.find(name)).map(|i| i + from) { + let before = i.checked_sub(1).and_then(|j| b.get(j).copied()); + let after = b.get(i + name.len()).copied(); + if !before.is_some_and(|c| ident(c) || c == b'.') && !after.is_some_and(ident) { + return Some((n as u32 + 1, i as u32 + 1)); } + from = i + name.len(); } } - (None, None) + None } /// Evaluate `code` in `ctx` and convert the result. diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 303bd820a..a54e5742d 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -142,6 +142,24 @@ fn errors_report_line_and_column() { assert_eq!(e.line, Some(3), "{e:?}"); } +/// A script wrapped in a function (the usual `(function () { ... })();` shape) reports the line +/// that failed, not the wrapper's line (#1063). +#[test] +fn errors_inside_a_function_wrapper_report_the_failing_line() { + let mut s = session(); + let o = run_code(&mut s, "(function () {\n var a = 1;\n throw new Error('inside');\n})();\n", "thrown.jsx"); + let e = o.error.expect("thrown"); + assert_eq!(e.message, "inside"); + assert_eq!(e.line, Some(3), "{e:?}"); + let o = run_code(&mut s, "(function () {\n var a = 1;\n nope.call();\n})();\n", "reference.jsx"); + let e = o.error.expect("reference error"); + assert_eq!(e.line, Some(3), "{e:?}"); + let o = run_code(&mut s, "(function () {\n var c = app.project.items.addComp('C', 10, 10, 1, 1, 24);\n c.layer(5);\n})();\n", "range.jsx"); + let e = o.error.expect("range error"); + assert!(e.message.contains("out of range"), "{e:?}"); + assert_eq!(e.line, Some(3), "{e:?}"); +} + #[test] fn match_names_and_lookups() { let mut s = session(); From a26f1a374925f8a4f02da2dadb805c72762ab416 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 12 Oct 2026 22:47:58 +0200 Subject: [PATCH 137/181] #551: drop the Shift+Space default from the preview parity gaps Shift+Spacebar now plays every frame by default, so it is no longer a gap in docs/ui-parity.md. Co-Authored-By: Claude Haiku 5.5 --- docs/ui-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 3654f61b7..8c1119720 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -27,7 +27,7 @@ feel and platform differences, and the 26.x interface changes are not in yet. | Keyboard shortcuts | After Effects' defaults; Keyboard Shortcuts editor that rebinds and saves sets | 75% | Shortcut editor in Settings; adding shortcuts fails on Linux (#467); requests for more (#428, #336); no shortcuts for scripts | | Drag and drop | Project → Timeline / viewer / New Comp / Render Queue; effects onto layers; files from the OS | 65% | `ui_drag_drop`; OS drops fail on Wayland (winit) and some Linux setups (#268); multi-file drops (#552, #351); crash dragging over the import dialog (#511) | | Text editing on canvas | Point and paragraph text, per-character styles, Character / Paragraph panels, OpenType and variable axes | 70% | `ui_text_edit`, `ui_font_search`; paragraph alignment inverted (#316); font search by typing (#583); toolbar font options (#550); the font menus' variable-font filter (26.3) landed (#741) | -| Preview feel | Spacebar / Numpad 0 previews, RAM preview, cache bars, adaptive resolution | 55% | `ui_ram_preview`, `ui_live_preview`; stutter (#595, #304), scrubbing lag (#329), Shift+Space default (#551) | +| Preview feel | Spacebar / Numpad 0 previews, RAM preview, cache bars, adaptive resolution | 55% | `ui_ram_preview`, `ui_live_preview`; stutter (#595, #304), scrubbing lag (#329) | | Home screen, Learn, templates | Home with recent projects, template gallery, interactive Learn tutorials | 70% | Layout nitpicks (#581); Home literals untranslated (#574) | | Quick Apply (26.2) | Search to apply effects, presets or commands | partial | A command palette exists; not After Effects' Quick Apply | | Accessibility | AccessKit enabled; focus labels tested; Project panel keyboard navigation and accessible names (#776) | 20% | No screen-reader pass or keyboard-navigation audit of the other panels; no contrast check | From 5530560fb8b646560b057706343b3af0e79de899 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Tue, 13 Oct 2026 01:53:35 +0200 Subject: [PATCH 138/181] #1063: find a light's Casts Shadows option by its After Effects name A script asks a light's options for "ADBE Casts Shadows", which returned null. The light's option is named "Casts Shadows" here, so the lookup never matched. The name is now an alias for the castsShadows property, and a regression test checks the lookup returns the property. Co-Authored-By: Claude Haiku 5.5 --- crates/script/src/model.rs | 2 ++ crates/script/src/tests.rs | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index 4445fbbf2..3c44e1641 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -393,6 +393,8 @@ fn alias(k: &str) -> Option<&'static str> { "content" | "contents" => "contents", "maskshape" | "maskpath" => "path", "maskfeather" => "feather", + // A light's options name it "ADBE Casts Shadows" in After Effects (#1063). + "adbecastsshadows" => "castsShadows", "maskopacity" => "opacity", "maskexpansion" | "maskoffset" => "expansion", "sourcetext" => "sourceText", diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 303bd820a..594b93044 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -1010,3 +1010,19 @@ fn scripted_text_range_selector_resolves_and_is_the_only_selector() { ); assert_eq!(o.result, json!([1, "ADBE Text Selector", 50, 100, "ADBE Text Percent Offset"])); } + +/// #1063: a script asks a light's options for After Effects' "ADBE Casts Shadows". +#[test] +fn light_options_accept_the_after_effects_casts_shadows_name() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var comp = app.project.items.addComp("Repro", 1920, 1080, 1, 10, 25); + var light = comp.layers.addLight("Key", [100, 100]); + var c = light.property("ADBE Light Options Group").property("ADBE Casts Shadows"); + c == null ? "null" : c.name + "#, + ); + assert_ne!(o.result, json!("null"), "{:?}", o.result); +} From 56f6612f78b8c471df3c09b93d6eb3a919392828 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 02:02:46 +0200 Subject: [PATCH 139/181] Dialogs: drag a modal dialog by its title to move it off the viewer (fixes #1155) --- crates/ui-egui/src/panels/dialogs.rs | 17 +++++++++++++++-- crates/ui-egui/tests/ui_tail.rs | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index e95a667ec..4560b5659 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -178,14 +178,27 @@ fn modal_with_scroll(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tok /// window. pub(crate) fn modal_fit(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui, f32)) { let size = size.min(modal_space(ctx)); - egui::Modal::new(egui::Id::new(("modal", title))) + let id = egui::Id::new(("modal", title)); + // Dragging the title row moves the dialog off what it edits. The offset is kept within the + // window, so dragging back never has to undo a part the dialog could not follow. + let offset_id = id.with("offset"); + let room = ((ctx.content_rect().size() - size) * 0.5).max(vec2(0.0, 0.0)); + let offset = ctx.data(|d| d.get_temp::(offset_id)).unwrap_or_default().clamp(-room, room); + egui::Modal::new(id) + .area(egui::Modal::default_area(id).anchor(Align2::CENTER_CENTER, offset)) .backdrop_color(Color32::from_black_alpha(120)) .frame(egui::Frame::window(&ctx.style_of(ctx.theme())).fill(t.panel_bg).inner_margin(egui::Margin::same(18))) .show(ctx, |ui| { ui.set_width(size.x - 36.0); ui.set_min_height(size.y - 36.0); // The id stays keyed on the untranslated source, so switching language cannot move it. - ui.label(egui::RichText::new(tr(title)).font(Tokens::semibold(15.0)).color(t.tab_text_active)); + let head = ui.label(egui::RichText::new(tr(title)).font(Tokens::semibold(15.0)).color(t.tab_text_active)); + let row = Rect::from_x_y_ranges(ui.max_rect().x_range(), head.rect.y_range()); + let grip = ui.interact(row, id.with("grip"), Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab); + if grip.dragged() { + let moved = (offset + grip.drag_delta()).clamp(-room, room); + ui.ctx().data_mut(|d| d.insert_temp(offset_id, moved)); + } ui.add_space(10.0); let title_h = ui.cursor().top() - ui.min_rect().top(); body(ui, (size.y - 36.0 - title_h).max(40.0)); diff --git a/crates/ui-egui/tests/ui_tail.rs b/crates/ui-egui/tests/ui_tail.rs index aa4128cbf..f7e49ae0b 100644 --- a/crates/ui-egui/tests/ui_tail.rs +++ b/crates/ui-egui/tests/ui_tail.rs @@ -103,6 +103,24 @@ fn layer_style_dialog_adds_previews_and_cancels() { assert_eq!(styles(&mut h), ["stroke"]); } +#[test] +fn layer_style_dialog_moves_by_its_title() { + let mut h = harness(1400.0); + let lid = h.state().session.active_comp().unwrap().layers[0].id.0; + h.state_mut().session.execute("layer.select", json!({"layers": [lid]})).unwrap(); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.style.dropShadow", json!({})).unwrap(); + h.run_steps(3); + let list0 = rect(&h, "dialog.layerStyle.list.blendingOptions"); + // The title row sits just above the style list. + let grab = pos2(list0.center().x, list0.min.y - 20.0); + drag(&mut h, grab, grab + vec2(-150.0, 60.0)); + h.run_steps(2); + let list1 = rect(&h, "dialog.layerStyle.list.blendingOptions"); + assert!((list1.min - list0.min - vec2(-150.0, 60.0)).length() < 2.0, "the dialog followed the drag: {:?} → {:?}", list0.min, list1.min); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::LayerStyles), "dragging does not close it"); +} + #[test] fn project_panel_columns_scroll_horizontally() { let mut h = harness(1400.0); From 9e8112499c99e002e6d76991c9ce53a1fc2d2f54 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Tue, 13 Oct 2026 02:08:59 +0200 Subject: [PATCH 140/181] #1167: test: a marker edit keeps the viewer frames (fails on main; reproduces the audio pause) Co-Authored-By: Claude Haiku 5.5 --- crates/ui-egui/src/frames.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 9a3398d62..ae0c54971 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -1970,4 +1970,19 @@ mod tests { switch(&mut s, inner, solid, "video", true); assert_ne!(at(&s), o1); } + + #[test] + fn markers_keep_the_frames() { + use effectcraft_engine::Session; + use serde_json::json; + let mut s = Session::default(); + let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap(); + let outer = ItemId(id(s.execute("comp.new", json!({"name": "Outer", "width": 8, "height": 8, "duration": 1})).unwrap(), "comp")); + let solid = id(s.execute("layer.newSolid", json!({"comp": outer.0, "color": "#ff0000"})).unwrap(), "layer"); + let f = Frames::default(); + let at = |s: &Session| f.content_of(&s.project, s.revision, outer); + let o0 = at(&s); + s.execute("layer.addMarker", json!({"comp": outer.0, "layers": [solid], "time": 0.5, "comment": "beat"})).unwrap(); + assert_eq!(at(&s), o0, "a marker changes no pixels, so it keeps the frames"); + } } From 863a6aff6cc6828088367a7748f6943e9d086978 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Tue, 13 Oct 2026 02:19:47 +0200 Subject: [PATCH 141/181] #1167: frames: keep the viewer frames when only plain markers change Adding a marker replaced the comp, so every cached frame stopped matching and the preview had to re-render before its audio could carry on. Plain markers change no pixels; protected time markers with a duration still do (Responsive Design). Markers count in full again when a project uses expressions, which can read them. Co-Authored-By: Claude Haiku 5.5 --- crates/project/src/lib.rs | 17 +++++++++++++++++ crates/ui-egui/src/frames.rs | 37 +++++++++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index fee3b8ce7..c9b49147e 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -865,6 +865,23 @@ impl Comp { && layers.iter().zip(&o.layers).all(|(a, b)| a.same_pixels(b)) } + /// Draws the same pixels as `o` once plain markers are ignored: they change no pixels. Protected + /// time markers with a duration set the timing (Responsive Design), so those still count. + pub fn same_pixels_ignoring_markers(&self, o: &Comp) -> bool { + let protected = + |m: &[Marker]| -> Vec<(Tick, Tick)> { m.iter().filter(|m| m.protected && m.duration > Tick::ZERO).map(|m| (m.time, m.duration)).collect() }; + if protected(&self.markers) != protected(&o.markers) { + return false; + } + let mut c = self.clone(); + c.markers = o.markers.clone(); + for (a, b) in c.layers.iter_mut().zip(&o.layers) { + a.markers = b.markers.clone(); + a.markers_locked = b.markers_locked; + } + c.same_pixels(o) + } + /// The comp with those switches and the Resolution at their defaults (borrowed when they /// already are), for keys that hash the whole comp. pub fn pixel_form(&self) -> std::borrow::Cow<'_, Comp> { diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index ae0c54971..059bc3a03 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -291,6 +291,23 @@ pub fn comp_content(project: &Project, comp: ItemId) -> u64 { comp_content_with(project, comp, |_, c| Arc::as_ptr(c)) } +/// Whether two versions of a comp draw the same pixels. Markers only count when an expression +/// could read them (`markers_count`). +fn same_pixels(a: &Comp, b: &Comp, markers_count: bool) -> bool { + if markers_count { a.same_pixels(b) } else { a.same_pixels_ignoring_markers(b) } +} + +/// Whether a layer anywhere in the project has an expression, which can read markers. +fn project_has_expressions(project: &Project) -> bool { + fn has_expression(g: &PropGroup) -> bool { + g.children.iter().any(|c| match c { + Node::Prop(p) => p.expr.is_some(), + Node::Group(sub) => has_expression(sub), + }) + } + project.items.values().any(|i| matches!(&i.kind, ItemKind::Comp(c) if c.layers.iter().any(|l| has_expression(&l.props)))) +} + /// [`comp_content`] with each comp's address given by `addr`. fn comp_content_with(project: &Project, comp: ItemId, addr: impl Fn(ItemId, &Arc) -> *const Comp) -> u64 { use std::hash::{Hash, Hasher}; @@ -703,10 +720,12 @@ impl Frames { fn identity(&self, project: &Arc, comp: ItemId) -> Identity { let plain = comp_content(project, comp); let Ok(mut bases) = self.bases.lock() else { return (plain, None) }; + // Markers can only change pixels through expressions, which read them. + let markers_count = project_has_expressions(project); if let Some((base_id, base)) = bases.get(&comp.0) && *base_id != plain && comp_content_with(project, comp, |id, c| match base.item(id).map(|i| &i.kind) { - Some(ItemKind::Comp(b)) if Arc::ptr_eq(b, c) || b.same_pixels(c) => Arc::as_ptr(b), + Some(ItemKind::Comp(b)) if Arc::ptr_eq(b, c) || same_pixels(b, c, markers_count) => Arc::as_ptr(b), _ => Arc::as_ptr(c), }) == *base_id { @@ -1985,4 +2004,20 @@ mod tests { s.execute("layer.addMarker", json!({"comp": outer.0, "layers": [solid], "time": 0.5, "comment": "beat"})).unwrap(); assert_eq!(at(&s), o0, "a marker changes no pixels, so it keeps the frames"); } + + #[test] + fn markers_count_when_an_expression_can_read_them() { + use effectcraft_engine::Session; + use serde_json::json; + let mut s = Session::default(); + let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap(); + let outer = ItemId(id(s.execute("comp.new", json!({"name": "Outer", "width": 8, "height": 8, "duration": 1})).unwrap(), "comp")); + let solid = id(s.execute("layer.newSolid", json!({"comp": outer.0, "color": "#ff0000"})).unwrap(), "layer"); + s.execute("prop.setExpression", json!({"comp": outer.0, "layer": solid, "path": "transform/opacity", "expression": "1"})).unwrap(); + let f = Frames::default(); + let at = |s: &Session| f.content_of(&s.project, s.revision, outer); + let o0 = at(&s); + s.execute("layer.addMarker", json!({"comp": outer.0, "layers": [solid], "time": 0.5, "comment": "beat"})).unwrap(); + assert_ne!(at(&s), o0, "an expression can read the marker, so the frames change"); + } } From 63e5068f7cecb7423ce1333595457ced17271a41 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Tue, 13 Oct 2026 02:36:16 +0200 Subject: [PATCH 142/181] #1169: blend the two samples around each time when a layer is stretched Stretched layers copied the nearest source sample for each output sample, which adds error: at 48 kHz the worst error on a 440 Hz tone was 0.025, and 0.15 at 8 kHz. They now blend the two source samples either side. Layers at 100% and 50% speed keep their exact samples. The render tests cover a plain precomp, which mixes the same audio as its layers, and the stretch error against the exact tone. Co-Authored-By: Claude Haiku 5.5 --- crates/render/src/audio.rs | 105 +++++++++++++++++++++++++++++++++++-- 1 file changed, 102 insertions(+), 3 deletions(-) diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index 4f2ffc52e..68be63fe4 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -158,12 +158,17 @@ impl MixEnv<'_> { }; let same = (speed - 1.0).abs() < 1e-9; for i in 0..m { - let j = if same { i } else { ((i as f64 * speed) as usize).min(src_n - 1) }; + // The source sample at fractional index `x`, blended linearly with the next one unless the + // layer plays at its own speed (then the samples are copied as they are). + let x = i as f64 * speed; + let j = if same { i } else { (x as usize).min(src_n - 1) }; if j * 2 + 1 >= buf.len() { break; } - out[(a + i) * 2] = buf[j * 2]; - out[(a + i) * 2 + 1] = buf[j * 2 + 1]; + let k = if same || (j + 1) * 2 + 1 >= buf.len() { j } else { j + 1 }; + let f = if same { 0.0 } else { ((x - j as f64) as f32).clamp(0.0, 1.0) }; + out[(a + i) * 2] = buf[j * 2] + (buf[k * 2] - buf[j * 2]) * f; + out[(a + i) * 2 + 1] = buf[j * 2 + 1] + (buf[k * 2 + 1] - buf[j * 2 + 1]) * f; } out } @@ -357,4 +362,98 @@ mod tests { let i = 700 * 2; assert!((m[i] - 0.375).abs() < 1e-3 && (m[i + 1] + 0.25).abs() < 1e-3, "{} {}", m[i], m[i + 1]); } + + /// A 440 Hz tone, so a change in the mix shows in the samples. + struct Tone; + impl FootageSource for Tone { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn audio(&self, _: ItemId, _: &Footage, start: Tick, frames: usize, rate: u32) -> Option> { + let t0 = start.seconds(); + Some( + (0..frames) + .flat_map(|k| { + let v = (std::f64::consts::TAU * 440.0 * (t0 + k as f64 / rate as f64)).sin() as f32 * 0.5; + [v, v] + }) + .collect(), + ) + } + } + + #[test] + fn a_plain_precomp_mixes_the_same_audio_as_its_layers() { + let mut p = Project::default(); + let f = Footage { + path: "tone.wav".into(), + kind: FootageKind::Audio, + has_audio: true, + frame_rate: FrameRate::new(25, 1), + duration: Tick::from_seconds_f64(10.0), + ..Default::default() + }; + let fid = p.add_item("tone.wav", effectcraft_color::Label::SeaFoam, None, ItemKind::Footage(f)); + let base = Comp::new(64, 64, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0)); + let tone = build::layer(&mut p, &base, "tone", LayerSource::Footage { item: fid }, (0, 0), None); + let mut inner = base.clone(); + inner.layers = vec![tone]; + let inner_id = p.add_item("Inner", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(inner.into())); + let pre = build::layer(&mut p, &base, "pre", LayerSource::Comp { item: inner_id }, (64, 64), None); + let mut outer = base.clone(); + outer.layers = vec![pre]; + let outer_id = p.add_item("Outer", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(outer.into())); + let rate = 8000; + let t = Tick::from_seconds_f64(0.5); + let direct = mix_comp(&p, &Tone, None, inner_id, t, 4000, rate); + let nested = mix_comp(&p, &Tone, None, outer_id, t, 4000, rate); + assert_eq!(direct.len(), nested.len()); + let worst = direct.iter().zip(&nested).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); + assert!(worst < 1e-6, "a plain precomp changes its audio by {worst}"); + } + + /// The error of a tone mixed at `stretch` percent against the exact tone, at `rate` Hz. + fn stretch_error(stretch: f64, rate: u32) -> (f64, f64) { + let mut p = Project::default(); + let f = Footage { + path: "tone.wav".into(), + kind: FootageKind::Audio, + has_audio: true, + frame_rate: FrameRate::new(25, 1), + duration: Tick::from_seconds_f64(10.0), + ..Default::default() + }; + let fid = p.add_item("tone.wav", effectcraft_color::Label::SeaFoam, None, ItemKind::Footage(f)); + let base = Comp::new(64, 64, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0)); + let mut tone = build::layer(&mut p, &base, "tone", LayerSource::Footage { item: fid }, (0, 0), None); + tone.stretch = stretch; + let mut comp = base.clone(); + comp.layers = vec![tone]; + let cid = p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into())); + let n = 2000usize; + let t = Tick::from_seconds_f64(0.5); + let m = mix_comp(&p, &Tone, None, cid, t, n, rate); + let speed = 100.0 / stretch; + let (mut worst, mut sq) = (0.0f64, 0.0f64); + for i in 0..n { + // The source plays `speed` times as fast as the comp: source time = speed * comp time. + let ideal = (std::f64::consts::TAU * 440.0 * speed * (t.seconds() + i as f64 / rate as f64)).sin() * 0.5; + let e = (m[i * 2] as f64 - ideal).abs(); + worst = worst.max(e); + sq += e * e; + } + (worst, (sq / n as f64).sqrt()) + } + + #[test] + fn stretched_layers_resample_a_tone_with_small_error() { + // Blending the two samples around each time: about 0.0002 at 48 kHz and 0.007 at 8 kHz. Copying + // the nearest sample gave 0.025 and 0.15. + for stretch in [70.0, 33.0] { + let (worst, _) = stretch_error(stretch, 48_000); + assert!(worst < 1e-3, "stretch {stretch}%: error {worst} at 48 kHz"); + let (worst, _) = stretch_error(stretch, 8_000); + assert!(worst < 1e-2, "stretch {stretch}%: error {worst} at 8 kHz"); + } + } } From 98796a29b8c113476719325c7035c05675dd31d9 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Tue, 13 Oct 2026 02:47:54 +0200 Subject: [PATCH 143/181] #1063: the gradient ramp matches the CPU on the GPU, including transparent regions Co-Authored-By: Claude Haiku 5.5 --- crates/gpu/src/tests_fx_tone.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/gpu/src/tests_fx_tone.rs b/crates/gpu/src/tests_fx_tone.rs index 61c977d18..08212a12a 100644 --- a/crates/gpu/src/tests_fx_tone.rs +++ b/crates/gpu/src/tests_fx_tone.rs @@ -183,3 +183,8 @@ fn color_stabilizer() { } } } + +#[test] +fn gradient_ramp_matches_the_cpu() { + effect_case("ec.generate.gradientramp", &[]); +} From f816cd5e389d2b4e86967f5189f00f3098487d07 Mon Sep 17 00:00:00 2001 From: redonion23 <199118131+redonion23@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:04:12 +0200 Subject: [PATCH 144/181] Scripting: a stroke's Dashes use After Effects' match names (#815) Dashes reported its children as `dash`, `gap`, `offset`, so `property("ADBE Vector Stroke Dash 1")` was null and addProperty threw. They now report ADBE Vector Stroke Dash 1-3 / Gap 1-3 / Offset. addProperty with one of those names returns the existing property (EffectCraft's Dashes always holds Dash 1, Gap 1 and Offset) or adds the Dash/Gap 2 or 3 pair through shape.dashes.add. Lookups by the old ids still work. Co-Authored-By: Claude Opus 5.5 --- crates/script/src/matchnames.rs | 19 +++++++++++++++++++ crates/script/src/model.rs | 9 +++++++++ crates/script/src/prelude.js | 9 +++++++++ crates/script/src/tests.rs | 31 +++++++++++++++++++++++++++++++ 4 files changed, 68 insertions(+) diff --git a/crates/script/src/matchnames.rs b/crates/script/src/matchnames.rs index d8a8f3aae..60a40e695 100644 --- a/crates/script/src/matchnames.rs +++ b/crates/script/src/matchnames.rs @@ -72,6 +72,22 @@ const TEXT_PATH_OPTIONS: &[(&str, &str)] = &[ ("lastMargin", "ADBE Text Last Margin"), ]; +// A stroke's Dashes, as After Effects' scripting API names them (#815). +const STROKE_DASHES: &[(&str, &str)] = &[ + ("dash", "ADBE Vector Stroke Dash 1"), + ("gap", "ADBE Vector Stroke Gap 1"), + ("dash2", "ADBE Vector Stroke Dash 2"), + ("gap2", "ADBE Vector Stroke Gap 2"), + ("dash3", "ADBE Vector Stroke Dash 3"), + ("gap3", "ADBE Vector Stroke Gap 3"), + ("offset", "ADBE Vector Stroke Offset"), +]; + +/// The Dashes child id (`dash`, `gap2`, `offset`…) for an After Effects match name. +pub fn stroke_dash(name: &str) -> Option<&'static str> { + STROKE_DASHES.iter().find(|(_, m)| *m == name).map(|(id, _)| *id) +} + const TEXT_ANIMATOR: &[(&str, &str)] = &[("selectors", "ADBE Text Selectors"), ("properties", "ADBE Text Animator Properties")]; const TEXT_SELECTORS: &[(&str, &str)] = @@ -447,6 +463,9 @@ pub fn node(chain: &[&Node]) -> String { if let Some((_, _, m)) = SHAPE_PROPS.iter().find(|(item, prop, _)| *item == pid && *prop == id) { return m.to_string(); } + if pid == "dashes" { + return find(STROKE_DASHES, id).map(str::to_string).unwrap_or_else(own); + } own() } _ => own(), diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index 4445fbbf2..9aacdbb48 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -626,6 +626,15 @@ fn resolve_add(s: &Session, a: &J) -> R { } } "animators" => json!({"kind": "animator"}), + // A stroke always holds Dash 1, Gap 1 and Offset (Dash 0 draws no dashes); adding one of + // them returns it, Dash/Gap 2 and 3 are added as a pair (`shape.dashes.add`) (#815). + "dashes" if in_group => match matchnames::stroke_dash(name) { + Some(id) => match g.children.iter().find(|n| n.match_id() == id) { + Some(n) => json!({"kind": "existing", "uid": n.uid()}), + None => json!({"kind": "dash", "id": id, "stroke": l.props.parent_of(g.uid).map(|p| p.uid)}), + }, + None => json!({"error": format!("Can not add \"{name}\" to Dashes")}), + }, _ if in_contents && in_group => match matchnames::shape_kind(name) { Some(k) => { json!({"kind": "shape", "shape": k, "group": if l.props.sub("contents").is_some_and(|c| c.uid == g.uid) { J::Null } else { json!(g.uid) }}) diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index 9daac6ab7..1f57f9d64 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -715,6 +715,15 @@ PropertyGroup.prototype.addProperty = function (name) { var p = { comp: L.__comp, layer: L.__id, kind: r.shape }; if (r.group !== null && r.group !== undefined) p.group = r.group; uid = __call("layer.addShapeItem", p).uid; + } else if (r.kind === "existing") { + uid = r.uid; + } else if (r.kind === "dash") { + // Dash/Gap 2 and 3 come as a pair: add pairs until the one asked for exists. + var dp = { comp: L.__comp, layer: L.__id, prop: r.stroke }; + for (var n = 0; n < 3 && !this.property(name); n++) __call("shape.dashes.add", dp); + var added = this.property(name); + if (!added) throw __err("Can not add \"" + name + "\" to Dashes"); + return added; } else if (r.kind === "animator") { uid = __call("layer.addTextAnimator", { comp: L.__comp, layer: L.__id, properties: [] }).animator; } else if (r.kind === "animatorProperty") { diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 303bd820a..a4930f470 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -828,6 +828,37 @@ fn script_remove_keys_batches_a_range_as_one_edit() { assert!(invalid.error.is_some()); } +/// #815: a stroke's Dashes use After Effects' match names, and addProperty with them returns +/// Dash 1 / Gap 1 / Offset or adds the Dash 2 / Gap 2 pair. +#[test] +fn script_stroke_dashes_use_after_effects_match_names() { + let mut s = session(); + let o = ok( + &mut s, + "function names(g) { var a = []; for (var i = 1; i <= g.numProperties; i++) a.push(g.property(i).matchName); return a.join(','); }\ + var comp = app.project.items.addComp('Names', 100, 100, 1, 10, 25);\ + var contents = comp.layers.addShape().property('ADBE Root Vectors Group'); contents.addProperty('ADBE Vector Shape - Rect');\ + var d = contents.addProperty('ADBE Vector Graphic - Stroke').property('ADBE Vector Stroke Dashes');\ + var before = names(d);\ + var dash1 = d.addProperty('ADBE Vector Stroke Dash 1'); dash1.setValue(12);\ + var gap2 = d.addProperty('ADBE Vector Stroke Gap 2');\ + var err = ''; try { d.addProperty('ADBE Vector Stroke Dash 9'); } catch (e) { err = e.message; }\ + [before, dash1.matchName, d.property('ADBE Vector Stroke Dash 1').value, d.property('ADBE Vector Stroke Offset').matchName, gap2.matchName, names(d), err]", + ); + assert_eq!( + o.result, + json!([ + "ADBE Vector Stroke Dash 1,ADBE Vector Stroke Gap 1,ADBE Vector Stroke Offset", + "ADBE Vector Stroke Dash 1", + 12, + "ADBE Vector Stroke Offset", + "ADBE Vector Stroke Gap 2", + "ADBE Vector Stroke Dash 1,ADBE Vector Stroke Gap 1,ADBE Vector Stroke Dash 2,ADBE Vector Stroke Gap 2,ADBE Vector Stroke Offset", + "Can not add \"ADBE Vector Stroke Dash 9\" to Dashes" + ]) + ); +} + #[test] fn script_layer_comment_and_unnamed_marker_params_round_trip() { let mut s = session(); From 765915e126053523efd8d6b92623682135c41e36 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 12 Oct 2026 21:07:39 -0400 Subject: [PATCH 145/181] Workspaces: deleting the current workspace saves after switching to Default (#696) --- crates/ui-egui/src/menus.rs | 3 ++- crates/ui-egui/tests/ui_workspace_focus.rs | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b10b0e31c..e19e0c6d8 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1054,10 +1054,11 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu if app.ui.saved_workspaces.remove(&name).is_none() && !builtin { return Err(format!("no workspace `{name}`")); } - app.store_saved_workspaces(); + // Leave the deleted workspace first: a current workspace needs a definition. if app.ui.workspace == name { app.set_workspace("Default"); } + app.store_saved_workspaces(); return Ok(json!({"deleted": name})); } if let Some(new) = p.get("rename").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty() && *n != name) { diff --git a/crates/ui-egui/tests/ui_workspace_focus.rs b/crates/ui-egui/tests/ui_workspace_focus.rs index f9dd3c47f..fe1ab2d72 100644 --- a/crates/ui-egui/tests/ui_workspace_focus.rs +++ b/crates/ui-egui/tests/ui_workspace_focus.rs @@ -330,3 +330,18 @@ fn unowned_tab_keeps_the_timeline_mini_flowchart_shortcut() { output.textures_delta.clear(); assert!(app.ui.mini_flowchart.is_some(), "an unowned Tab still invokes the application binding"); } + +/// Deleting the current workspace writes the file straight away (it used to be rejected for +/// having no definition until the next frame retried). +#[test] +fn deleting_the_current_workspace_saves_immediately() { + let store = Arc::new(MemoryConfig::default()); + let mut app = EffectcraftApp::new(Session { config: Some(store.clone()), ..Default::default() }); + let ctx = egui::Context::default(); + effectcraft_ui_egui::menus::invoke(&mut app, &ctx, "window.saveWorkspaceAs", json!({"name": "Gone"})).unwrap(); + effectcraft_ui_egui::menus::invoke(&mut app, &ctx, "window.editWorkspaces", json!({"name": "Gone", "delete": true})).unwrap(); + assert_eq!(app.ui.workspace, "Default"); + assert!(effectcraft_ui_egui::control::inspect(&app, &ctx)["workspaceStorageError"].is_null()); + let text = store.read("workspaces.json").unwrap(); + assert!(!text.contains("Gone"), "the deleted workspace must be gone from the file: {text}"); +} From bcfb25ca93a3dd1a4916239703d7d506c3461f28 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 12 Oct 2026 21:24:41 -0400 Subject: [PATCH 146/181] Dialogs: share the buttons-first layout Settings uses Settings keeps its buttons in view by laying them out first, at the bottom of its body, and giving its pages the height left above them, clipped to it. That layout is now dialogs::above_footer, so other dialogs can keep their buttons in view the same way. Settings looks and works as before. --- crates/ui-egui/src/panels/dialogs.rs | 16 ++++++++ crates/ui-egui/src/panels/settings.rs | 54 ++++++++++++--------------- 2 files changed, 40 insertions(+), 30 deletions(-) diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index e95a667ec..6f735e9c1 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -192,6 +192,22 @@ pub(crate) fn modal_fit(ctx: &egui::Context, title: &str, size: egui::Vec2, t: & }); } +/// The buttons of a [`modal_fit`] body of `height`, kept in view: `footer` lays them out first, +/// at the bottom (bottom up: its first row is the lowest), and the rest of the body gets the room +/// above them. Returns that room (a left-to-right row, clipped to it, so a long or wide body +/// scrolls or clips under the buttons instead of pushing them out of the dialog) and its height. +pub(crate) fn above_footer(ui: &mut egui::Ui, height: f32, footer: impl FnOnce(&mut egui::Ui)) -> (egui::Ui, f32) { + let area = ui.allocate_space(vec2(ui.available_width(), height)).1; + let mut foot = ui.new_child(egui::UiBuilder::new().max_rect(area).layout(egui::Layout::bottom_up(egui::Align::Min))); + footer(&mut foot); + foot.add_space(8.0); + let body_h = (foot.min_rect().top() - area.top()).max(0.0); + let room = Rect::from_min_size(area.min, vec2(area.width(), body_h)); + let mut body = ui.new_child(egui::UiBuilder::new().max_rect(room).layout(egui::Layout::left_to_right(egui::Align::Min))); + body.set_clip_rect(room.intersect(body.clip_rect())); + (body, body_h) +} + pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) { let Some(d) = app.dialog else { return }; let t = app.tokens; diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index f7a45febb..5f115b842 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -10,7 +10,7 @@ use crate::i18n::{tr, tr_args}; use effectcraft_engine::prefs::{APPEARANCE_MODES, DARK_THEMES, Item, Kind, LIGHT_THEMES, Page, pages}; use effectcraft_engine::segment::Task; -use egui::{Color32, Rect, RichText, vec2}; +use egui::{Color32, RichText, vec2}; use serde_json::{Value, json}; use crate::theme::{ThemeKind, Tokens}; @@ -633,37 +633,31 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let mut acts: Vec = vec![]; let (mut ok, mut cancel) = (false, false); super::dialogs::modal_fit(ctx, "Settings", vec2(820.0, 600.0), t, |ui, height| { - // The buttons go first, from the bottom up, and the pages get the height they leave: at - // a high UI Scale the pages scroll and the buttons stay in view. - let area = ui.allocate_space(vec2(ui.available_width(), height)).1; - let mut foot = ui.new_child(egui::UiBuilder::new().max_rect(area).layout(egui::Layout::bottom_up(egui::Align::Min))); - foot.horizontal(|ui| { - let r = ui.add_enabled(idx > 0, egui::Button::new(tr("Previous"))); - app.auto.add("settings.previous", r.rect, "Previous"); - if r.clicked() { - page = all[idx - 1].id.to_string(); - } - let r = ui.add_enabled(idx + 1 < all.len(), egui::Button::new(tr("Next"))); - app.auto.add("settings.next", r.rect, "Next"); - if r.clicked() { - page = all[idx + 1].id.to_string(); - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let r = ui.add(egui::Button::new(RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent)); - app.auto.add("settings.ok", r.rect, "OK"); - ok |= r.clicked(); - let r = ui.button(tr("Cancel")); - app.auto.add("settings.cancel", r.rect, "Cancel"); - cancel |= r.clicked(); + // The buttons go first and the pages get the height they leave: at a high UI Scale the + // pages scroll and the buttons stay in view. + let (mut body, body_h) = super::dialogs::above_footer(ui, height, |foot| { + foot.horizontal(|ui| { + let r = ui.add_enabled(idx > 0, egui::Button::new(tr("Previous"))); + app.auto.add("settings.previous", r.rect, "Previous"); + if r.clicked() { + page = all[idx - 1].id.to_string(); + } + let r = ui.add_enabled(idx + 1 < all.len(), egui::Button::new(tr("Next"))); + app.auto.add("settings.next", r.rect, "Next"); + if r.clicked() { + page = all[idx + 1].id.to_string(); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let r = ui.add(egui::Button::new(RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent)); + app.auto.add("settings.ok", r.rect, "OK"); + ok |= r.clicked(); + let r = ui.button(tr("Cancel")); + app.auto.add("settings.cancel", r.rect, "Cancel"); + cancel |= r.clicked(); + }); }); + foot.separator(); }); - foot.separator(); - foot.add_space(8.0); - let body_h = (foot.min_rect().top() - area.top()).max(0.0); - // (A child, clipped to its share, so a wide page can't widen the dialog under the buttons.) - let pages = Rect::from_min_size(area.min, vec2(area.width(), body_h)); - let mut body = ui.new_child(egui::UiBuilder::new().max_rect(pages).layout(egui::Layout::left_to_right(egui::Align::Min))); - body.set_clip_rect(pages.intersect(body.clip_rect())); { let ui = &mut body; // Page list (left, like After Effects). From bf911d18bc37e0ee1ab518e2297a704c1ba2a4a8 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 12 Oct 2026 21:24:43 -0400 Subject: [PATCH 147/181] Layer Styles: OK and Cancel stay in view (#1062) The Layer Style dialog's OK and Cancel came after the style list and the style's controls in one scroll area, and at its usual size they were laid out past the dialog's bottom edge: clipped, and a click on them did nothing until the dialog was scrolled. The dialog now uses above_footer: OK and Cancel sit at its bottom right and the list and the controls scroll above them, also at 200 % UI Scale. Test: layer_style_buttons_stay_in_view (both buttons painted whole and clicked, at 100 % and 200 %; fails before: Cancel isn't painted). --- .../ui-egui/src/panels/layer_styles_dialog.rs | 91 ++++++++++--------- crates/ui-egui/tests/ui_tail.rs | 40 ++++++++ 2 files changed, 89 insertions(+), 42 deletions(-) diff --git a/crates/ui-egui/src/panels/layer_styles_dialog.rs b/crates/ui-egui/src/panels/layer_styles_dialog.rs index 1366c2ae8..6e9b28e49 100644 --- a/crates/ui-egui/src/panels/layer_styles_dialog.rs +++ b/crates/ui-egui/src/panels/layer_styles_dialog.rs @@ -8,7 +8,7 @@ use crate::i18n::tr; use effectcraft_engine::keyframe::Value as KV; use effectcraft_engine::project::styles::{BLENDING, STYLES}; use effectcraft_engine::project::{Layer, LayerId, ParamUi, Property}; -use egui::{Align2, vec2}; +use egui::vec2; use serde_json::{Value, json}; use crate::theme::Tokens; @@ -138,36 +138,57 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let lt = l.layer_time(time); let mut actions: Vec<(String, Value)> = Vec::new(); let mut close: Option = None; - super::dialogs::modal(ctx, &format!("Layer Style — {}", l.name), vec2(720.0, 520.0), t, |ui| { - ui.horizontal_top(|ui| { + super::dialogs::modal_fit(ctx, &format!("Layer Style — {}", l.name), vec2(720.0, 520.0), t, |ui, height| { + // OK and Cancel stay at the bottom, in view; the list and the style's controls scroll + // above them (#1062). + let (mut body, body_h) = super::dialogs::above_footer(ui, height, |foot| { + foot.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let ok = ui.button(tr("OK")); + app.auto.add("dialog.layerStyle.ok", ok.rect, "OK"); + if ok.clicked() { + close = Some(true); + } + let cancel = ui.button(tr("Cancel")); + app.auto.add("dialog.layerStyle.cancel", cancel.rect, "Cancel"); + if cancel.clicked() { + close = Some(false); + } + }); + }); + }); + { + let ui = &mut body; // Style list. ui.vertical(|ui| { ui.set_width(200.0); - let mut entries = vec![(BLENDING, "Blending Options")]; - entries.extend(STYLES.iter().copied()); - for (id, name) in entries { - ui.horizontal(|ui| { - if id != BLENDING { - let g = styles.as_ref().and_then(|s| s.sub(id)); - let mut on = g.is_some_and(|g| g.enabled); - if ui.checkbox(&mut on, "").changed() { - if g.is_none() { - actions.push(("layer.style.add".into(), json!({"layers": [lid], "style": id}))); - } else { - actions.push(("layer.style.toggle".into(), json!({"layer": lid, "style": id, "value": on}))); + egui::ScrollArea::vertical().id_salt("ls-list").max_height(body_h).show(ui, |ui| { + let mut entries = vec![(BLENDING, "Blending Options")]; + entries.extend(STYLES.iter().copied()); + for (id, name) in entries { + ui.horizontal(|ui| { + if id != BLENDING { + let g = styles.as_ref().and_then(|s| s.sub(id)); + let mut on = g.is_some_and(|g| g.enabled); + if ui.checkbox(&mut on, "").changed() { + if g.is_none() { + actions.push(("layer.style.add".into(), json!({"layers": [lid], "style": id}))); + } else { + actions.push(("layer.style.toggle".into(), json!({"layer": lid, "style": id, "value": on}))); + } } + } else { + ui.add_space(24.0); } - } else { - ui.add_space(24.0); - } - let r = ui.selectable_label(sel == id, name); - if r.clicked() { - app.dialog_state.layer_style.selected = id.to_string(); - } - let rect = r.rect; - app.auto.add(&format!("dialog.layerStyle.list.{id}"), rect, name); - }); - } + let r = ui.selectable_label(sel == id, name); + if r.clicked() { + app.dialog_state.layer_style.selected = id.to_string(); + } + let rect = r.rect; + app.auto.add(&format!("dialog.layerStyle.list.{id}"), rect, name); + }); + } + }); }); ui.separator(); // Controls of the selected style. @@ -180,7 +201,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.label(egui::RichText::new(tr("Not applied: tick it in the list to add it.")).weak()); return; }; - egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| { + egui::ScrollArea::vertical().max_height((body_h - 30.0).max(0.0)).show(ui, |ui| { egui::Grid::new("ls-grid").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| { g.walk("", &mut |_, p| { if matches!(p.ui, ParamUi::Hidden) { @@ -201,21 +222,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { }); }); }); - }); - ui.add_space(8.0); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let ok = ui.button(tr("OK")); - app.auto.add("dialog.layerStyle.ok", ok.rect, "OK"); - if ok.clicked() { - close = Some(true); - } - let cancel = ui.button(tr("Cancel")); - app.auto.add("dialog.layerStyle.cancel", cancel.rect, "Cancel"); - if cancel.clicked() { - close = Some(false); - } - let _ = Align2::LEFT_CENTER; - }); + } }); for (id, p) in actions { if let Err(e) = app.session.execute(&id, p) { diff --git a/crates/ui-egui/tests/ui_tail.rs b/crates/ui-egui/tests/ui_tail.rs index aa4128cbf..6fcd4bcd0 100644 --- a/crates/ui-egui/tests/ui_tail.rs +++ b/crates/ui-egui/tests/ui_tail.rs @@ -60,6 +60,16 @@ fn timeline_outline_columns_scroll_horizontally() { assert!(h.state().auto.find("timeline.outlineScroll").is_none()); } +fn click(h: &mut Harness<'_, EffectcraftApp>, at: Pos2) { + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerMoved(at)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + h.step(); + } + h.run_steps(2); +} + fn styles(h: &mut Harness<'_, EffectcraftApp>) -> Vec { let v = h.state_mut().session.execute("layer.style.list", json!({})).unwrap(); v["styles"].as_array().unwrap().iter().map(|s| s["style"].as_str().unwrap().to_string()).collect() @@ -103,6 +113,36 @@ fn layer_style_dialog_adds_previews_and_cancels() { assert_eq!(styles(&mut h), ["stroke"]); } +/// #1062: the Layer Style dialog's OK and Cancel stay in view, below the list and the style's +/// controls (they were laid out past its bottom edge, under the fold of its scroll area, so a +/// click on them did nothing until the dialog was scrolled), also at 200 % UI Scale. +#[test] +fn layer_style_buttons_stay_in_view() { + let mut h = harness(1400.0); + let lid = h.state().session.active_comp().unwrap().layers[0].id.0; + h.state_mut().session.execute("layer.select", json!({"layers": [lid]})).unwrap(); + let ctx = h.ctx.clone(); + // Whole: inside the window, and painted with none of it clipped away. + let in_view = |h: &Harness<'_, EffectcraftApp>, id: &str| { + let r = rect(h, id); + let clip = + h.output().shapes.iter().find(|c| matches!(&c.shape, egui::Shape::Rect(s) if (s.rect.center() - r.center()).length() < 0.5)).map(|c| c.clip_rect); + assert!(h.ctx.content_rect().contains_rect(r) && clip.is_some_and(|c| c.contains_rect(r)), "{id} is cut off: {r:?} in {clip:?}"); + r.center() + }; + for (scale, button, kept) in [(100, "dialog.layerStyle.ok", 1), (200, "dialog.layerStyle.cancel", 1)] { + h.state_mut().set_pref("appearance.uiScale", json!(scale)).unwrap(); + h.run_steps(3); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.style.dropShadow", json!({})).unwrap(); + h.run_steps(5); + in_view(&h, "dialog.layerStyle.cancel"); + let at = in_view(&h, button); + click(&mut h, at); + assert_eq!(h.state().dialog, None, "{scale} %: {button} closed the dialog"); + assert_eq!(styles(&mut h).len(), kept, "{scale} %: {button}"); + } +} + #[test] fn project_panel_columns_scroll_horizontally() { let mut h = harness(1400.0); From 73ea427015333ed5ba21afae0e2dc370619dea19 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 12 Oct 2026 21:31:16 -0400 Subject: [PATCH 148/181] Commands: a frontend-only command's own failure is its result (#696) --- crates/ui-egui/src/menus.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e19e0c6d8..ae0834ca2 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -752,7 +752,7 @@ fn clipboard_note(s: &effectcraft_engine::Session) -> String { } pub(crate) fn run_engine(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result { - let r = app.session.execute(id, params).map_err(|e| e.to_string()); + let mut r = app.session.execute(id, params).map_err(|e| e.to_string()); if let Err(e) = &r { app.ui.status = e.clone(); } else if COPY_COMMANDS.contains(&id) && app.session.state.text_edit.is_none() { @@ -763,7 +763,12 @@ pub(crate) fn run_engine(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str match ev { effectcraft_engine::Event::Frontend { command, params } => { if let Err(e) = frontend(app, ctx, &command, params) { - app.ui.status = e; + app.ui.status = e.clone(); + // A frontend-only command's own failure is the command's result (#696: a + // workspace save that didn't reach disk reported success). + if command == id && r.is_ok() { + r = Err(e); + } } } other => app.session.events.push(other), From b9a9a0a4148f16add9783e6d536e6b301591486b Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 12 Oct 2026 21:32:18 -0400 Subject: [PATCH 149/181] Workspaces: Save, Save As, Rename and Delete fail when the file isn't written (#696) --- crates/ui-egui/src/lib.rs | 13 +++++++-- crates/ui-egui/src/menus.rs | 8 ++--- crates/ui-egui/src/native_menu.rs | 1 + crates/ui-egui/src/workspace_state.rs | 34 ++++++++++++++++++++++ crates/ui-egui/tests/ui_panels.rs | 2 ++ crates/ui-egui/tests/ui_workspace_focus.rs | 6 +++- 6 files changed, 56 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 746d917bc..df886759b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -428,9 +428,16 @@ impl EffectcraftApp { } /// Keep the saved workspaces for the next session (see [`EffectcraftApp::new`]): After - /// Effects' saved workspaces last until they are deleted (#320). - pub(crate) fn store_saved_workspaces(&mut self) { - self.save_workspace_state(); + /// Effects' saved workspaces last until they are deleted (#320). `Err` when they weren't + /// written (#696): the layouts stay usable and a later save retries, but a restart loses them. + pub(crate) fn store_saved_workspaces(&mut self) -> Result<(), String> { + let Err(error) = self.workspace_store.save_now(self.session.config.as_deref(), &self.ui) else { return Ok(()) }; + let message = format!("Workspaces are kept for this session only and won't be there after a restart: {error}"); + log::warn!("{message}"); + self.ui.status = message.clone(); + // The raw error, so the background retry doesn't report the same failure again. + self.workspace_store.error = Some(error); + Err(message) } /// Offer the frontend's commands (tools, timeline navigation…) for keyboard shortcuts. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ae0834ca2..4b8d14a42 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1017,7 +1017,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu let name = app.ui.workspace.clone(); app.ui.saved_workspaces.insert(name.clone(), app.ui.dock.clone()); app.ui.saved_floating.insert(name.clone(), app.ui.floating.clone()); - app.store_saved_workspaces(); + app.store_saved_workspaces()?; json!({"workspace": name}) } "window.saveWorkspaceAs" => { @@ -1034,7 +1034,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu app.ui.saved_workspaces.insert(name.clone(), app.ui.dock.clone()); app.ui.saved_floating.insert(name.clone(), app.ui.floating.clone()); app.ui.workspace = name.clone(); - app.store_saved_workspaces(); + app.store_saved_workspaces()?; json!({"workspace": name}) } "window.editWorkspaces" => { @@ -1063,7 +1063,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu if app.ui.workspace == name { app.set_workspace("Default"); } - app.store_saved_workspaces(); + app.store_saved_workspaces()?; return Ok(json!({"deleted": name})); } if let Some(new) = p.get("rename").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty() && *n != name) { @@ -1078,7 +1078,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu if app.ui.workspace == name { app.ui.workspace = new.to_string(); } - app.store_saved_workspaces(); + app.store_saved_workspaces()?; return Ok(json!({"renamed": new})); } json!({"workspaces": app.workspace_names()}) diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index 5d0a3c531..c3266acf5 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -429,6 +429,7 @@ mod tests { #[test] fn saved_workspaces_are_in_the_native_workspace_menu() { let mut app = app(); + app.session.config = Some(std::sync::Arc::new(effectcraft_engine::config::MemoryConfig::default())); let ctx = egui::Context::default(); crate::menus::invoke(&mut app, &ctx, "window.saveWorkspaceAs", json!({"name": "My Layout"})).unwrap(); let entry = |app: &EffectcraftApp| build(app).items().into_iter().find(|i| i.label == "My Layout").cloned(); diff --git a/crates/ui-egui/src/workspace_state.rs b/crates/ui-egui/src/workspace_state.rs index b7233a806..c2ac9e373 100644 --- a/crates/ui-egui/src/workspace_state.rs +++ b/crates/ui-egui/src/workspace_state.rs @@ -263,6 +263,19 @@ impl WorkspaceStore { self.error = None; Ok(true) } + + /// An explicit save (Save Workspace, Save as New Workspace, Edit Workspaces): unlike + /// [`Self::save`], no settings folder or a file blocked at load is an error, not "nothing to do". + pub(crate) fn save_now(&mut self, config: Option<&dyn ConfigStore>, ui: &UiState) -> Result<(), String> { + let Some(config) = config else { + return Err("there is no settings folder to save them in".into()); + }; + if !self.writable { + let path = config.dir().map_or_else(|| FILE.to_string(), |dir| dir.join(FILE).display().to_string()); + return Err(format!("{path} couldn't be loaded at startup, so it is left untouched; fix or move it and restart")); + } + self.save(Some(config), ui).map(|_| ()) + } } #[cfg(test)] @@ -283,10 +296,31 @@ mod tests { ui.focused = PanelKind::Project; ui.dock = crate::dock::workspace("Minimal"); assert!(!store.save(Some(&config), &ui).unwrap()); + assert!(store.save_now(Some(&config), &ui).unwrap_err().contains("couldn't be loaded")); assert_eq!(config.read(FILE).as_deref(), Some(text.as_str())); } } + #[test] + fn explicit_save_without_a_settings_folder_is_an_error() { + let mut ui = UiState::default(); + let mut store = WorkspaceStore::load(None, &mut ui); + ui.focused = PanelKind::Project; + assert!(!store.save(None, &ui).unwrap()); + assert!(store.save_now(None, &ui).is_err()); + } + + #[test] + fn explicit_save_with_nothing_new_succeeds() { + let config = MemoryConfig::default(); + let mut ui = UiState::default(); + let mut store = WorkspaceStore::load(Some(&config), &mut ui); + ui.focused = PanelKind::Project; + store.save_now(Some(&config), &ui).unwrap(); + store.save_now(Some(&config), &ui).unwrap(); + assert!(config.read(FILE).is_some()); + } + #[test] fn invalid_tabs_split_and_deep_tree_are_rejected() { let mut snapshot = Snapshot::capture(&UiState::default()); diff --git a/crates/ui-egui/tests/ui_panels.rs b/crates/ui-egui/tests/ui_panels.rs index e3fee868c..38df1ecbc 100644 --- a/crates/ui-egui/tests/ui_panels.rs +++ b/crates/ui-egui/tests/ui_panels.rs @@ -172,6 +172,8 @@ fn saved_workspaces_persist_across_sessions() { #[test] fn saved_workspace_is_listed_in_the_workspace_menu() { let mut h = harness(); + // Saving needs a settings folder. + h.state_mut().session.config = Some(Arc::new(MemoryConfig::default())); let ctx = h.ctx.clone(); effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.workspace", json!({"name": "Minimal"})).unwrap(); effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.saveWorkspaceAs", json!({"name": "My Layout"})).unwrap(); diff --git a/crates/ui-egui/tests/ui_workspace_focus.rs b/crates/ui-egui/tests/ui_workspace_focus.rs index fe1ab2d72..ecd3a16ad 100644 --- a/crates/ui-egui/tests/ui_workspace_focus.rs +++ b/crates/ui-egui/tests/ui_workspace_focus.rs @@ -85,7 +85,11 @@ fn unreadable_native_workspace_is_preserved_after_later_layout_changes() { assert!(effectcraft_ui_egui::control::inspect(&app, &ctx)["workspaceStorageError"].is_string()); app.set_workspace("Minimal"); app.ui.focused = PanelKind::Project; - effectcraft_ui_egui::menus::invoke(&mut app, &ctx, "window.saveWorkspaceAs", json!({"name":"Must not overwrite"})).unwrap(); + let error = effectcraft_ui_egui::menus::invoke(&mut app, &ctx, "window.saveWorkspaceAs", json!({"name":"Must not overwrite"})).unwrap_err(); + assert!(error.contains("won't be there after a restart") && error.contains("workspaces.json"), "{error}"); + assert_eq!(app.ui.status, error, "the failure is in the status bar"); + assert_eq!(app.ui.workspace, "Must not overwrite", "the layout stays usable for this session"); + assert!(app.ui.saved_workspaces.contains_key("Must not overwrite")); eframe::App::on_exit(&mut app); assert_eq!(std::fs::read(&path).unwrap(), bytes); assert!(effectcraft_ui_egui::control::inspect(&app, &ctx)["workspaceStorageError"].is_string()); From 41b04bf414efd0a97603aaf2f350b8d175624d34 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 12 Oct 2026 21:32:22 -0400 Subject: [PATCH 150/181] Workspaces: tests for a failed, a storage-less and an on-disk workspace save (#696) --- crates/ui-egui/tests/ui_workspace_focus.rs | 89 ++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/crates/ui-egui/tests/ui_workspace_focus.rs b/crates/ui-egui/tests/ui_workspace_focus.rs index ecd3a16ad..8464c1fbc 100644 --- a/crates/ui-egui/tests/ui_workspace_focus.rs +++ b/crates/ui-egui/tests/ui_workspace_focus.rs @@ -349,3 +349,92 @@ fn deleting_the_current_workspace_saves_immediately() { let text = store.read("workspaces.json").unwrap(); assert!(!text.contains("Gone"), "the deleted workspace must be gone from the file: {text}"); } + +/// A settings store whose writes fail while `full` is set (a full or read-only disk). +#[derive(Default)] +struct FullDisk { + inner: MemoryConfig, + full: std::sync::atomic::AtomicBool, +} + +impl ConfigStore for FullDisk { + fn read(&self, name: &str) -> Option { + self.inner.read(name) + } + fn write(&self, name: &str, data: &str) -> std::io::Result<()> { + if self.full.load(std::sync::atomic::Ordering::Relaxed) { + return Err(std::io::Error::other("synthetic full disk")); + } + self.inner.write(name, data) + } + fn remove(&self, name: &str) -> std::io::Result<()> { + self.inner.remove(name) + } +} + +/// Issue #696: Save as New Workspace said nothing when the file couldn't be written, so the +/// workspace was gone after a restart. The command fails, the layout stays usable, and saving +/// again once the disk is writable keeps it. +#[test] +fn failed_workspace_save_is_reported_and_retryable() { + let store = Arc::new(FullDisk::default()); + store.full.store(true, std::sync::atomic::Ordering::Relaxed); + let mut app = EffectcraftApp::new(Session { config: Some(store.clone()), ..Default::default() }); + let ctx = egui::Context::default(); + let invoke = |app: &mut EffectcraftApp, id: &str, params| effectcraft_ui_egui::menus::invoke(app, &ctx, id, params); + let error = invoke(&mut app, "window.saveWorkspaceAs", json!({"name": "caption craft"})).unwrap_err(); + assert!(error.contains("synthetic full disk") && error.contains("won't be there after a restart"), "{error}"); + assert_eq!(app.ui.status, error); + let storage_error = effectcraft_ui_egui::control::inspect(&app, &ctx)["workspaceStorageError"].clone(); + assert!(storage_error.as_str().is_some_and(|e| e.contains("synthetic full disk")), "{storage_error}"); + assert_eq!(app.ui.workspace, "caption craft"); + assert_eq!(app.saved_workspace_names(), ["caption craft"]); + assert!(store.read("workspaces.json").is_none()); + // Rename and Delete report the same way. + assert!(invoke(&mut app, "window.editWorkspaces", json!({"name": "caption craft", "rename": "Captions"})).is_err()); + assert_eq!(app.saved_workspace_names(), ["Captions"]); + invoke(&mut app, "window.saveWorkspaceAs", json!({"name": "Scratch"})).unwrap_err(); + assert!(invoke(&mut app, "window.editWorkspaces", json!({"name": "Scratch", "delete": true})).is_err()); + assert_eq!(app.saved_workspace_names(), ["Captions"]); + // Writable again: an explicit Save keeps it. + store.full.store(false, std::sync::atomic::Ordering::Relaxed); + invoke(&mut app, "window.workspace", json!({"name": "Captions"})).unwrap(); + invoke(&mut app, "window.saveWorkspace", json!({})).unwrap(); + assert!(effectcraft_ui_egui::control::inspect(&app, &ctx)["workspaceStorageError"].is_null()); + let restarted = EffectcraftApp::new(Session { config: Some(store), ..Default::default() }); + assert_eq!(restarted.saved_workspace_names(), ["Captions"]); +} + +/// Without a settings folder there is nowhere to keep a workspace: saving says so. +#[test] +fn workspace_save_without_a_settings_folder_is_reported() { + let mut app = EffectcraftApp::new(Session::default()); + let ctx = egui::Context::default(); + let error = effectcraft_ui_egui::menus::invoke(&mut app, &ctx, "window.saveWorkspaceAs", json!({"name": "Mine"})).unwrap_err(); + assert!(error.contains("no settings folder"), "{error}"); + assert_eq!(app.saved_workspace_names(), ["Mine"]); +} + +/// Save as New Workspace, Rename and Delete reach the real settings folder straight away (no +/// exit needed) and come back from it. +#[test] +fn saved_workspaces_reach_the_settings_folder_and_reload() { + let dir = scratch_dir("disk"); + let launch = || EffectcraftApp::new(Session { config: Some(Arc::new(DirConfig::new(&dir))), ..Default::default() }); + let mut app = launch(); + let ctx = egui::Context::default(); + let invoke = |app: &mut EffectcraftApp, id: &str, params| effectcraft_ui_egui::menus::invoke(app, &ctx, id, params).unwrap(); + invoke(&mut app, "window.workspace", json!({"name": "Minimal"})); + invoke(&mut app, "window.saveWorkspaceAs", json!({"name": "caption craft"})); + let saved = app.ui.dock.clone(); + assert!(std::fs::read_to_string(dir.join("workspaces.json")).unwrap().contains("caption craft")); + invoke(&mut app, "window.saveWorkspaceAs", json!({"name": "Scratch"})); + invoke(&mut app, "window.editWorkspaces", json!({"name": "caption craft", "rename": "Captions"})); + invoke(&mut app, "window.editWorkspaces", json!({"name": "Scratch", "delete": true})); + drop(app); + let mut app = launch(); + assert_eq!(app.saved_workspace_names(), ["Captions"]); + invoke(&mut app, "window.workspace", json!({"name": "Captions"})); + assert_eq!(app.ui.dock, saved); + std::fs::remove_dir_all(dir).unwrap(); +} From dbaf559b581ebf93aa478a0c93486fbab9cdf52b Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 12 Oct 2026 21:32:24 -0400 Subject: [PATCH 151/181] Docs: workspace commands report a save that didn't reach disk (#696) --- docs/control-protocol.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f54879817..0a425eba7 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -81,6 +81,10 @@ errors. Unreadable, invalid or newer files leave the default workspace available preserved for the entire session, including after later layout changes. Load and save errors appear in the status message and the nullable `workspaceStorageError` field; a failed write remains eligible for retry. +`window.saveWorkspace`, `window.saveWorkspaceAs` and `window.editWorkspaces` (rename, delete) +write the file immediately and return an error when it isn't written (a failed write, a file +preserved after a failed load, or no configuration store). The changed workspaces still apply +for the session, and a later save retries them. ## Input From ecf485e3759aa1ecfd4fc2748110c5f264fc548a Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 12 Oct 2026 21:35:48 -0400 Subject: [PATCH 152/181] Docs: engine.execute reports a failed workspace save through workspaceStorageError (#696) --- docs/control-protocol.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0a425eba7..89d319f78 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -82,9 +82,11 @@ preserved for the entire session, including after later layout changes. Load and appear in the status message and the nullable `workspaceStorageError` field; a failed write remains eligible for retry. `window.saveWorkspace`, `window.saveWorkspaceAs` and `window.editWorkspaces` (rename, delete) -write the file immediately and return an error when it isn't written (a failed write, a file -preserved after a failed load, or no configuration store). The changed workspaces still apply -for the session, and a later save retries them. +write the file immediately. Through `ui.menu.invoke` they return an error when it isn't written +(a failed write, a file preserved after a failed load, or no configuration store); +`engine.execute` hands them to the UI and returns `{frontend}` first, so check +`workspaceStorageError` there. The changed workspaces still apply for the session, and a later +save retries them. ## Input From af1133a1f3d98f3b3929b9616fb3551f39ff17f8 Mon Sep 17 00:00:00 2001 From: maxaeexe Date: Tue, 13 Oct 2026 04:43:53 +0300 Subject: [PATCH 153/181] layer.addMarker: add composition marker when no layers are selected --- crates/engine/src/commands/layer_menu.rs | 19 +++++++++++++------ crates/engine/src/tests_markers.rs | 13 +++++++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/commands/layer_menu.rs b/crates/engine/src/commands/layer_menu.rs index 755dd8986..b5aebb593 100644 --- a/crates/engine/src/commands/layer_menu.rs +++ b/crates/engine/src/commands/layer_menu.rs @@ -6,7 +6,7 @@ use effectcraft_project::{FrameBlend, GroupKind, ItemId, LayerId, LayerSource, M use effectcraft_time::Tick; use serde_json::{Value, json}; -use super::{CommandSpec, b_p, bad, comp_id, f_p, frontend, has_comp, has_layers, layer_mut, layer_p, layers_p, merge_p, str_p}; +use super::{CommandSpec, b_p, bad, comp_id, f_p, frontend, has_comp, has_layers, layer_mut, layer_p, layers_p, merge_p, str_p, time_p}; use crate::{EngineError, Result, Session, cmd}; // ---------------------------------------------------------------- enablement @@ -545,11 +545,18 @@ fn mask_lock_others(s: &mut Session, p: &Value) -> Result { fn add_marker(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; - if ids.is_empty() { - return Err(bad("layer.addMarker", "no layer")); - } - let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(s.time()); + let t = time_p(s, p, s.project.comp(cid)); let comment = str_p(p, "comment").unwrap_or_default().to_string(); + if ids.is_empty() { + s.edit("Add Marker", None, |proj, _| { + let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + c.markers.retain(|m| m.time != t); + c.markers.push(Marker { time: t, comment, ..Default::default() }); + c.markers.sort_by_key(|m| m.time); + Ok(()) + })?; + return Ok(json!({"time": t.seconds()})); + } s.edit("Add Marker", None, |proj, _| { for lid in &ids { let l = layer_mut(proj, cid, *lid)?; @@ -842,7 +849,7 @@ pub fn specs() -> Vec { cmd!("layer.mask.lock", "Locked", ["Layer", "Mask"], None, "{layer?, mask?, value?}", has_masks, mask_lock), cmd!("layer.mask.unlockAll", "Unlock All Masks", ["Layer", "Mask"], None, "{layers?}", has_masks, mask_unlock_all), cmd!("layer.mask.lockOthers", "Lock Other Masks", ["Layer", "Mask"], None, "{layer?, mask?}", has_masks, mask_lock_others), - cmd!("layer.addMarker", "Add Marker", ["Layer", "Markers"], None, "{layers?, time?, comment?}", has_layers, add_marker), + cmd!("layer.addMarker", "Add Marker", ["Layer", "Markers"], None, "{layers?, time?, comment?}", has_comp, add_marker), cmd!("layer.markersLock", "Lock Markers", ["Layer", "Markers"], None, "{layers?, value?}", has_layers, markers_lock), cmd!("layer.deleteAllMarkers", "Delete All Markers", ["Layer", "Markers"], None, "{layers?}", has_markers, delete_all_markers), cmd!("layer.trackMatte", "Track Matte", [], None, "{layers?, op: none|alpha|alphaInverted|luma|lumaInverted|above|below}", has_layers, track_matte_op), diff --git a/crates/engine/src/tests_markers.rs b/crates/engine/src/tests_markers.rs index 6673241d9..afe4b9890 100644 --- a/crates/engine/src/tests_markers.rs +++ b/crates/engine/src/tests_markers.rs @@ -181,3 +181,16 @@ fn nested_comp_markers_follow_the_precomp_layers_timing() { let solid = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap(); assert!(s.execute("markers.nested", json!({"layer": solid})).is_err()); } + +#[test] +fn layer_add_marker_without_selection_adds_comp_marker() { + let (mut s, _) = setup(); + s.execute("time.set", json!({"time": 1.5})).unwrap(); + s.execute("edit.deselectAll", json!({})).unwrap(); + assert!(s.state.selected_layers.is_empty()); + s.execute("layer.addMarker", json!({"comment": "timeline marker"})).unwrap(); + let comp = s.active_comp().unwrap(); + assert_eq!(comp.markers.len(), 1); + assert!((comp.markers[0].time.seconds() - 1.5).abs() < 1e-9); + assert_eq!(comp.markers[0].comment, "timeline marker"); +} From 3db5aebb33855df7e661f07e1c64e0bbac911338 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 03:47:24 +0200 Subject: [PATCH 154/181] Graph editor: speed graph handles keep dragging once their key leaves the plot (fixes #1171) --- crates/ui-egui/src/panels/graph.rs | 5 ++++- crates/ui-egui/tests/ui_keyframes.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 77e39c620..0ba5d2d1b 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -376,7 +376,10 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte let (lid, uid, d) = (c.layer.id, c.prop.uid, c.dim); for k in &c.keys { let kp = pos2(tm.x(k.t), ymap(k.v)); - if !plot.expand(6.0).contains(kp) { + // In the speed graph an end key rises with the handle being dragged; skipping it once it + // leaves the plot would drop the handle's widget and end the drag at the top edge. + let handle_dragged = || ["in", "out"].iter().any(|side| ctx.is_being_dragged(egui::Id::new(("ghandle", uid, d, k.idx, *side)))); + if !plot.expand(6.0).contains(kp) && !handle_dragged() { continue; } let kjson = json!({"layer": lid.0, "prop": uid, "time": k.time.seconds()}); diff --git a/crates/ui-egui/tests/ui_keyframes.rs b/crates/ui-egui/tests/ui_keyframes.rs index 743528249..e5b2cae92 100644 --- a/crates/ui-egui/tests/ui_keyframes.rs +++ b/crates/ui-egui/tests/ui_keyframes.rs @@ -435,3 +435,31 @@ fn ctrl_alt_scrub_in_the_timeline_is_proportional() { assert!(moved[0].abs() < 1e-9, "the first selected layer stays: {moved:?}"); assert!((moved[1] - moved[2] / 2.0).abs() < 1e-6, "the middle one moves by half: {moved:?}"); } + +/// #1171: in the speed graph an end key sits at its one side's speed, so it rises with the handle +/// being dragged; once it left the plot its handles were skipped and the drag stopped at the top +/// edge. A drag that ends above the graph keeps raising the speed as far as the pointer goes. +#[test] +fn speed_graph_handle_drags_upward_past_the_top() { + let (mut h, id, uid) = harness(); + let s = &mut h.state_mut().session; + s.execute("prop.select", json!({"layer": id.0, "prop": uid})).unwrap(); + s.execute("keys.selectAll", json!({})).unwrap(); + s.execute("keys.easyEase", json!({})).unwrap(); + h.state_mut().ui.timeline.graph_editor = true; + h.state_mut().ui.timeline.graph_mode = "speed".into(); + h.run_steps(4); + let out_speed = |h: &Harness<'_, EffectcraftApp>| { + h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop("transform/opacity").unwrap().keys[0].out_ease[0].speed + }; + let e = h.state().auto.find(&format!("timeline.graph.handle.{uid}.0.0.out")).cloned().expect("the first key's out handle shows"); + let at = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + drag(&mut h, at, at + egui::vec2(0.0, -40.0), Default::default()); + let short = out_speed(&h); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(4); + assert_eq!(out_speed(&h), 0.0); + drag(&mut h, at, at + egui::vec2(0.0, -400.0), Default::default()); + let long = out_speed(&h); + assert!(short > 1.0 && long > 9.0 * short, "40 px up gave {short}, 400 px up gave {long}"); +} From e643457fa34ab624be29c8c85feeb488e758a957 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 12 Oct 2026 22:03:01 -0400 Subject: [PATCH 155/181] Commands: an earlier invocation's frontend failure isn't a later command's result (#696) --- crates/ui-egui/src/menus.rs | 9 ++++--- crates/ui-egui/tests/ui_workspace_focus.rs | 28 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 4b8d14a42..6036ac6e0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -752,6 +752,8 @@ fn clipboard_note(s: &effectcraft_engine::Session) -> String { } pub(crate) fn run_engine(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result { + // Events already queued belong to earlier invocations (`engine.execute` defers them). + let queued = app.session.events.len(); let mut r = app.session.execute(id, params).map_err(|e| e.to_string()); if let Err(e) = &r { app.ui.status = e.clone(); @@ -759,14 +761,15 @@ pub(crate) fn run_engine(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str ctx.copy_text(clipboard_note(&app.session)); } let events = app.session.drain_events(); - for ev in events { + for (index, ev) in events.into_iter().enumerate() { match ev { effectcraft_engine::Event::Frontend { command, params } => { if let Err(e) = frontend(app, ctx, &command, params) { app.ui.status = e.clone(); // A frontend-only command's own failure is the command's result (#696: a - // workspace save that didn't reach disk reported success). - if command == id && r.is_ok() { + // workspace save that didn't reach disk reported success). An earlier + // invocation's failure only sets the status. + if index >= queued && command == id && r.is_ok() { r = Err(e); } } diff --git a/crates/ui-egui/tests/ui_workspace_focus.rs b/crates/ui-egui/tests/ui_workspace_focus.rs index 8464c1fbc..e3c5a381a 100644 --- a/crates/ui-egui/tests/ui_workspace_focus.rs +++ b/crates/ui-egui/tests/ui_workspace_focus.rs @@ -438,3 +438,31 @@ fn saved_workspaces_reach_the_settings_folder_and_reload() { assert_eq!(app.ui.dock, saved); std::fs::remove_dir_all(dir).unwrap(); } + +/// A frontend event left queued by an earlier invocation (`engine.execute` defers them) only +/// sets the status: its failure is not the result of a later command with the same id. +#[test] +fn an_earlier_queued_frontend_failure_is_not_a_later_commands_result() { + let store = Arc::new(MemoryConfig::default()); + let mut app = EffectcraftApp::new(Session { config: Some(store.clone()), ..Default::default() }); + let ctx = egui::Context::default(); + let invoke = |app: &mut EffectcraftApp, id: &str, params| effectcraft_ui_egui::menus::invoke(app, &ctx, id, params); + // An earlier failure, then a success. + app.session.execute("window.workspace", json!({"name": "missing-old"})).unwrap(); + invoke(&mut app, "window.workspace", json!({"name": "Minimal"})).unwrap(); + assert_eq!(app.ui.workspace, "Minimal"); + // An earlier failing delete, then a rename that reaches the store. + invoke(&mut app, "window.saveWorkspaceAs", json!({"name": "Original"})).unwrap(); + app.session.execute("window.editWorkspaces", json!({"name": "missing-old", "delete": true})).unwrap(); + invoke(&mut app, "window.editWorkspaces", json!({"name": "Original", "rename": "Renamed"})).unwrap(); + assert!(store.read("workspaces.json").unwrap().contains("Renamed")); + // Both fail: the result is the current invocation's own failure. + app.session.execute("window.workspace", json!({"name": "missing-old"})).unwrap(); + let error = invoke(&mut app, "window.workspace", json!({"name": "missing-new"})).unwrap_err(); + assert!(error.contains("missing-new") && !error.contains("missing-old"), "{error}"); + // An earlier failure of another command. + app.session.execute("window.workspace", json!({"name": "missing-old"})).unwrap(); + invoke(&mut app, "window.saveWorkspaceAs", json!({"name": "Fine"})).unwrap(); + // Its own failure with nothing queued is still the result. + assert!(invoke(&mut app, "window.workspace", json!({"name": "missing"})).is_err()); +} From 544d46a21821ea67b4ce1998fa3d61aa2c7a36ec Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 12 Oct 2026 22:03:01 -0400 Subject: [PATCH 156/181] Workspaces: a failed save says a restart may undo the changes (#696) --- crates/ui-egui/src/lib.rs | 5 ++-- crates/ui-egui/tests/ui_workspace_focus.rs | 29 ++++++++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index df886759b..a3906e50c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -429,10 +429,11 @@ impl EffectcraftApp { /// Keep the saved workspaces for the next session (see [`EffectcraftApp::new`]): After /// Effects' saved workspaces last until they are deleted (#320). `Err` when they weren't - /// written (#696): the layouts stay usable and a later save retries, but a restart loses them. + /// written (#696): the change applies for this session and a later save retries it, but until + /// then a restart brings back what is on disk. pub(crate) fn store_saved_workspaces(&mut self) -> Result<(), String> { let Err(error) = self.workspace_store.save_now(self.session.config.as_deref(), &self.ui) else { return Ok(()) }; - let message = format!("Workspaces are kept for this session only and won't be there after a restart: {error}"); + let message = format!("Workspace changes couldn't be saved and may be lost after a restart: {error}"); log::warn!("{message}"); self.ui.status = message.clone(); // The raw error, so the background retry doesn't report the same failure again. diff --git a/crates/ui-egui/tests/ui_workspace_focus.rs b/crates/ui-egui/tests/ui_workspace_focus.rs index e3c5a381a..0d0ecd001 100644 --- a/crates/ui-egui/tests/ui_workspace_focus.rs +++ b/crates/ui-egui/tests/ui_workspace_focus.rs @@ -86,7 +86,7 @@ fn unreadable_native_workspace_is_preserved_after_later_layout_changes() { app.set_workspace("Minimal"); app.ui.focused = PanelKind::Project; let error = effectcraft_ui_egui::menus::invoke(&mut app, &ctx, "window.saveWorkspaceAs", json!({"name":"Must not overwrite"})).unwrap_err(); - assert!(error.contains("won't be there after a restart") && error.contains("workspaces.json"), "{error}"); + assert!(error.contains("may be lost after a restart") && error.contains("workspaces.json"), "{error}"); assert_eq!(app.ui.status, error, "the failure is in the status bar"); assert_eq!(app.ui.workspace, "Must not overwrite", "the layout stays usable for this session"); assert!(app.ui.saved_workspaces.contains_key("Must not overwrite")); @@ -383,7 +383,7 @@ fn failed_workspace_save_is_reported_and_retryable() { let ctx = egui::Context::default(); let invoke = |app: &mut EffectcraftApp, id: &str, params| effectcraft_ui_egui::menus::invoke(app, &ctx, id, params); let error = invoke(&mut app, "window.saveWorkspaceAs", json!({"name": "caption craft"})).unwrap_err(); - assert!(error.contains("synthetic full disk") && error.contains("won't be there after a restart"), "{error}"); + assert!(error.contains("synthetic full disk") && error.contains("may be lost after a restart"), "{error}"); assert_eq!(app.ui.status, error); let storage_error = effectcraft_ui_egui::control::inspect(&app, &ctx)["workspaceStorageError"].clone(); assert!(storage_error.as_str().is_some_and(|e| e.contains("synthetic full disk")), "{storage_error}"); @@ -466,3 +466,28 @@ fn an_earlier_queued_frontend_failure_is_not_a_later_commands_result() { // Its own failure with nothing queued is still the result. assert!(invoke(&mut app, "window.workspace", json!({"name": "missing"})).is_err()); } + +/// A failed Save Workspace or Delete of a workspace already on disk leaves the file as it was: +/// after a restart the earlier layout, and the workspace that wasn't deleted, come back. +#[test] +fn failed_save_or_delete_of_a_saved_workspace_keeps_the_file() { + let store = Arc::new(FullDisk::default()); + let mut app = EffectcraftApp::new(Session { config: Some(store.clone()), ..Default::default() }); + let ctx = egui::Context::default(); + let invoke = |app: &mut EffectcraftApp, id: &str, params| effectcraft_ui_egui::menus::invoke(app, &ctx, id, params); + invoke(&mut app, "window.workspace", json!({"name": "Minimal"})).unwrap(); + invoke(&mut app, "window.saveWorkspaceAs", json!({"name": "Kept"})).unwrap(); + let saved = app.ui.dock.clone(); + let before = store.read("workspaces.json").unwrap(); + store.full.store(true, std::sync::atomic::Ordering::Relaxed); + app.ui.dock.close(PanelKind::Timeline); + let error = invoke(&mut app, "window.saveWorkspace", json!({})).unwrap_err(); + assert!(error.contains("synthetic full disk") && error.contains("may be lost after a restart"), "{error}"); + assert!(invoke(&mut app, "window.editWorkspaces", json!({"name": "Kept", "delete": true})).is_err()); + assert!(app.saved_workspace_names().is_empty(), "the delete applies for this session"); + assert_eq!(store.read("workspaces.json").unwrap(), before); + let mut restarted = EffectcraftApp::new(Session { config: Some(store), ..Default::default() }); + assert_eq!(restarted.saved_workspace_names(), ["Kept"]); + restarted.set_workspace("Kept"); + assert_eq!(restarted.ui.dock, saved); +} From 3aaefab270767bdeef11326168e3c7453491fcab Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 04:17:16 +0200 Subject: [PATCH 157/181] UI: the interface symbols test checks the drawn glyph, not has_glyph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit epaint's has_glyph compares face keys, so a glyph drawn by the replacement face reads as missing. With no face in the stack covering egui's `◻`, Inter's `?` becomes the replacement and Inter's `•` fails the check, which happens once another test (the #1141 one) has loaded a system face without `◻`: the fallback tries loaded faces first. Compare the drawn glyph with the replacement glyph instead, as assert_drawn already does. --- crates/ui-egui/src/theme.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 2ed02c3ab..46ad66f5a 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -700,8 +700,14 @@ mod tests { ctx.run_ui(Default::default(), |_| {}).textures_delta.clear(); for family in [FontFamily::Proportional, FontFamily::Monospace, FontFamily::Name("medium".into()), FontFamily::Name("semibold".into())] { let id = FontId::new(12.0, family); + // Not has_glyph: it compares face keys, so Inter's `•` reads as missing when no face in + // the stack has egui's `◻` and Inter's `?` becomes the replacement, as happens once + // another test has loaded a system face without `◻` (the fallback tries loaded faces first). + let uv = |s: String| ctx.fonts_mut(|f| f.layout_no_wrap(s, id.clone(), egui::Color32::WHITE).rows[0].glyphs[0].uv_rect); + let missing = uv("\u{10ffff}".into()); for c in "▸•›·▶◀●○⚠…×".chars() { - assert!(ctx.fonts_mut(|f| f.has_glyph(&id, c)), "{c} has no glyph in {id:?}"); + let drawn = uv(c.to_string()); + assert_ne!((drawn.min, drawn.max), (missing.min, missing.max), "{c} has no glyph in {id:?}"); } } } From a96c80788a548ee727e4412956f012a6682bc325 Mon Sep 17 00:00:00 2001 From: redonion23 <199118131+redonion23@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:32:48 +0200 Subject: [PATCH 158/181] Scripting: CompItem.saveFrameToPng writes the frame as a PNG (#816) After Effects scripts save a comp frame with saveFrameToPng(time, file); EffectCraft only had the comp.saveFrameAs command, so the method was undefined. It now runs comp.saveFrameAs at the given comp time, after the same file-gate check as other script writes (Allow Scripts to Write Files). Co-Authored-By: Claude Opus 5.5 --- crates/script/src/prelude.js | 8 ++++++++ crates/script/src/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index 9daac6ab7..7eb1b30dd 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -1600,6 +1600,14 @@ CompItem.prototype.constructor = CompItem; __call("essential.rename", { comp: this.__id, control: c[i - 1].id, name: String(name) }); return String(name); }; + // After Effects' CompItem.saveFrameToPng(time, file): Composition ▸ Save Frame As ▸ File at + // `time` (comp seconds), behind the scripting file gate like other writes (#816). + P.saveFrameToPng = function (time, file) { + var f = __str(file); + if (!f) throw __err("saveFrameToPng: missing file"); + __file("write", f, ""); + __call("comp.saveFrameAs", { comp: this.__id, path: f, time: __num(time, "time") }); + }; P.openInEssentialGraphics = function () { __call("comp.openInEssentialGraphics", { comp: this.__id }); }; P.exportAsMotionGraphicsTemplate = function (overwrite, path) { var f = __str(path); diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 303bd820a..84c2760f6 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -362,6 +362,29 @@ fn security_gate_for_files() { let _ = std::fs::remove_dir_all(&dir); } +/// #816: CompItem.saveFrameToPng writes the frame as a PNG, behind the file-write preference. +#[test] +fn save_frame_to_png_writes_behind_the_file_gate() { + let dir = std::env::temp_dir().join(format!("ec-save-frame-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let png = dir.join("frame.png"); + let mut s = session(); + let code = format!( + "var c = app.project.items.addComp('F', 40, 30, 1, 2, 25); c.layers.addSolid([1, 0, 0], 'S', 40, 30, 1); c.saveFrameToPng(1, new File({}))", + serde_json::to_string(&png.to_string_lossy()).unwrap() + ); + let o = run_code(&mut s, &code, "frame.jsx"); + assert!(o.error.expect("refused").message.contains("Allow Scripts to Write Files")); + assert!(!png.exists() || std::fs::metadata(&png).unwrap().len() == 0); + s.prefs.scripting.allow_scripts_write_files = true; + ok(&mut s, &code); + let bytes = std::fs::read(&png).unwrap(); + assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n"); + // IHDR width and height. + assert_eq!((u32::from_be_bytes(bytes[16..20].try_into().unwrap()), u32::from_be_bytes(bytes[20..24].try_into().unwrap())), (40, 30)); + let _ = std::fs::remove_dir_all(&dir); +} + /// #590: a script can't give itself file and network access (or turn off the warning before /// executing files) by changing Settings ▸ Scripting & Expressions; the user still can. #[test] From 26b24518362b5cba4caa96cca1a779ae7b73ca96 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 04:41:07 +0200 Subject: [PATCH 159/181] i18n: the Settings pages reach the Chinese catalogs (fixes #1178) --- crates/ui-egui/src/i18n/ui.rs | 407 ++++++++++++++++++++++++++ crates/ui-egui/src/panels/settings.rs | 10 +- 2 files changed, 412 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index f31d0951c..7848f8e14 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -1060,6 +1060,192 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("After Effects project", "After Effects 项目"), ("Lottie animation", "Lottie 动画"), ("Project", "项目"), + // Settings dialog pages (engine schema, #1178). + ("General", "常规"), + ("Language", "语言"), + ("Levels of Undo", "撤消级别"), + ("Path Point and Handle Size", "路径点和手柄大小"), + ("Recent Projects Shown", "显示的最近项目数"), + ("Options", "选项"), + ("Show Tool Tips", "显示工具提示"), + ("Create Layers at Composition Start Time", "在合成开始时间创建图层"), + ("Switches Affect Nested Comps", "开关影响嵌套的合成"), + ("Default Spatial Interpolation to Linear", "默认空间插值为线性"), + ("Preserve Constant Vertex and Feather Point Count when Editing Masks", "编辑蒙版时保持固定的顶点和羽化点数"), + ("Synchronize Time of All Related Items", "同步所有相关项目的时间"), + ("Expression Pick Whip Writes Compact English", "表达式拾取器写入紧凑型英语"), + ("Create Split Layers Above Original Layer", "在原始图层上方创建拆分图层"), + ("Use System Color Picker", "使用系统拾色器"), + ( + "EffectCraft always uses its own colour picker (the same on every platform and the web).", + "EffectCraft 始终使用自己的拾色器(在所有平台和网页版中都相同)。", + ), + ("Startup & Repair", "启动和修复"), + ("Show Home Screen When Launching", "启动时显示主屏幕"), + ("Show Home Screen When Opening a Project", "打开项目时显示主屏幕"), + ("Offer to Open the Latest Auto-Save After a Crash", "崩溃后提示打开最新的自动保存"), + ("Window Graphics", "窗口图形"), + ("Automatic", "自动"), + ( + "Window Graphics applies from the next launch. When the graphics driver stops EffectCraft as its window opens, the next launch switches to OpenGL.", + "窗口图形从下次启动起生效。如果图形驱动程序在窗口打开时导致 EffectCraft 停止运行,下次启动将切换为 OpenGL。", + ), + ("Repair", "修复"), + ("Reset Settings", "重置设置"), + ("Reset Keyboard Shortcuts", "重置键盘快捷键"), + ("Empty Disk Cache", "清空磁盘缓存"), + ("New Project Loads Template", "新建项目时加载模板"), + ("Template Project", "模板项目"), + ("Auto-Save", "自动保存"), + ("Automatically Save Projects", "自动保存项目"), + ("Save Every", "保存间隔"), + ("Minutes", "分钟"), + ("Maximum Project Versions", "最大项目版本数"), + ("Auto-Save Location", "自动保存位置"), + ("Next to Project", "项目旁边"), + ("Custom Location", "自定义位置"), + ("Save When Starting Render Queue", "启动渲染队列时保存"), + ("Save Now", "立即保存"), + ("Motion Path", "运动路径"), + ("All Keyframes", "所有关键帧"), + ("No Motion Path", "无运动路径"), + ("No More Than (Seconds)", "不超过(秒)"), + ("No More Than (Keyframes)", "不超过(关键帧)"), + ("Seconds", "秒"), + ("Keyframes", "关键帧"), + ("Display", "显示"), + ("Show Rendering Progress in Info Panel and Flowchart", "在信息面板和流程图中显示渲染进度"), + ("Hardware Accelerate Composition, Layer and Footage Panels", "硬件加速合成、图层和素材面板"), + ("Panels are always drawn by the GPU (wgpu).", "面板始终由 GPU(wgpu)绘制。"), + ("Previews", "预览"), + ("Adaptive Resolution Limit", "自适应分辨率限制"), + ("Show Internal Wireframes", "显示内部线框"), + ("Cache Frames When Idle", "空闲时缓存帧"), + ("Viewer Zoom Quality", "查看器缩放品质"), + ("Faster", "更快"), + ("More Accurate", "更准确"), + ("Display Color Space", "显示色彩空间"), + ("GPU Information...", "GPU 信息..."), + ("Appearance", "外观"), + ("Brightness", "亮度"), + ("UI Scale", "界面缩放"), + ("Labels and Colors", "标签和颜色"), + ("Use Label Color for Layer Handles and Paths", "将标签颜色用于图层手柄和路径"), + ("Use Label Color for Related Tabs", "将标签颜色用于相关选项卡"), + ("Cycle Mask Colors", "循环蒙版颜色"), + ("Use Gradients", "使用渐变"), + ("Menu Bar", "菜单栏"), + ("Grids & Guides", "网格和参考线"), + ("Gridline Every", "网格线间隔"), + ("Subdivisions", "细分"), + ("Style", "样式"), + ("Lines", "线条"), + ("Dashed Lines", "虚线"), + ("Dots", "点"), + ("Horizontal", "水平"), + ("Vertical", "垂直"), + ("Safe Margins", "安全边距"), + ("Action-safe", "动作安全"), + ("Title-safe", "字幕安全"), + ("Labels", "标签"), + ("Text Engine", "文本引擎"), + ("Latin", "拉丁文"), + ("South Asian and Middle Eastern", "南亚和中东"), + ("Show Font Preview", "显示字体预览"), + ("Number of Recent Fonts to Display", "要显示的最近使用字体数"), + ("Show Font Names in English", "以英文显示字体名称"), + ("Still Footage", "静止素材"), + ("Length of Composition", "合成长度"), + ("Duration (Seconds)", "持续时间(秒)"), + ("Still Duration", "静止持续时间"), + ("Sequence Footage", "序列素材"), + ("Report Missing Frames", "报告缺失帧"), + ("Automatically Reload Footage", "自动重新加载素材"), + ("Off", "关闭"), + ("Non-Sequence Footage", "非序列素材"), + ("All Footage", "所有素材"), + ("Ask User", "询问用户"), + ("Guess", "猜测"), + ("Ignore", "忽略"), + ("Straight", "直接"), + ("Premultiplied", "预乘"), + ("Default Drag Import As", "默认拖动导入为"), + ("Footage", "素材"), + ("Composition - Retain Layer Sizes", "合成 - 保持图层大小"), + ("Export", "导出"), + ("Default Output Folder", "默认输出文件夹"), + ("Segment Sequences", "分段序列"), + ("Files per Segment", "每段文件数"), + ("files", "个文件"), + ("Segment Movie Files", "分段影片文件"), + ("Segment Size", "分段大小"), + ("Append Bit Depth to File Name", "将位深度附加到文件名"), + ("Audio", "音频"), + ("Audio Hardware", "音频硬件"), + ("Preview Sample Rate", "预览采样率"), + ("Audio Output Mapping", "音频输出映射"), + ("Left", "左"), + ("Right", "右"), + ("channel", "声道"), + ("Disk", "磁盘"), + ("Disk Cache", "磁盘缓存"), + ("Enable Disk Cache", "启用磁盘缓存"), + ("Maximum Disk Cache Size", "最大磁盘缓存大小"), + ("Disk Cache Folder", "磁盘缓存文件夹"), + ("Media Cache", "媒体缓存"), + ("Database and Cache Folder", "数据库和缓存文件夹"), + ("Conformed Audio Folder", "符合的音频文件夹"), + ("Memory & CPU", "内存和 CPU"), + ("RAM Reserved for Other Applications", "为其他应用程序保留的内存"), + ("Cache Budgets", "缓存预算"), + ("Layer Cache", "图层缓存"), + ("Footage Frame Cache", "素材帧缓存"), + ("Preview (RAM) Cache", "预览(内存)缓存"), + ("Reduce Cache Size When System Is Low on Memory", "系统内存不足时减少缓存大小"), + ("Video", "视频"), + ("Enable Video Preview Output", "启用视频预览输出"), + ("Video Device", "视频设备"), + ("Floating Window", "浮动窗口"), + ("Full Screen", "全屏"), + ("Video Output During Playback", "播放期间的视频输出"), + ("Mirror on Computer Monitor", "在计算机显示器上镜像"), + ("Disable Video Output When in Background", "在后台时禁用视频输出"), + ("Default 3D Renderer", "默认 3D 渲染器"), + ("Show 3D Reference Axes", "显示 3D 参考轴"), + ("Extended Viewer", "扩展查看器"), + ("Realtime Shadows in Draft", "草图模式下的实时阴影"), + ("Scripting & Expressions", "脚本和表达式"), + ("Application Scripting", "应用程序脚本"), + ("Allow Scripts to Write Files and Access Network", "允许脚本写入文件和访问网络"), + ("Warn User When Executing Files", "执行文件时警告用户"), + ("Enable JavaScript Debugger", "启用 JavaScript 调试器"), + ("Expressions Editor", "表达式编辑器"), + ("Font Size", "字体大小"), + ("Syntax Highlighting", "语法高亮"), + ("Line Numbers", "行号"), + ("Auto-complete", "自动完成"), + ("Bracket Matching", "括号匹配"), + ("Word Wrap", "自动换行"), + ("Show Expression Error Banner", "显示表达式错误横幅"), + ( + "Expressions use the JavaScript engine. Script errors report their file and line in the Script Console; there is no step debugger.", + "表达式使用 JavaScript 引擎。脚本错误会在脚本控制台中报告其文件和行号;没有单步调试器。", + ), + ("Roto Brush", "Roto 笔刷"), + ("Segmentation Model", "分割模型"), + ( + "Roto Brush 2.0 and 3.0 use the model chosen here (Version 1.0 always uses the classic engine). Models are open source, downloaded only when you ask, and checked against their published SHA-256.", + "Roto 笔刷 2.0 和 3.0 使用此处选择的模型(1.0 版始终使用经典引擎)。模型均为开源,仅在你请求时下载,并会根据其公布的 SHA-256 进行校验。", + ), + ("Face Tracking", "面部跟踪"), + ("Face Model", "面部模型"), + ( + "Face tracking in the Tracker panel (Outline Only and Detailed Features) uses the model chosen here. Models are open source, downloaded only when you ask, and checked against their published SHA-256.", + "跟踪器面板中的面部跟踪(仅轮廓和详细五官)使用此处选择的模型。模型均为开源,仅在你请求时下载,并会根据其公布的 SHA-256 进行校验。", + ), + ("Match System", "跟随系统"), + ("Default", "默认"), + ("Stored, but not used by EffectCraft yet", "已保存,但 EffectCraft 尚未使用"), ]; const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ @@ -1590,6 +1776,192 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("After Effects project", "After Effects 專案"), ("Lottie animation", "Lottie 動畫"), ("Project", "專案"), + // Settings dialog pages (engine schema, #1178). + ("General", "一般"), + ("Language", "語言"), + ("Levels of Undo", "還原層級"), + ("Path Point and Handle Size", "路徑點和控點大小"), + ("Recent Projects Shown", "顯示的最近專案數"), + ("Options", "選項"), + ("Show Tool Tips", "顯示工具提示"), + ("Create Layers at Composition Start Time", "在合成開始時間建立圖層"), + ("Switches Affect Nested Comps", "開關影響巢狀合成"), + ("Default Spatial Interpolation to Linear", "預設空間插值為線性"), + ("Preserve Constant Vertex and Feather Point Count when Editing Masks", "編輯遮色片時保持固定的頂點和羽化點數"), + ("Synchronize Time of All Related Items", "同步所有相關項目的時間"), + ("Expression Pick Whip Writes Compact English", "運算式拾取線寫入精簡英文"), + ("Create Split Layers Above Original Layer", "在原始圖層上方建立分割圖層"), + ("Use System Color Picker", "使用系統檢色器"), + ( + "EffectCraft always uses its own colour picker (the same on every platform and the web).", + "EffectCraft 一律使用自己的檢色器(在所有平台和網頁版中皆相同)。", + ), + ("Startup & Repair", "啟動和修復"), + ("Show Home Screen When Launching", "啟動時顯示首頁畫面"), + ("Show Home Screen When Opening a Project", "開啟專案時顯示首頁畫面"), + ("Offer to Open the Latest Auto-Save After a Crash", "當機後提示開啟最新的自動儲存"), + ("Window Graphics", "視窗圖形"), + ("Automatic", "自動"), + ( + "Window Graphics applies from the next launch. When the graphics driver stops EffectCraft as its window opens, the next launch switches to OpenGL.", + "視窗圖形自下次啟動起生效。如果圖形驅動程式在視窗開啟時導致 EffectCraft 停止執行,下次啟動會切換為 OpenGL。", + ), + ("Repair", "修復"), + ("Reset Settings", "重設設定"), + ("Reset Keyboard Shortcuts", "重設鍵盤快速鍵"), + ("Empty Disk Cache", "清空磁碟快取"), + ("New Project Loads Template", "新增專案時載入範本"), + ("Template Project", "範本專案"), + ("Auto-Save", "自動儲存"), + ("Automatically Save Projects", "自動儲存專案"), + ("Save Every", "儲存間隔"), + ("Minutes", "分鐘"), + ("Maximum Project Versions", "專案版本數上限"), + ("Auto-Save Location", "自動儲存位置"), + ("Next to Project", "專案旁邊"), + ("Custom Location", "自訂位置"), + ("Save When Starting Render Queue", "啟動算圖佇列時儲存"), + ("Save Now", "立即儲存"), + ("Motion Path", "動態路徑"), + ("All Keyframes", "所有關鍵影格"), + ("No Motion Path", "無動態路徑"), + ("No More Than (Seconds)", "不超過(秒)"), + ("No More Than (Keyframes)", "不超過(關鍵影格)"), + ("Seconds", "秒"), + ("Keyframes", "關鍵影格"), + ("Display", "顯示"), + ("Show Rendering Progress in Info Panel and Flowchart", "在資訊面板和流程圖中顯示算圖進度"), + ("Hardware Accelerate Composition, Layer and Footage Panels", "硬體加速合成、圖層和素材面板"), + ("Panels are always drawn by the GPU (wgpu).", "面板一律由 GPU(wgpu)繪製。"), + ("Previews", "預覽"), + ("Adaptive Resolution Limit", "自適應解析度限制"), + ("Show Internal Wireframes", "顯示內部線框"), + ("Cache Frames When Idle", "閒置時快取影格"), + ("Viewer Zoom Quality", "檢視器縮放品質"), + ("Faster", "較快"), + ("More Accurate", "較精確"), + ("Display Color Space", "顯示色彩空間"), + ("GPU Information...", "GPU 資訊..."), + ("Appearance", "外觀"), + ("Brightness", "亮度"), + ("UI Scale", "介面縮放"), + ("Labels and Colors", "標籤和顏色"), + ("Use Label Color for Layer Handles and Paths", "將標籤顏色用於圖層控點和路徑"), + ("Use Label Color for Related Tabs", "將標籤顏色用於相關標籤頁"), + ("Cycle Mask Colors", "循環遮色片顏色"), + ("Use Gradients", "使用漸層"), + ("Menu Bar", "選單列"), + ("Grids & Guides", "格點和參考線"), + ("Gridline Every", "格點線間隔"), + ("Subdivisions", "細分"), + ("Style", "樣式"), + ("Lines", "線條"), + ("Dashed Lines", "虛線"), + ("Dots", "點"), + ("Horizontal", "水平"), + ("Vertical", "垂直"), + ("Safe Margins", "安全邊界"), + ("Action-safe", "動作安全"), + ("Title-safe", "字幕安全"), + ("Labels", "標籤"), + ("Text Engine", "文字引擎"), + ("Latin", "拉丁文"), + ("South Asian and Middle Eastern", "南亞和中東"), + ("Show Font Preview", "顯示字體預覽"), + ("Number of Recent Fonts to Display", "要顯示的最近使用字體數"), + ("Show Font Names in English", "以英文顯示字體名稱"), + ("Still Footage", "靜態素材"), + ("Length of Composition", "合成長度"), + ("Duration (Seconds)", "持續時間(秒)"), + ("Still Duration", "靜態持續時間"), + ("Sequence Footage", "序列素材"), + ("Report Missing Frames", "報告遺失的影格"), + ("Automatically Reload Footage", "自動重新載入素材"), + ("Off", "關閉"), + ("Non-Sequence Footage", "非序列素材"), + ("All Footage", "所有素材"), + ("Ask User", "詢問使用者"), + ("Guess", "猜測"), + ("Ignore", "忽略"), + ("Straight", "直接"), + ("Premultiplied", "預乘"), + ("Default Drag Import As", "預設拖曳匯入為"), + ("Footage", "素材"), + ("Composition - Retain Layer Sizes", "合成 - 保留圖層大小"), + ("Export", "匯出"), + ("Default Output Folder", "預設輸出資料夾"), + ("Segment Sequences", "分段序列"), + ("Files per Segment", "每段檔案數"), + ("files", "個檔案"), + ("Segment Movie Files", "分段影片檔案"), + ("Segment Size", "分段大小"), + ("Append Bit Depth to File Name", "將位元深度附加到檔案名稱"), + ("Audio", "音訊"), + ("Audio Hardware", "音訊硬體"), + ("Preview Sample Rate", "預覽取樣率"), + ("Audio Output Mapping", "音訊輸出對應"), + ("Left", "左"), + ("Right", "右"), + ("channel", "聲道"), + ("Disk", "磁碟"), + ("Disk Cache", "磁碟快取"), + ("Enable Disk Cache", "啟用磁碟快取"), + ("Maximum Disk Cache Size", "磁碟快取大小上限"), + ("Disk Cache Folder", "磁碟快取資料夾"), + ("Media Cache", "媒體快取"), + ("Database and Cache Folder", "資料庫和快取資料夾"), + ("Conformed Audio Folder", "符合的音訊資料夾"), + ("Memory & CPU", "記憶體和 CPU"), + ("RAM Reserved for Other Applications", "為其他應用程式保留的記憶體"), + ("Cache Budgets", "快取預算"), + ("Layer Cache", "圖層快取"), + ("Footage Frame Cache", "素材影格快取"), + ("Preview (RAM) Cache", "預覽(記憶體)快取"), + ("Reduce Cache Size When System Is Low on Memory", "系統記憶體不足時減少快取大小"), + ("Video", "視訊"), + ("Enable Video Preview Output", "啟用視訊預覽輸出"), + ("Video Device", "視訊裝置"), + ("Floating Window", "浮動視窗"), + ("Full Screen", "全螢幕"), + ("Video Output During Playback", "播放期間的視訊輸出"), + ("Mirror on Computer Monitor", "在電腦顯示器上鏡像"), + ("Disable Video Output When in Background", "在背景時停用視訊輸出"), + ("Default 3D Renderer", "預設 3D 算圖引擎"), + ("Show 3D Reference Axes", "顯示 3D 參考軸"), + ("Extended Viewer", "延伸檢視器"), + ("Realtime Shadows in Draft", "草稿模式下的即時陰影"), + ("Scripting & Expressions", "指令碼和運算式"), + ("Application Scripting", "應用程式指令碼"), + ("Allow Scripts to Write Files and Access Network", "允許指令碼寫入檔案和存取網路"), + ("Warn User When Executing Files", "執行檔案時警告使用者"), + ("Enable JavaScript Debugger", "啟用 JavaScript 除錯器"), + ("Expressions Editor", "運算式編輯器"), + ("Font Size", "字體大小"), + ("Syntax Highlighting", "語法醒目提示"), + ("Line Numbers", "行號"), + ("Auto-complete", "自動完成"), + ("Bracket Matching", "括號配對"), + ("Word Wrap", "自動換行"), + ("Show Expression Error Banner", "顯示運算式錯誤橫幅"), + ( + "Expressions use the JavaScript engine. Script errors report their file and line in the Script Console; there is no step debugger.", + "運算式使用 JavaScript 引擎。指令碼錯誤會在指令碼主控台中回報其檔案和行號;沒有逐步除錯器。", + ), + ("Roto Brush", "Roto 筆刷"), + ("Segmentation Model", "分割模型"), + ( + "Roto Brush 2.0 and 3.0 use the model chosen here (Version 1.0 always uses the classic engine). Models are open source, downloaded only when you ask, and checked against their published SHA-256.", + "Roto 筆刷 2.0 和 3.0 使用此處選擇的模型(1.0 版一律使用經典引擎)。模型皆為開放原始碼,僅在你要求時下載,並會依其公布的 SHA-256 進行驗證。", + ), + ("Face Tracking", "臉部追蹤"), + ("Face Model", "臉部模型"), + ( + "Face tracking in the Tracker panel (Outline Only and Detailed Features) uses the model chosen here. Models are open source, downloaded only when you ask, and checked against their published SHA-256.", + "追蹤器面板中的臉部追蹤(僅輪廓和詳細五官)使用此處選擇的模型。模型皆為開放原始碼,僅在你要求時下載,並會依其公布的 SHA-256 進行驗證。", + ), + ("Match System", "跟隨系統"), + ("Default", "預設"), + ("Stored, but not used by EffectCraft yet", "已儲存,但 EffectCraft 尚未使用"), ]; /// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs. @@ -3368,6 +3740,41 @@ mod tests { } } + /// The Settings pages come from the engine as data, so the literal scan above cannot see them: + /// every title, section, label, choice, button, note and unit they draw needs a Chinese row + /// (#1178). Language names are written in their own language, and a few labels read the same + /// everywhere. + #[test] + fn settings_pages_are_translated() { + use effectcraft_engine::prefs::{Item, Kind, LANGUAGES, pages}; + const VERBATIM: &[&str] = &["", "3D", "1/2", "1/4", "1/8", "sRGB", "Display P3", "px", "s", "%", "MB", "GB", "Hz", "pt"]; + let mut drawn = Vec::new(); + for page in pages() { + drawn.push(page.title); + for item in page.items { + match item { + Item::Section(text) | Item::Note(text) | Item::Button { label: text, .. } => drawn.push(text), + Item::Setting { label, kind, .. } => { + drawn.push(label); + match kind { + Kind::Int(_, _, unit) | Kind::Float(_, _, unit) => drawn.push(unit), + Kind::Choice(options) => drawn.extend(options.iter().map(|(l, _)| *l)), + _ => {} + } + } + _ => {} + } + } + } + drawn.push("Stored, but not used by EffectCraft yet"); + let languages: Vec<&str> = LANGUAGES.iter().map(|(l, _)| *l).filter(|l| *l != "Match System").collect(); + let hans: HashMap<&str, &str> = SIMPLIFIED_CHINESE.iter().copied().collect(); + let mut missing: Vec<&str> = drawn.into_iter().filter(|en| !VERBATIM.contains(en) && !languages.contains(en) && !hans.contains_key(en)).collect(); + missing.sort_unstable(); + missing.dedup(); + assert!(missing.is_empty(), "Settings text with no Simplified Chinese row: {missing:?}"); + } + /// A newly hard-coded interface string fails here instead of shipping in English to every /// language. It is a heuristic - it looks at the call shapes the panels use - and it ignores /// one-character literals, which are punctuation or a key name rather than a sentence. diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index f7a45febb..8cbb9b35a 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -77,7 +77,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value let sel = get(key).as_str().unwrap_or("").to_string(); ui.horizontal(|ui| { ui.add_sized(vec2(label_w, 18.0), egui::Label::new(tr("Default Output:"))); - let shown = if sel.is_empty() { "System Default".to_string() } else { sel.clone() }; + let shown = if sel.is_empty() { tr("System Default").to_string() } else { sel.clone() }; let r = egui::ComboBox::from_id_salt(("settings", key)).selected_text(shown).width(260.0).show_ui(ui, |ui| { if ui.selectable_label(sel.is_empty(), tr("System Default")).clicked() { acts.push(Act::Set(key.into(), json!(""))); @@ -101,7 +101,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value Item::Setting { key, label, kind, live } => { let v = get(key); let id = format!("settings.{key}"); - let tip = (!live).then_some("Stored, but not used by EffectCraft yet"); + let tip = (!live).then(|| tr("Stored, but not used by EffectCraft yet")); let mut resp: Option = None; match kind { Kind::Bool => { @@ -123,7 +123,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value let drag = egui::DragValue::new(&mut x).range(lo..=hi).speed(0.25).update_while_editing(false); let r = ui.add(crate::widgets::drag_value(drag)); if !unit.is_empty() { - ui.label(RichText::new(unit).color(t.text_dim)); + ui.label(RichText::new(tr(unit)).color(t.text_dim)); } if r.changed() { acts.push(Act::Set(key.into(), json!(x))); @@ -134,7 +134,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value let mut x = v.as_f64().unwrap_or(0.0); let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.1).max_decimals(3))); if !unit.is_empty() { - ui.label(RichText::new(unit).color(t.text_dim)); + ui.label(RichText::new(tr(unit)).color(t.text_dim)); } if r.changed() { acts.push(Act::Set(key.into(), json!(x))); @@ -146,7 +146,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value ui.label(RichText::new(tr("Darker")).color(t.text_dim)); let r = ui.add(egui::Slider::new(&mut x, lo..=hi).show_value(false)); ui.label(RichText::new(tr("Lighter")).color(t.text_dim)); - if ui.small_button("Default").clicked() { + if ui.small_button(tr("Default")).clicked() { acts.push(Act::Set(key.into(), json!(0.0))); } if r.changed() { From de0f5736209519ec0b43e17659a087719f24f892 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 12 Oct 2026 22:42:53 -0400 Subject: [PATCH 160/181] Layer Styles: the controls scroll both ways above the buttons (#1062) Keeping OK and Cancel in view took away the outer scroll area that also scrolled the dialog sideways, and the controls only scrolled up and down. In a narrow window (960x600 at 200 % UI Scale leaves the controls about 190 points next to the 200-point list) they ran past the dialog's right edge with no way to reach them. The controls now scroll both ways in the room left under their heading, with no minimum scrolled size, so a small budget can't hide the end of them; the list scrolls in its height the same way. Each control also registers dialog.layerStyle.value.