From 3b2e26ceef4c8c32d142e0cf3e6240b0a53651fd Mon Sep 17 00:00:00 2001 From: heojunfo Date: Mon, 2 Nov 2026 15:29:59 +0900 Subject: [PATCH 01/71] Render Queue EXR sequences are scan-line, not tiled (#2805) image's OpenEXR encoder writes 64x64 RLE tiles, which FilmCraft and other scan-line readers refuse. Write the frames with the exr crate in scan lines with ZIP, like comp.saveFrameAsExr. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/export/Cargo.toml | 1 + crates/export/src/lib.rs | 14 +++++++++++--- crates/export/tests/it/roundtrip.rs | 16 ++++++++++++++++ 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f45086f9b..162594099 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1555,6 +1555,7 @@ dependencies = [ "effectcraft-render", "effectcraft-time", "effectcraft-vp9enc", + "exr", "filmcraft-aac", "filmcraft-h264enc", "filmcraft-isobmff", diff --git a/crates/export/Cargo.toml b/crates/export/Cargo.toml index f2c185262..c477d2787 100644 --- a/crates/export/Cargo.toml +++ b/crates/export/Cargo.toml @@ -23,6 +23,7 @@ effectcraft-vp9enc = { workspace = true } effectcraft-hevcenc = { workspace = true } effectcraft-av1enc = { workspace = true } image = { workspace = true, features = ["png", "jpeg", "tiff", "exr", "tga"] } +exr = { workspace = true } gif = { workspace = true } rayon = { workspace = true } web-time = { workspace = true } diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 1b69a87bd..560d07978 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -297,7 +297,7 @@ struct StillOutput { fn write_still(cx: &Cx, file: &mut W, img: &Image, comp: &Comp, output: StillOutput) -> Result<()> { let StillOutput { format: fmt, channels, size: (w, h), quality } = output; - use image::{ExtendedColorType, ImageEncoder, ImageFormat}; + use image::{ExtendedColorType, ImageEncoder}; match fmt { OutputFormat::ExrSequence => { // Linear light, premultiplied (the EXR convention); RGB keeps alpha = 1 over the background. @@ -326,8 +326,16 @@ fn write_still(cx: &Cx, file: &mut W, img: &I data.extend_from_slice(&[c[0], c[1], c[2], a]); } } - let buf = image::Rgba32FImage::from_raw(w, h, data).ok_or_else(|| ExportError::Encode("exr buffer".into()))?; - image::DynamicImage::ImageRgba32F(buf).write_to(file, ImageFormat::OpenExr).map_err(|e| ExportError::Encode(e.to_string())) + // Scan lines with ZIP like `comp.saveFrameAsExr`: `image`'s encoder writes 64×64 RLE tiles, + // which FilmCraft and other scan-line readers refuse (#2805). + use exr::prelude::{Encoding, Layer, LayerAttributes, SpecificChannels, Vec2, WritableImage}; + let row = w as usize * 4; + let pixels = SpecificChannels::rgba(|p: Vec2| { + let o = p.y() * row + p.x() * 4; + (data[o], data[o + 1], data[o + 2], data[o + 3]) + }); + let layer = Layer::new((w as usize, h as usize), LayerAttributes::default(), Encoding::SMALL_LOSSLESS, pixels); + exr::image::Image::from_layer(layer).write().to_buffered(file).map_err(|e| ExportError::Encode(e.to_string())) } OutputFormat::JpegSequence => { let px = cx.pixels(img, comp, if channels == Channels::Alpha { Channels::Alpha } else { Channels::Rgb }, w, h); diff --git a/crates/export/tests/it/roundtrip.rs b/crates/export/tests/it/roundtrip.rs index 477191f64..fbad2ca48 100644 --- a/crates/export/tests/it/roundtrip.rs +++ b/crates/export/tests/it/roundtrip.rs @@ -302,6 +302,22 @@ fn exr_sequence_is_linear() { assert!((c[2] - 0.1329).abs() < 0.01 && (c[3] - 1.0).abs() < 1e-6, "{c:?}"); } +/// Sequence frames are scan-line EXR, not tiled, so scan-line readers such as FilmCraft open +/// them (#2805). +#[test] +fn exr_sequence_writes_scan_lines() { + let (p, cid) = project(None); + let d = out_dir("seq-exr-scanline"); + let om = OutputModule::for_format(OutputFormat::ExrSequence); + run(&p, cid, &NoFootage, &om, &d.join("f_[#####].exr")); + let bytes = std::fs::read(d.join("f_00000.exr")).expect("exr"); + // Version field: 2, then the flags byte whose 0x02 bit marks a single-part tiled file. + assert_eq!(&bytes[4..6], &[2, 0], "{:02x?}", &bytes[..8]); + let meta = exr::meta::MetaData::read_from_buffered(std::io::Cursor::new(&bytes), false).expect("exr header"); + assert_eq!(meta.headers[0].blocks, exr::meta::BlockDescription::ScanLines); + assert_eq!(meta.headers[0].compression, exr::compression::Compression::ZIP16); +} + /// A 32 bpc project whose only layer is a full-frame opaque solid of the given colour. fn bright_project(color: [f32; 3]) -> (Project, ItemId) { let mut p = Project::default(); From c9201556798217b2ebbcba98df1a78ba32f0ca50 Mon Sep 17 00:00:00 2001 From: Rahul Date: Mon, 2 Nov 2026 15:20:45 +0530 Subject: [PATCH 02/71] test(cli): a child that exits before draining stdin breaks the write, not the test The FreeBSD run of run_reads_commands_from_a_file_or_stdin hit a race: the CLI exits with code 2 on the usage-error cases before the helper finishes writing its standard input, so write_all fails with BrokenPipe and the unwrap panicked at apps/effectcraft-cli/tests/it/cli.rs:139:61. Ignore exactly BrokenPipe while writing the piped standard input and assert on any other error kind, so an early exit keeps feeding the assertions below instead of failing the harness. Fixes #2794 Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com> --- apps/effectcraft-cli/tests/it/cli.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/effectcraft-cli/tests/it/cli.rs b/apps/effectcraft-cli/tests/it/cli.rs index e9a96dd7e..74314e307 100644 --- a/apps/effectcraft-cli/tests/it/cli.rs +++ b/apps/effectcraft-cli/tests/it/cli.rs @@ -136,7 +136,14 @@ fn run_reads_arguments_from_a_file() { /// Run with `stdin` piped in; returns (exit code, stdout, stderr). fn run_with_stdin(args: &[&str], stdin: &str) -> (i32, String, String) { let mut child = bin().args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); - child.stdin.take().unwrap().write_all(stdin.as_bytes()).unwrap(); + // The child may exit before it has read all of standard input (the usage-error cases below + // exit with code 2 without draining stdin), so a broken pipe while writing is that early + // exit, not a failure of this helper; the assertions on the returned output still hold. + let mut stdin_pipe = child.stdin.take().unwrap(); + if let Err(err) = stdin_pipe.write_all(stdin.as_bytes()) { + assert_eq!(err.kind(), std::io::ErrorKind::BrokenPipe, "writing the child's standard input: {err}"); + } + drop(stdin_pipe); let out = child.wait_with_output().unwrap(); (out.status.code().unwrap_or(-1), String::from_utf8_lossy(&out.stdout).into_owned(), String::from_utf8_lossy(&out.stderr).into_owned()) } From a2f6cd3ed581ecca769e81ecd7bc64ce47d29107 Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 09:15:05 -0300 Subject: [PATCH 03/71] 3D Camera Tracker: a partial solve is not reported as solved (#2822) camera.solveStatus said `solved: true` for any stored solve, but the solver keeps the frames it cannot resect as unsolved and interpolates the camera there. `solved` now needs every frame, the same check camera.exportBlender makes, and `partial` marks a solve with gaps, next to `solvedFrames`. --- crates/engine/src/cmd/camera_cmds.rs | 9 +++++++-- crates/engine/src/tests/blender.rs | 17 +++++++++++++++++ docs/agents.md | 4 +++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/camera_cmds.rs b/crates/engine/src/cmd/camera_cmds.rs index e717d51cc..7f93730f8 100644 --- a/crates/engine/src/cmd/camera_cmds.rs +++ b/crates/engine/src/cmd/camera_cmds.rs @@ -246,13 +246,18 @@ fn status(s: &mut Session, p: &Value) -> Result { out["deleted"] = json!(settings.deleted.len()); out["lensDistortionSolved"] = json!(settings.lens_distortion); let solve = ct::solve(¶ms); - out["solved"] = json!(solve.is_some()); + // The solver interpolates the frames it could not solve: only a solve of every frame is + // `solved` (what `camera.exportBlender` needs); one with gaps is `partial`. + let solved_frames = solve.as_ref().map_or(0, |sv| sv.frames.iter().filter(|c| c.solved).count()); + let complete = solve.as_ref().is_some_and(|sv| solved_frames == sv.frames.len()); + out["solved"] = json!(complete); + out["partial"] = json!(solve.is_some() && !complete); if let Some(sv) = &solve { let f = sv.focal(); out["methodUsed"] = json!(sv.method_used.label()); out["averageError"] = json!(sv.average_error); out["points"] = json!(sv.points.len()); - out["solvedFrames"] = json!(sv.frames.iter().filter(|c| c.solved).count()); + out["solvedFrames"] = json!(solved_frames); out["focalLength"] = json!(f); out["horizontalAngleOfView"] = json!(sv.hfov(f)); out["groundPlane"] = json!(sv.ground.is_some()); diff --git a/crates/engine/src/tests/blender.rs b/crates/engine/src/tests/blender.rs index 84e939055..b4e0a5c80 100644 --- a/crates/engine/src/tests/blender.rs +++ b/crates/engine/src/tests/blender.rs @@ -238,6 +238,23 @@ fn blender_export_rejects_malformed_solves_without_panicking() { } } +/// A solve with unsolved (interpolated) frames is partial, not solved, so the status agrees with +/// the export, which refuses it. +#[test] +fn solve_status_reports_unsolved_frames_as_partial() { + let (mut s, lid, uid) = fixture(false); + let st = s.execute("camera.solveStatus", json!({"layer": lid.0})).unwrap(); + assert_eq!((&st["solved"], &st["partial"], &st["solvedFrames"]), (&json!(true), &json!(false), &json!(3)), "{st}"); + let mut solve = + ct::solve(&crate::camera_track::static_params(s.active_comp().unwrap().layer(lid).unwrap().props.find_group(uid).unwrap())).unwrap().as_ref().clone(); + solve.frames[1].solved = false; + s.execute("prop.set", json!({"layer": lid.0, "path": "effects/#1/solve", "value": solve.to_json()})).unwrap(); + s.camera_pending.clear(); + let st = s.execute("camera.solveStatus", json!({"layer": lid.0})).unwrap(); + assert_eq!((&st["solved"], &st["partial"], &st["solvedFrames"]), (&json!(false), &json!(true), &json!(2)), "{st}"); + assert!(s.execute_checked("camera.exportBlender", json!({"layer": lid.0, "path": path("partial.py")})).is_err()); +} + #[test] fn blender_export_respects_script_write_permission() { let (mut s, lid, _) = fixture(false); diff --git a/docs/agents.md b/docs/agents.md index 34162aacd..c3e51e751 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -476,7 +476,9 @@ undistort?, wait?}`) apply Effect ▸ Perspective ▸ 3D Camera Tracker (or reuse the layer's) and analyse it in the background: "Analyzing in background (step 1 of 2)" tracks features, "Solving camera" solves. `camera.analyze {layer?, effect?, wait?}` re-runs it, `camera.cancel` stops it without writing -anything. `camera.solveStatus` reports `progress`/`banner`, `analyzed`, `solved`, `methodUsed`, +anything. `camera.solveStatus` reports `progress`/`banner`, `analyzed`, `solved` (every frame +solved), `partial` with `solvedFrames` of `frames` (the camera is interpolated on the other frames, +and `camera.exportBlender` refuses such a solve), `methodUsed`, `averageError` (pixels), `focalLength`, `horizontalAngleOfView`, the current frame's `camera` (position, orientation, zoom), `groundPlane` and the solved `lensDistortion` (`k1`, `k2`, normalised by half the frame diagonal) when Solve Lens Distortion (`effects/#1/advanced/lensDistortion`, also on From 540ee9fbc5c9c0e8ed8293c5706036dc4d5320aa Mon Sep 17 00:00:00 2001 From: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:30:58 +0000 Subject: [PATCH 04/71] Motion Tile: mirror folds sample the edge texel, not the transparent outside MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tile boundary that lands exactly on an output pixel centre puts the tile fraction at exactly 0 or 1, and the sample position at the source's geometric edge — where sample_bilinear mixes in half a texel of the transparent outside. The fold column comes out half-faded: a visible seam line along the mirrored boundary, which sweeps the tile as Tile Center animates across pixel centres (#2820). Both the CPU path and the GPU kernel switch to the clamped sampler, which repeats the edge texel there; interior samples are untouched, and the default single-tile identity mapping already sampled texel centres. The regression test puts the folds exactly on pixel centres (tileWidth 50%, centre on the layer, mirror on) and asserts the fold columns stay opaque and equal the edge texels they mirror — it fails on the unfixed code at alpha 0.5. Fixes #2820 Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com> --- crates/effects/src/stylize2.rs | 37 +++++++++++++++++++++++++- crates/gpu/src/shaders/fx_stylize.wgsl | 4 ++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/crates/effects/src/stylize2.rs b/crates/effects/src/stylize2.rs index e0354ea75..d1020b1bc 100644 --- a/crates/effects/src/stylize2.rs +++ b/crates/effects/src/stylize2.rs @@ -457,7 +457,11 @@ fn motion_tile(ctx: &EffectCtx, b: Buf) -> Buf { fy = 1.0 - fy; } } - *px = src.sample_bilinear(fx * lw * s + b.offset[0], fy * lh * s + b.offset[1]); + // Clamped: a tile edge lands exactly on a pixel centre whenever the boundary does + // (#2820's animated Tile Center walks it across the grid), and the plain sampler + // would mix in the transparent outside at the source's geometric edge, leaving a + // half-faded seam pixel along the fold. + *px = src.sample_bilinear_clamped(fx * lw * s + b.offset[0], fy * lh * s + b.offset[1]); } }); Buf { img: out, offset: noff, scale: s } @@ -808,6 +812,37 @@ mod tests { } } + #[test] + fn motion_tile_mirror_folds_stay_opaque() { + // A fold that lands exactly on a pixel centre samples the source's geometric edge, where + // the plain bilinear tap reaches half a texel into the transparent outside — a faint + // seam line on the mirrored boundary that sweeps the tile while Tile Center animates + // (#2820). The fold must repeat the edge texel instead: opaque, and exactly the pixel + // it mirrors. + let img = ramp(10, 8); + let out = run_at( + "ec.stylize.motiontile", + &[("tileCenter", Value::Vec2([5.0, 4.0])), ("tileWidth", num(50.0)), ("mirrorEdges", Value::Bool(true))], + img.clone(), + 0.0, + ); + assert_eq!((out.img.width, out.img.height), (10, 8)); + for (x, mirrored) in [(2u32, 0u32), (7, 9)] { + for y in 0..out.img.height { + let a = out.img.get(x, y); + assert!((a[3] - 1.0).abs() < 1e-5, "fold x={x},y={y} faded to alpha {}", a[3]); + let b = img.get(mirrored, y); + for c in 0..4 { + assert!((a[c] - b[c]).abs() < 1e-5, "fold x={x},y={y} channel {c}"); + } + } + } + // And no pixel anywhere in the tile faded either. + for px in out.img.data.iter() { + assert!((px[3] - 1.0).abs() < 1e-5, "tile pixel faded to alpha {}", px[3]); + } + } + #[test] fn strobe_by_time() { assert!(strobe_on(0.02, 0.05, 1.0, 0.0, 1)); diff --git a/crates/gpu/src/shaders/fx_stylize.wgsl b/crates/gpu/src/shaders/fx_stylize.wgsl index 97db06c52..7fe42df7a 100644 --- a/crates/gpu/src/shaders/fx_stylize.wgsl +++ b/crates/gpu/src/shaders/fx_stylize.wgsl @@ -611,7 +611,9 @@ fn fxs_motiontile(@builtin(global_invocation_id) gid: vec3) { fy = 1.0 - fy; } } - textureStore(out, p, sample_bilinear(src, fx * P.f[0].y + P.f[1].x, fy * P.f[0].z + P.f[1].y)); + // Clamped for the same reason as the CPU path: a fold on a pixel centre samples the + // source's geometric edge, where the plain sampler mixes in the transparent outside. + textureStore(out, p, sample_bilinear_clamped(src, fx * P.f[0].y + P.f[1].x, fy * P.f[0].z + P.f[1].y)); } // CC RepeTile (stylize2::repetile) into a new output size. data: per output column (tile index, From c08e6f6d5fac59b497eb9324e9db1f57878bfeef Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 11:04:25 -0300 Subject: [PATCH 05/71] Roto Brush: keep frozen mattes in a session without their model (#2833) The frozen key hashes the chain keys, whose seed holds the session's model, so mattes frozen with SAM 2.1 didn't match in a session on Classic, such as a fresh effectcraft-cli render, and the frame was segmented again with Classic. Accept the key under any Roto Brush model too, as Object Matte has done since #1852. frozen_key is unchanged for projects frozen today, and editing a stroke or a matte setting still makes the frozen mattes stale. --- crates/effects/src/roto.rs | 75 +++++++++++++++++++++++++++++++++----- 1 file changed, 66 insertions(+), 9 deletions(-) diff --git a/crates/effects/src/roto.rs b/crates/effects/src/roto.rs index b53fa5d65..5a96a1555 100644 --- a/crates/effects/src/roto.rs +++ b/crates/effects/src/roto.rs @@ -9,7 +9,8 @@ //! neighbouring frames with [`crate::EffectHost::self_at`]). Undo, redo and reopening a project //! 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. +//! parameter ([`FrozenCache`]); they are used while their key still matches, whichever model the +//! session has (a session without the model that froze them uses them as they are). //! //! Rendering: binary segmentation → Roto Brush Matte (Shift Edge, Reduce Chatter, Feather, //! Contrast) → Refine Edge matting inside the Refine Edge band (guided filter + closed-form @@ -242,6 +243,11 @@ fn seed_with(params: &Params, layer_size: [f64; 2], fps: f64, model: &str) -> u6 h } +/// Show Object: 0 shows every object, N object N alone. +fn show_object_of(params: &Params) -> u32 { + f(params, "rotoBrushMatte/object").round().clamp(0.0, 32.0) as u32 +} + /// The layer frame shown at layer time `t` seconds. pub fn frame_of(t: f64, fps: f64) -> i64 { (t * fps + 1e-6).floor() as i64 @@ -415,7 +421,7 @@ impl Chain { let data = data(params); let keys = data.chain_keys(seed(params, layer_size, fps)); let saved = saved_for(params, &data, layer_size, fps); - let show_object = f(params, "rotoBrushMatte/object").round().clamp(0.0, 32.0) as u32; + let show_object = show_object_of(params); Chain { refine_radius: data.refine_radius(), data, keys, opts: seg_opts(params, scale), model: model_for(params), saved, show_object } } @@ -869,7 +875,8 @@ fn needs_neighbours(mp: &MatteParams) -> bool { // ------------------------------------------------------------------ Freeze /// Frozen mattes (Freeze): the final 8-bit alpha and the Refine Edge band of every frame of -/// the span at full resolution, valid while `key` matches [`frozen_key`]. +/// the span at full resolution, valid while `key` matches [`frozen_key`] under the session's model or +/// any other Roto Brush model. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct FrozenCache { @@ -899,21 +906,35 @@ impl FrozenCache { } } -/// What frozen mattes depend on: every chain key and the matte settings. +/// What frozen mattes depend on: every chain key (the session's model is in their seed) and the matte settings. pub fn frozen_key(params: &Params, layer_size: [f64; 2], fps: f64) -> String { - let ch = Chain::new(params, layer_size, fps, 1.0); + frozen_key_with(params, layer_size, fps, model_for(params).map_or(effectcraft_segment::CLASSICAL, |m| m.info().id)) +} + +/// [`frozen_key`] for the model with id `model` ([`effectcraft_segment::CLASSICAL`]: none). +fn frozen_key_with(params: &Params, layer_size: [f64; 2], fps: f64, model: &str) -> String { let mut h = 0xcbf2_9ce4_8422_2325; - for (f, k) in &ch.keys { + for (f, k) in &data(params).chain_keys(seed_with(params, layer_size, fps, model)) { fnv(&mut h, &f.to_le_bytes()); fnv(&mut h, &k.to_le_bytes()); } fnv(&mut h, format!("{:?}", matte_params(params)).as_bytes()); - if ch.show_object != 0 { - fnv(&mut h, &ch.show_object.to_le_bytes()); + let show_object = show_object_of(params); + if show_object != 0 { + fnv(&mut h, &show_object.to_le_bytes()); } format!("{h:016x}") } +/// Whether frozen mattes still apply: their key matches under the session's model, or under the model that froze +/// them, which this session may not have loaded (a fresh headless render). Frozen mattes need no segmentation. +fn frozen_applies(fc: &FrozenCache, params: &Params, layer_size: [f64; 2], fps: f64) -> bool { + fc.key == frozen_key(params, layer_size, fps) + || std::iter::once(effectcraft_segment::CLASSICAL) + .chain(effectcraft_segment::MODELS.iter().filter(|m| m.task == effectcraft_segment::Task::Mask).map(|m| m.id)) + .any(|id| fc.key == frozen_key_with(params, layer_size, fps, id)) +} + /// Whether the instance is frozen (has frozen mattes). pub fn is_frozen(params: &Params) -> bool { !s(params, FROZEN).is_empty() @@ -1132,7 +1153,7 @@ fn matte_for(ctx: &EffectCtx, f: i64, img: &Arc, scale: f64) -> Option<(V let (w, h) = (img.width as usize, img.height as usize); // Frozen mattes. if let Some(fc) = frozen(ctx.params) - && fc.key == frozen_key(ctx.params, ctx.layer_size, ctx.fps()) + && frozen_applies(&fc, ctx.params, ctx.layer_size, ctx.fps()) && let Some(fr) = fc.frames.get(&f) { let [fw, fh] = fc.size; @@ -1351,6 +1372,42 @@ mod tests { assert!(o.data.iter().all(|p| p[3] == 0.0)); } + /// Mattes frozen with a model render as frozen in a session that has another model or none (a fresh headless + /// render), instead of being segmented again with the classic engine. + #[test] + fn frozen_mattes_survive_a_session_without_their_model() { + let mut d = RotoData::default(); + d.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 3.0, points: vec![[35.0, 40.0], [45.0, 40.0]], object: 0 }, 2, [0, 5]); + let mut params = params_with(&d); + params.values.insert(INPUT_KEY.into(), Value::Str("frozen-without-the-model".into())); + let size = [100.0, 80.0]; + assert!(model_for(¶ms).is_none(), "this session has no model"); + // the frozen matte is the left half of the layer: no segmentation of the disk gives it + let alpha: Vec = (0..100 * 80).map(|i| f32::from(u8::from(i % 100 < 50))).collect(); + let mut fc = FrozenCache { key: frozen_key_with(¶ms, size, 25.0, effectcraft_segment::SAM2_TINY.id), size: [100, 80], ..Default::default() }; + fc.frames.insert(0, freeze_frame(&alpha, &[0; 100 * 80])); + assert_ne!(fc.key, frozen_key(¶ms, size, 25.0)); + params.values.insert(FROZEN.into(), Value::Str(fc.to_json())); + let host = Seq; + let ctx = EffectCtx { + params: ¶ms, + time: 0.001, + layer_size: size, + seed: 1, + adjustment: false, + env: EffectEnv { host: Some(&host), frame_rate: 25.0, ..Default::default() }, + }; + let out = crate::apply(&specs()[0], &ctx, Buf { img: frame(0.0), offset: [0.0; 2], scale: 1.0 }).img; + let a = |x: usize, y: usize| out.data[y * 100 + x][3]; + assert!(a(10, 70) > 0.99 && a(90, 10) < 0.01, "the frozen matte is used: {} at (10, 70), {} at (90, 10)", a(10, 70), a(90, 10)); + // a stroke edited after the freeze still makes it stale, whatever the model + let mut moved = d.clone(); + moved.strokes[0].points[0] = [36.0, 40.0]; + let mut edited = params.clone(); + edited.values.insert(STROKES.into(), Value::Str(moved.to_json())); + assert!(!frozen_applies(&fc, &edited, size, 25.0)); + } + /// A stand-in tracker for two objects: object 0 is the moving disk, object 1 a fixed square. It records how many /// objects (and prompts per object) it was asked for. struct TwoObjects(std::sync::Mutex>>); From 1cd7c2e6f67dc5b49758ac84e8eb68259b2932e1 Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 11:42:22 -0300 Subject: [PATCH 06/71] Roto Brush: freezing again keeps frames that are frozen already (#2833) With frozen mattes now used in a session without the model that froze them, a second Freeze in such a session still segmented every frame again, so it replaced the model's matte with the session engine's. frame_matte, which only Freeze calls, now returns a frame's frozen matte while the frozen mattes apply, and segments only frames that have none. --- crates/effects/src/roto.rs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/roto.rs b/crates/effects/src/roto.rs index 5a96a1555..60ee2b7f1 100644 --- a/crates/effects/src/roto.rs +++ b/crates/effects/src/roto.rs @@ -1256,7 +1256,8 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf { /// The final matte of the effect at layer frame `f` on the full-resolution layer grid of /// `input` (the effect's input buffer at scale 1), computing what is missing. For Freeze and -/// the Layer panel overlays. +/// the Layer panel overlays. A frame that is frozen already keeps its frozen matte, so freezing +/// again in a session without the model that froze it does not replace that model's matte. pub fn frame_matte( params: &Params, layer_size: [f64; 2], @@ -1267,6 +1268,9 @@ pub fn frame_matte( ) -> Option<(Vec, Vec)> { let chain = Chain::new(params, layer_size, fps, 1.0); let img = frame(f)?; + if let Some(kept) = frozen_frame_matte(params, layer_size, fps, f, img.width as usize, img.height as usize) { + return Some(kept); + } let seg = chain.ensure(f, frame, cancel)?; let mp = matte_params(params); let (mut prev, mut next) = (None, None); @@ -1282,6 +1286,20 @@ pub fn frame_matte( Some((alpha, seg.refine.clone())) } +/// Frame `f`'s frozen alpha and band on a `w`×`h` grid, while the frozen mattes apply ([`frozen_applies`]) and were +/// frozen at that size. +fn frozen_frame_matte(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, w: usize, h: usize) -> Option<(Vec, Vec)> { + let fc = frozen(params)?; + let fr = fc.frames.get(&f)?; + if fc.size != [w, h] || !frozen_applies(&fc, params, layer_size, fps) { + return None; + } + let n = w.checked_mul(h).filter(|n| *n > 0 && *n <= rle::MAX_PLANE_BYTES)?; + let alpha = rle::decode_bounded(&fr.a, n).filter(|a| a.len() == n)?; + let band = rle::decode_bounded(&fr.b, n).filter(|b| b.len() == n)?; + Some((alpha.iter().map(|v| f32::from(*v) / 255.0).collect(), band)) +} + #[cfg(test)] mod tests { use super::*; @@ -1400,6 +1418,11 @@ mod tests { let out = crate::apply(&specs()[0], &ctx, Buf { img: frame(0.0), offset: [0.0; 2], scale: 1.0 }).img; let a = |x: usize, y: usize| out.data[y * 100 + x][3]; assert!(a(10, 70) > 0.99 && a(90, 10) < 0.01, "the frozen matte is used: {} at (10, 70), {} at (90, 10)", a(10, 70), a(90, 10)); + // freezing again in this session keeps the frozen matte instead of segmenting with the classic engine + let mut get = |g: i64| Some(Arc::new(frame(g as f64 / 25.0))); + let (kept, band) = frame_matte(¶ms, size, 25.0, 0, &mut get, &|| false).expect("frame 0's matte"); + assert_eq!((kept, band.len()), (alpha.clone(), 100 * 80), "a second Freeze keeps the frozen frame"); + assert!(frame_matte(¶ms, size, 25.0, 1, &mut get, &|| false).is_some_and(|(a, _)| a[40 * 100 + 10] < 0.01), "a frame with none is segmented"); // a stroke edited after the freeze still makes it stale, whatever the model let mut moved = d.clone(); moved.strokes[0].points[0] = [36.0, 40.0]; From 0de8d57cf89465c2cc3802d212438fa0c55cb2e2 Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 11:46:03 -0300 Subject: [PATCH 07/71] Import: EDL source timecodes land on each file's own time (#2832) FilmCraft's EDL reader keeps source timecodes as media time and leaves the rebase to the caller after probing. The probe now keeps the file's start timecode (Footage::file_timecode), and an EDL import rebases each found file's clips with filmcraft_interchange::rebase_source_timecode before the layers are made, as FilmCraft's own import does. --- crates/interchange/src/import.rs | 34 +++++++++++++++++++++-- crates/interchange/src/tests.rs | 15 ++++++++++ crates/media/src/probe.rs | 2 ++ crates/media/tests/it/file_timecode.rs | 38 ++++++++++++++++++++++++++ crates/media/tests/it/main.rs | 1 + crates/project/src/footage.rs | 11 ++++++-- 6 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 crates/media/tests/it/file_timecode.rs diff --git a/crates/interchange/src/import.rs b/crates/interchange/src/import.rs index 28f638a90..64dbeb3da 100644 --- a/crates/interchange/src/import.rs +++ b/crates/interchange/src/import.rs @@ -156,7 +156,13 @@ pub fn import( }), name: Some(opts.name.trim_end_matches(|c| c != '.').trim_end_matches('.').to_string()).filter(|s| !s.is_empty()), }; - let (imported, report) = filmcraft_interchange::import_with(bytes, format.fc(), &fc_opts)?; + let (mut imported, report) = filmcraft_interchange::import_with(bytes, format.fc(), &fc_opts)?; + // Each file is probed once, though an EDL's media are probed before the layers are made. + let mut probed: HashMap> = HashMap::new(); + let mut probe_once = |path: &str| probed.entry(path.to_string()).or_insert_with(|| probe(path)).clone(); + if format == TimelineFormat::Edl { + rebase_to_file_timecode(&mut imported.project, &mut probe_once); + } let mut res = ImportResult { format: Some(format), ..Default::default() }; for e in &report.entries { if e.count > 1 { res.warnings.push(format!("{} (×{})", e.message, e.count)) } else { res.warnings.push(e.message.clone()) } @@ -164,10 +170,34 @@ pub fn import( let folder_name = if opts.name.is_empty() { "Imported Timeline".to_string() } else { opts.name.clone() }; res.folder = project.add_item(&folder_name, Label::None, None, ItemKind::Folder); let mut im = Importer { src: &imported.project, project, res, items: HashMap::new(), solids: HashMap::new(), solids_folder: None, bins: HashMap::new() }; - im.run(&imported.sequences, probe); + im.run(&imported.sequences, &mut probe_once); Ok(im.res) } +/// An EDL's source times are its source timecodes, which FilmCraft's reader keeps as media time. +/// Put the clips of each file that has a start timecode on the file's own time, as FilmCraft does +/// when it links the media of an imported EDL. +fn rebase_to_file_timecode(project: &mut fp::Project, probe: &mut dyn FnMut(&str) -> Option) { + let files: Vec<(fp::ItemId, String)> = project + .items + .values() + .filter_map(|it| match &it.kind { + fp::ItemKind::Media(m) if !m.offline => match &m.media { + fp::MediaRef::File { path } => Some((it.id, path.clone())), + _ => None, + }, + _ => None, + }) + .collect(); + for (id, path) in files { + if let Some(f) = probe(&path) + && let Some(tc) = f.file_timecode + { + filmcraft_interchange::rebase_source_timecode(project, id, tc, crate::rate_out(f.native_rate.unwrap_or(f.frame_rate))); + } + } +} + struct Importer<'a> { src: &'a fp::Project, project: &'a mut Project, diff --git a/crates/interchange/src/tests.rs b/crates/interchange/src/tests.rs index 97efd76cd..e3d5ade36 100644 --- a/crates/interchange/src/tests.rs +++ b/crates/interchange/src/tests.rs @@ -365,6 +365,21 @@ fn edl_imports_one_track() { assert!(c.layers.len() >= 2); } +/// An EDL's source timecodes are footage time once the file's own start timecode is known: +/// 01:00:01:00 in a file that starts at 01:00:00:00 is one second in, not 3601 s. +#[test] +fn edl_source_timecode_lands_on_the_files_own_time() { + let edl = "TITLE: CUT\nFCM: NON-DROP FRAME\n\n001 A001 V C 01:00:01:00 01:00:02:00 01:00:00:00 01:00:01:00\n* FROM CLIP NAME: a.mov\n* SOURCE FILE: /media/a.mov\n"; + let mut probe_tc = |path: &str| probe(path).map(|f| Footage { file_timecode: Some(3600 * 25), ..f }); + let mut p = Project::default(); + let opts = ImportOptions { name: "cut.edl".into(), edl_frame_rate: Some(25.0), ..Default::default() }; + let r = import(&mut p, edl.as_bytes(), None, &opts, &mut probe_tc).unwrap(); + let c = p.comp(r.comps[0]).unwrap(); + let l = layer(c, "a.mov"); + assert!(near(l.layer_time(l.in_point), t(1.0)), "{}", l.layer_time(l.in_point).seconds()); + assert!(near(l.start_time, t(-1.0)), "{}", l.start_time.seconds()); +} + #[test] fn rejects_unknown_documents() { let mut p = Project::default(); diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 6bae86f5b..33928eb92 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -198,6 +198,8 @@ pub(crate) fn footage_from_info(path: &str, info: &filmcraft_media::MediaInfo) - missing: false, sequence: Vec::new(), color_profile: v.and_then(|v| profile_of(&v.color)), + // counted in video frames, so only with a video stream + file_timecode: v.and(info.start_timecode), ..Default::default() } } diff --git a/crates/media/tests/it/file_timecode.rs b/crates/media/tests/it/file_timecode.rs new file mode 100644 index 000000000..e5cbfa92d --- /dev/null +++ b/crates/media/tests/it/file_timecode.rs @@ -0,0 +1,38 @@ +//! The start timecode a movie records is read into its footage (`Footage::file_timecode`). + +use std::io::Cursor; + +use effectcraft_project::FootageKind; +use filmcraft_isobmff::{Brand, Mp4Writer, SampleEntry, TimecodeConfig, TrackConfig, WriteSample, WriterOptions}; + +/// A two-frame 24 fps Motion JPEG MOV, with a timecode track from `start_frame` when given. +fn mov(start_frame: Option) -> Vec { + let mut jpeg = Vec::new(); + image::RgbImage::from_pixel(16, 16, image::Rgb([200, 40, 90])).write_to(&mut Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).expect("jpeg"); + let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mov)).expect("writer"); + let v = w.add_track(TrackConfig::new(SampleEntry::jpeg(16, 16), 24)).expect("video track"); + if let Some(start) = start_frame { + let tc = TimecodeConfig { timescale: 24, frame_duration: 1, frames_per_second: 24, ..Default::default() }; + w.add_timecode_track(v, tc, start).expect("timecode track"); + } + for _ in 0..2 { + w.write_sample(v, WriteSample { data: &jpeg, duration: 1, composition_offset: 0, is_sync: true }).expect("frame"); + } + w.finish().expect("finish").into_inner() +} + +#[test] +fn a_movies_start_timecode_is_read() { + let dir = std::env::temp_dir().join(format!("effectcraft-file-timecode-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("mkdir"); + // 10:00:00:00 at 24 fps, and a movie with no timecode + for (name, start) in [("tc.mov", Some(10 * 3600 * 24)), ("none.mov", None)] { + let p = dir.join(name); + std::fs::write(&p, mov(start)).expect("write"); + let f = effectcraft_media::probe(&p).expect("probe"); + assert_eq!(f.kind, FootageKind::Video, "{name}"); + assert_eq!(f.file_timecode, start.map(i64::from), "{name}"); + assert_eq!(f.start_timecode, None, "{name}: Interpret Footage still uses the source file timecode"); + } + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/media/tests/it/main.rs b/crates/media/tests/it/main.rs index 69bed4a9f..cc0f5b987 100644 --- a/crates/media/tests/it/main.rs +++ b/crates/media/tests/it/main.rs @@ -3,6 +3,7 @@ mod aac_layout; mod downmix; mod exif_orientation; +mod file_timecode; mod footage; mod open_in_place; mod tga; diff --git a/crates/project/src/footage.rs b/crates/project/src/footage.rs index 8cf07ea2e..2cf887b1f 100644 --- a/crates/project/src/footage.rs +++ b/crates/project/src/footage.rs @@ -255,10 +255,16 @@ pub struct Footage { #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub alphabetical: bool, /// Interpret Footage ▸ Start Timecode ▸ Override Start: the frame (at the footage's frame - /// rate) its timecode shows at its first frame. `None` = Use Source File Timecode (0: file - /// timecode isn't read). It only relabels source time, as in After Effects. + /// rate) its timecode shows at its first frame. `None` = Use Source File Timecode (shown + /// from 0: [`Footage::file_timecode`] isn't used here yet). It only relabels source time, as + /// in After Effects. #[serde(default, skip_serializing_if = "Option::is_none")] pub start_timecode: Option, + /// The start timecode recorded in the file, in frames at its own frame rate (`native_rate`, + /// else `frame_rate`). Read when probing; importing an EDL uses it to turn the list's source + /// timecodes into footage time. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file_timecode: Option, /// Markers the file carries (a WAV's cue points and XMP markers), at footage time. A layer /// made from the footage starts with them, and Update Markers From Source copies new ones. #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -299,6 +305,7 @@ impl Default for Footage { page: 0, alphabetical: false, start_timecode: None, + file_timecode: None, markers: vec![], } } From 7d00b5322d41b921a45d1b7691060525c0498780 Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 12:07:55 -0300 Subject: [PATCH 08/71] Lottie: read the closed flag older Bodymovin files keep outside the path Bodymovin 3.x and early 4.x write `cl` on a mask and `closed` on an `sh` item, with no `c` in the path, so the import brought these masks and paths in open. An open mask doesn't cut its layer, and masked solids filled the frame. The path's own `c` still wins. Fixes #2837. --- crates/lottie/src/import.rs | 3 ++- crates/lottie/src/shapes.rs | 9 ++++++--- crates/lottie/src/tests.rs | 25 +++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/lottie/src/import.rs b/crates/lottie/src/import.rs index 6d7cc1f5a..7a374ae9b 100644 --- a/crates/lottie/src/import.rs +++ b/crates/lottie/src/import.rs @@ -305,7 +305,8 @@ fn masks(im: &Im, ids: &mut Ids, list: &[Json], g: &mut PropGroup) { import_prop(im, j, p, conv); } }; - set("pt", "path", &shapes::to_path); + let closed = m.get("cl").and_then(Json::as_bool).unwrap_or(false); + set("pt", "path", &|v| shapes::to_path(v, closed)); set("o", "opacity", &anim::to_scalar); set("x", "expansion", &anim::to_scalar); set("f", "feather", &anim::to_vec2); diff --git a/crates/lottie/src/shapes.rs b/crates/lottie/src/shapes.rs index 5b9d29970..fab8d3646 100644 --- a/crates/lottie/src/shapes.rs +++ b/crates/lottie/src/shapes.rs @@ -27,7 +27,9 @@ pub(crate) fn path_value(v: &Value) -> Json { } } -pub(crate) fn to_path(j: &Json) -> Option { +/// A Lottie path. `closed` is used when the path has no `c`: Bodymovin 3.x and early 4.x keep the +/// flag outside the path, as `cl` on a mask and `closed` on an `sh` item. +pub(crate) fn to_path(j: &Json, closed: bool) -> Option { let o = match j { Json::Array(a) => a.first()?, o => o, @@ -48,7 +50,7 @@ pub(crate) fn to_path(j: &Json) -> Option { in_tangents: fit(pts("i")), out_tangents: fit(pts("o")), vertices, - closed: o.get("c").and_then(Json::as_bool).unwrap_or(false), + closed: o.get("c").and_then(Json::as_bool).unwrap_or(closed), feather: Vec::new(), })) } @@ -493,7 +495,8 @@ pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec { let mut g = build::shape_path(ids, ShapePath::default()); dir(&mut g); - set(im, &mut g, "path", j.get("ks"), &to_path); + let closed = j.get("closed").and_then(Json::as_bool).unwrap_or(false); + set(im, &mut g, "path", j.get("ks"), &|v| to_path(v, closed)); g } "fl" => { diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index 83c81e242..62bec834a 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -520,6 +520,31 @@ fn zero_time_stretch_is_not_imported() { assert!(imp.warnings.iter().any(|w| w.contains("time stretch")), "{:?}", imp.warnings); } +#[test] +fn legacy_closed_flags_close_masks_and_paths() { + // Bodymovin 3.x and early 4.x put the closed flag on the mask (`cl`) and on the `sh` item + // (`closed`), with no `c` in the path. An open mask would leave the solid whole. + let square = json!({"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[5, 5], [15, 5], [15, 15], [5, 15]]}); + let doc = json!({"w": 20, "h": 20, "fr": 30, "ip": 0, "op": 30, "assets": [], "layers": [ + {"ty": 4, "ind": 1, "ip": 0, "op": 30, "st": 0, "ks": {}, + "shapes": [{"ty": "sh", "closed": true, "ks": {"k": [{"t": 0, "s": [square], "e": [square]}, {"t": 10}]}}]}, + {"ty": 1, "ind": 2, "sc": "#ff0000", "sw": 20, "sh": 20, "ip": 0, "op": 30, "st": 0, "ks": {}, + "masksProperties": [{"mode": "a", "cl": true, "pt": {"k": square}}]}]}); + let mut q = Project::default(); + let nc = import(&mut q, doc.to_string().as_bytes(), "Imported", "", &mut |_| None).unwrap().comp; + let comp = q.comp(nc).unwrap(); + let closed = |v: &Value| matches!(v, Value::Path(sp) if sp.closed); + let shape = comp.layers[0].props.sub("contents").unwrap().groups().next().unwrap(); + let keys = &shape.get("path").unwrap().keys; + assert_eq!(keys.len(), 2); + assert!(keys.iter().all(|k| closed(&k.value))); + let mask = comp.layers[1].masks().unwrap().groups().next().unwrap(); + assert!(closed(&mask.get("path").unwrap().value)); + let img = render_frame(&q, nc, s(0.0), 1.0); + assert_eq!(img.data[0][3], 0.0, "the mask cuts the corner away"); + assert!(img.data[10 * 20 + 10][3] > 0.99); +} + #[test] fn precomp_with_time_remap() { let (mut p, cid) = setup(); From 0af6d2aab894e7791214fda06c3d5b6176121435 Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 12:16:12 -0300 Subject: [PATCH 09/71] Text animators: a later animator's Opacity can show characters an earlier one hid Animator Opacity stacked by multiplying, so once one animator set 0 % no animator below it could raise it again. Each animator now blends the opacity left by the ones above it toward its own value. A single Opacity animator gives the same result as before. Fixes #2838. --- crates/render/src/text.rs | 5 ++++- crates/render/src/text/tests.rs | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 19c17d53f..683da82ee 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -343,7 +343,10 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex } angles.apply(c, k); if let Some(o) = op { - c.opacity *= 1.0 + (o / 100.0 - 1.0) * k; + // Blend toward this animator's value, so a later animator can show characters an + // earlier one hid (Lottie files rely on it: Opacity 0 % on every character, then + // animators at 100 % that sweep in). With one animator this is the same as before. + c.opacity += (o - c.opacity) * k; } if let Some(f) = fill { c.fill = Some(f); diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 503ea097c..0280115a5 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -548,6 +548,22 @@ fn keyframed_selector_animates() { assert_eq!(selection(&p, cid, 1.0, 1), vec![0.0; 4]); } +#[test] +fn a_later_animator_opacity_shows_characters_an_earlier_one_hid() { + let (mut p, cid, comp) = setup(400, 200); + let mut l = text_layer(&mut p, &comp, doc("ABCD", 40.0)); + animator(&mut p, &mut l, &["opacity"], &["range"]); + set(&mut l, "text/animators/#1/properties/opacity", Value::Scalar(0.0)); + animator(&mut p, &mut l, &["opacity"], &["range"]); + set(&mut l, "text/animators/#2/properties/opacity", Value::Scalar(60.0)); + set(&mut l, "text/animators/#2/selectors/#1/end", Value::Scalar(50.0)); + p.comp_mut(cid).unwrap().layers.push(l); + let glyphs = glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]); + let opacity: Vec = glyphs.iter().map(|g| (g.1.opacity * 1000.0).round() / 1000.0).collect(); + // The second animator selects "AB" only: they come back at 60 %, "CD" stay hidden. + assert_eq!(opacity, [60.0, 60.0, 0.0, 0.0]); +} + #[test] fn caret_follows_animators_and_path_text() { let (mut p, cid, comp) = setup(400, 400); From 70adad55876483dc18532b0d93aa5489d9ef267c Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 12:26:37 -0300 Subject: [PATCH 10/71] Import: an EDL's "rebase after probing" note names only the media left on timecode (#2832) The import now does the rebase FilmCraft's reader asks for, so passing the reader's note on told people to do something already done. It is dropped when every file the list uses at an hour or more of timecode was rebased, and replaced by a warning naming the ones that weren't (the file is missing or has no start timecode). --- crates/interchange/src/import.rs | 52 +++++++++++++++++++++++--------- crates/interchange/src/tests.rs | 28 +++++++++++++++++ 2 files changed, 66 insertions(+), 14 deletions(-) diff --git a/crates/interchange/src/import.rs b/crates/interchange/src/import.rs index 64dbeb3da..8a15e874e 100644 --- a/crates/interchange/src/import.rs +++ b/crates/interchange/src/import.rs @@ -1,7 +1,7 @@ //! Timeline documents → compositions (File ▸ Import ▸ Adobe Premiere Pro Project / Timeline //! Interchange…). -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use effectcraft_color::{BlendMode, Label}; use effectcraft_keyframe::{Keyframe, Value}; @@ -160,11 +160,17 @@ pub fn import( // Each file is probed once, though an EDL's media are probed before the layers are made. let mut probed: HashMap> = HashMap::new(); let mut probe_once = |path: &str| probed.entry(path.to_string()).or_insert_with(|| probe(path)).clone(); - if format == TimelineFormat::Edl { - rebase_to_file_timecode(&mut imported.project, &mut probe_once); - } + let left_on_timecode = if format == TimelineFormat::Edl { rebase_to_file_timecode(&mut imported.project, &mut probe_once) } else { Vec::new() }; let mut res = ImportResult { format: Some(format), ..Default::default() }; for e in &report.entries { + // The reader's "rebase after probing" note is done above: name only the media it wasn't. + if format == TimelineFormat::Edl && e.message.starts_with(EDL_MEDIA_TIME_NOTE) { + if !left_on_timecode.is_empty() { + res.warnings + .push(format!("EDL source timecodes stay media time for {}: the file is missing or has no start timecode", left_on_timecode.join(", "))); + } + continue; + } if e.count > 1 { res.warnings.push(format!("{} (×{})", e.message, e.count)) } else { res.warnings.push(e.message.clone()) } } let folder_name = if opts.name.is_empty() { "Imported Timeline".to_string() } else { opts.name.clone() }; @@ -174,28 +180,46 @@ pub fn import( Ok(im.res) } +/// The start of the note FilmCraft's EDL reader adds when source timecodes look like real +/// timecode (an hour or more) and it has kept them as media time. +const EDL_MEDIA_TIME_NOTE: &str = "EDL source timecodes were imported as media time"; + /// An EDL's source times are its source timecodes, which FilmCraft's reader keeps as media time. /// Put the clips of each file that has a start timecode on the file's own time, as FilmCraft does -/// when it links the media of an imported EDL. -fn rebase_to_file_timecode(project: &mut fp::Project, probe: &mut dyn FnMut(&str) -> Option) { - let files: Vec<(fp::ItemId, String)> = project +/// when it links the media of an imported EDL. Returns the names of the media that stay on +/// timecode though their clips use an hour or more of it (the file is missing or has none). +fn rebase_to_file_timecode(project: &mut fp::Project, probe: &mut dyn FnMut(&str) -> Option) -> Vec { + let hour = filmcraft_time::Tick(3600 * filmcraft_time::TICKS_PER_SECOND); + let mut on_timecode: HashSet = HashSet::new(); + for it in project.items.values() { + if let fp::ItemKind::Sequence(seq) = &it.kind { + on_timecode.extend(seq.all_tracks().flat_map(|t| &t.items).filter(|ti| ti.source_in >= hour).map(|ti| ti.item)); + } + } + let files: Vec<(fp::ItemId, String, String, bool)> = project .items .values() .filter_map(|it| match &it.kind { - fp::ItemKind::Media(m) if !m.offline => match &m.media { - fp::MediaRef::File { path } => Some((it.id, path.clone())), + fp::ItemKind::Media(m) => match &m.media { + fp::MediaRef::File { path } => Some((it.id, it.name.clone(), path.clone(), m.offline)), _ => None, }, _ => None, }) .collect(); - for (id, path) in files { - if let Some(f) = probe(&path) - && let Some(tc) = f.file_timecode - { - filmcraft_interchange::rebase_source_timecode(project, id, tc, crate::rate_out(f.native_rate.unwrap_or(f.frame_rate))); + let mut left = Vec::new(); + for (id, name, path, offline) in files { + match if offline { None } else { probe(&path) } { + Some(Footage { file_timecode: Some(tc), native_rate, frame_rate, .. }) => { + filmcraft_interchange::rebase_source_timecode(project, id, tc, crate::rate_out(native_rate.unwrap_or(frame_rate))); + } + _ if on_timecode.contains(&id) => left.push(name), + _ => {} } } + left.sort(); + left.dedup(); + left } struct Importer<'a> { diff --git a/crates/interchange/src/tests.rs b/crates/interchange/src/tests.rs index e3d5ade36..6e738e86c 100644 --- a/crates/interchange/src/tests.rs +++ b/crates/interchange/src/tests.rs @@ -378,6 +378,34 @@ fn edl_source_timecode_lands_on_the_files_own_time() { let l = layer(c, "a.mov"); assert!(near(l.layer_time(l.in_point), t(1.0)), "{}", l.layer_time(l.in_point).seconds()); assert!(near(l.start_time, t(-1.0)), "{}", l.start_time.seconds()); + assert!(!r.warnings.iter().any(|w| w.contains("media time")), "the rebase is done: {:?}", r.warnings); +} + +/// The reader's note to rebase after probing is replaced by the media that couldn't be rebased: +/// a file with no start timecode and a missing file. Media cut from 00:00:00:00 needs no rebase. +#[test] +fn edl_media_left_on_timecode_is_named() { + let event = |n: u32, reel: &str, ch: &str, src: (&str, &str), rec: (&str, &str), file: &str| { + format!("{n:03} {reel:<8} {ch:<5} C {} {} {} {}\n* FROM CLIP NAME: {file}\n* SOURCE FILE: /media/{file}\n\n", src.0, src.1, rec.0, rec.1) + }; + let edl = [ + "TITLE: CUT\nFCM: NON-DROP FRAME\n\n".to_string(), + event(1, "A", "V", ("01:00:01:00", "01:00:02:00"), ("01:00:00:00", "01:00:01:00"), "a.mov"), + event(2, "B", "V", ("01:00:01:00", "01:00:02:00"), ("01:00:01:00", "01:00:02:00"), "b.mov"), + event(3, "C", "V", ("01:00:01:00", "01:00:02:00"), ("01:00:02:00", "01:00:03:00"), "c.mov"), + event(4, "D", "A", ("00:00:00:00", "00:00:03:00"), ("01:00:00:00", "01:00:03:00"), "d.wav"), + ] + .concat(); + let mut probe_some = |path: &str| match path { + "/media/a.mov" => probe(path).map(|f| Footage { file_timecode: Some(3600 * 25), ..f }), + "/media/c.mov" => None, + _ => probe(path), + }; + let mut p = Project::default(); + let opts = ImportOptions { name: "cut.edl".into(), edl_frame_rate: Some(25.0), ..Default::default() }; + let r = import(&mut p, edl.as_bytes(), None, &opts, &mut probe_some).unwrap(); + let notes: Vec<&String> = r.warnings.iter().filter(|w| w.contains("media time")).collect(); + assert_eq!(notes, ["EDL source timecodes stay media time for b.mov, c.mov: the file is missing or has no start timecode"], "{:?}", r.warnings); } #[test] From ace329d1266aba98735fa4d461273d45fc593d6f Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 12:32:25 -0300 Subject: [PATCH 11/71] Roto Brush: a Freeze job reads the frozen cache once (#2833) frame_matte looked up the frozen mattes for every frame, and that hashes the whole serialized cache and recomputes its keys, so freezing a long span again grew with frames times cache size. The Freeze job now resolves the applicable cache once (applying_frozen) and passes it to frame_matte. --- crates/effects/src/roto.rs | 30 ++++++++++++++++++++---------- crates/engine/src/analysis/roto.rs | 4 +++- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/crates/effects/src/roto.rs b/crates/effects/src/roto.rs index 60ee2b7f1..7dd90fe5b 100644 --- a/crates/effects/src/roto.rs +++ b/crates/effects/src/roto.rs @@ -1256,19 +1256,21 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf { /// The final matte of the effect at layer frame `f` on the full-resolution layer grid of /// `input` (the effect's input buffer at scale 1), computing what is missing. For Freeze and -/// the Layer panel overlays. A frame that is frozen already keeps its frozen matte, so freezing -/// again in a session without the model that froze it does not replace that model's matte. +/// the Layer panel overlays. A frame of `frozen` (the instance's frozen mattes, from +/// [`applying_frozen`]) keeps its frozen matte, so freezing again in a session without the model +/// that froze it does not replace that model's matte. pub fn frame_matte( params: &Params, layer_size: [f64; 2], fps: f64, f: i64, + frozen: Option<&FrozenCache>, frame: &mut dyn FnMut(i64) -> Option>, cancel: &dyn Fn() -> bool, ) -> Option<(Vec, Vec)> { let chain = Chain::new(params, layer_size, fps, 1.0); let img = frame(f)?; - if let Some(kept) = frozen_frame_matte(params, layer_size, fps, f, img.width as usize, img.height as usize) { + if let Some(kept) = frozen.and_then(|fc| frozen_frame_matte(fc, f, img.width as usize, img.height as usize)) { return Some(kept); } let seg = chain.ensure(f, frame, cancel)?; @@ -1286,12 +1288,17 @@ pub fn frame_matte( Some((alpha, seg.refine.clone())) } -/// Frame `f`'s frozen alpha and band on a `w`×`h` grid, while the frozen mattes apply ([`frozen_applies`]) and were -/// frozen at that size. -fn frozen_frame_matte(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, w: usize, h: usize) -> Option<(Vec, Vec)> { +/// The instance's frozen mattes while they apply ([`frozen_applies`]): the frames a second Freeze keeps. A Freeze job +/// resolves them once, since this hashes the whole frozen cache. +pub fn applying_frozen(params: &Params, layer_size: [f64; 2], fps: f64) -> Option> { let fc = frozen(params)?; + frozen_applies(&fc, params, layer_size, fps).then_some(fc) +} + +/// Frame `f`'s alpha and band in `fc` on a `w`×`h` grid, when it was frozen at that size. +fn frozen_frame_matte(fc: &FrozenCache, f: i64, w: usize, h: usize) -> Option<(Vec, Vec)> { let fr = fc.frames.get(&f)?; - if fc.size != [w, h] || !frozen_applies(&fc, params, layer_size, fps) { + if fc.size != [w, h] { return None; } let n = w.checked_mul(h).filter(|n| *n > 0 && *n <= rle::MAX_PLANE_BYTES)?; @@ -1420,15 +1427,18 @@ mod tests { assert!(a(10, 70) > 0.99 && a(90, 10) < 0.01, "the frozen matte is used: {} at (10, 70), {} at (90, 10)", a(10, 70), a(90, 10)); // freezing again in this session keeps the frozen matte instead of segmenting with the classic engine let mut get = |g: i64| Some(Arc::new(frame(g as f64 / 25.0))); - let (kept, band) = frame_matte(¶ms, size, 25.0, 0, &mut get, &|| false).expect("frame 0's matte"); - assert_eq!((kept, band.len()), (alpha.clone(), 100 * 80), "a second Freeze keeps the frozen frame"); - assert!(frame_matte(¶ms, size, 25.0, 1, &mut get, &|| false).is_some_and(|(a, _)| a[40 * 100 + 10] < 0.01), "a frame with none is segmented"); + let kept = applying_frozen(¶ms, size, 25.0).expect("the frozen mattes apply"); + let (matte, band) = frame_matte(¶ms, size, 25.0, 0, Some(&kept), &mut get, &|| false).expect("frame 0's matte"); + assert_eq!((matte, band.len()), (alpha.clone(), 100 * 80), "a second Freeze keeps the frozen frame"); + let segmented = frame_matte(¶ms, size, 25.0, 1, Some(&kept), &mut get, &|| false); + assert!(segmented.is_some_and(|(a, _)| a[40 * 100 + 10] < 0.01), "a frame with none is segmented"); // a stroke edited after the freeze still makes it stale, whatever the model let mut moved = d.clone(); moved.strokes[0].points[0] = [36.0, 40.0]; let mut edited = params.clone(); edited.values.insert(STROKES.into(), Value::Str(moved.to_json())); assert!(!frozen_applies(&fc, &edited, size, 25.0)); + assert!(applying_frozen(&edited, size, 25.0).is_none(), "a second Freeze then segments every frame"); } /// A stand-in tracker for two objects: object 0 is the moving disk, object 1 a fixed square. It records how many diff --git a/crates/engine/src/analysis/roto.rs b/crates/engine/src/analysis/roto.rs index 67b0afc68..7560444c2 100644 --- a/crates/engine/src/analysis/roto.rs +++ b/crates/engine/src/analysis/roto.rs @@ -489,6 +489,8 @@ async fn run_work(w: Work, shared: &RotoShared) { // With the classic engine, Propagate leaves no saved tracking behind. *lock(&shared.saved) = Some(String::new()); } + // Freezing again keeps the frames frozen already (resolved once: it reads the whole frozen cache). + let kept = if w.task == RotoTask::Freeze && !w.object_matte { fx::applying_frozen(&w.params, w.size, w.fps) } else { None }; for (k, f) in w.frames.iter().enumerate() { if cancel() { let mut s = lock(&shared.state); @@ -524,7 +526,7 @@ async fn run_work(w: Work, shared: &RotoShared) { } } (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) { + (RotoTask::Freeze, false) => match fx::frame_matte(&w.params, w.size, w.fps, *f, kept.as_deref(), &mut get, &cancel) { Some((a, b)) => { if let Some(img) = src.get(*f) { frozen.size = [img.width as usize, img.height as usize]; From 1a9eeb9e1b50e748698cdd5951382f989dc2e408 Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 12:33:02 -0300 Subject: [PATCH 12/71] Lottie: warn when embedded glyphs (`chars`) are not imported A Lottie file can carry glyph outlines in `chars` for players that lack the fonts. The import drops them and sets the text in installed fonts, which can draw it in a fallback font. It now says so in the warnings, naming the font families the glyphs were for. Fixes #2844. --- crates/lottie/src/import.rs | 11 +++++++++++ crates/lottie/src/tests.rs | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/crates/lottie/src/import.rs b/crates/lottie/src/import.rs index 6d7cc1f5a..cc1bacea4 100644 --- a/crates/lottie/src/import.rs +++ b/crates/lottie/src/import.rs @@ -135,6 +135,17 @@ pub fn import( cx.fonts.push((n, fam, st)); } } + // Glyph outlines embedded for players without the fonts. Text is set in installed fonts. + if let Some(chars) = doc.get("chars").and_then(Json::as_array).filter(|c| !c.is_empty()) { + let mut fams: Vec<&str> = vec![]; + for fam in chars.iter().filter_map(|c| s(c, "fFamily")) { + if !fams.contains(&fam) { + fams.push(fam); + } + } + let of = if fams.is_empty() { String::new() } else { format!(" of {}", fams.join(", ")) }; + cx.im.warn(format!("embedded glyphs (`chars`){of} are not imported; text uses the installed fonts")); + } let (w, h) = (w.round().max(1.0) as u32, h.round().max(1.0) as u32); let duration = tb.tick(op); // Pass 1: asset items (precomps empty for now, images stored). diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index 83c81e242..d8fcc5661 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -520,6 +520,17 @@ fn zero_time_stretch_is_not_imported() { assert!(imp.warnings.iter().any(|w| w.contains("time stretch")), "{:?}", imp.warnings); } +#[test] +fn embedded_glyphs_are_reported_when_dropped() { + let glyph = |ch: &str, fam: &str| json!({"ch": ch, "size": 100, "style": "Regular", "w": 60, "fFamily": fam, "data": {"shapes": []}}); + let doc = json!({"w": 20, "h": 20, "fr": 30, "ip": 0, "op": 30, "assets": [], "layers": [], + "fonts": {"list": [{"fName": "Sans-Regular", "fFamily": "Sans", "fStyle": "Regular"}]}, + "chars": [glyph("A", "Sans"), glyph("B", "Sans"), glyph("C", "Serif")]}); + let mut q = Project::default(); + let imp = import(&mut q, doc.to_string().as_bytes(), "Imported", "", &mut |_| None).unwrap(); + assert_eq!(imp.warnings, ["embedded glyphs (`chars`) of Sans, Serif are not imported; text uses the installed fonts"]); +} + #[test] fn precomp_with_time_remap() { let (mut p, cid) = setup(); From 74d71d0f01c14182745ccf7ecffba4c841532af0 Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 12:56:27 -0300 Subject: [PATCH 13/71] Import: BT.2020 PQ and HLG video gets the Rec. 2100 profile (#2849) profile_of matched BT.2020 primaries before looking at the transfer, so HDR10 and HLG footage got the Rec. 2020 profile and a managed project decoded it with the 2.4 gamma. A PQ render of a solid that is 191,64,128 re-imported as 130,73,101, and HLG as 156,36,97. Map BT.2020 + PQ to Rec. 2100 PQ and BT.2020 + HLG to Rec. 2100 HLG, the profiles Interpret Footage already offers. A PQ and an HLG ProRes render now read back into an sRGB project as the solid they came from. --- crates/export/tests/it/prores_color.rs | 56 ++++++++++++++++++++++---- crates/media/src/probe.rs | 24 ++++++++++- 2 files changed, 71 insertions(+), 9 deletions(-) diff --git a/crates/export/tests/it/prores_color.rs b/crates/export/tests/it/prores_color.rs index 89ecea486..a54a17293 100644 --- a/crates/export/tests/it/prores_color.rs +++ b/crates/export/tests/it/prores_color.rs @@ -1,14 +1,16 @@ //! ProRes `colr` atom (#2801): it describes the frames the render path made, read back with -//! FilmCraft's demuxer and, when installed, ffprobe as an external oracle. +//! FilmCraft's demuxer and, when installed, ffprobe as an external oracle. A PQ or HLG render +//! read back into a managed project has the colours it was rendered from (#2849). use std::path::Path; use std::process::Command; use effectcraft_color::Label; -use effectcraft_export::render_queue::{Channels, OutputFormat, OutputModule, RenderSettings, TimeSpan}; +use effectcraft_export::render_queue::{Channels, OutputFormat, OutputModule, RenderSettings, TimeSpan, sequence_path}; use effectcraft_export::{Job, export}; +use effectcraft_media::MediaPool; use effectcraft_project::{ColorSpace, Comp, ItemKind, LayerSource, Project, Solid, build}; -use effectcraft_render::NoFootage; +use effectcraft_render::{FootageSource, NoFootage}; use effectcraft_time::{FrameRate, Tick}; use filmcraft_isobmff::{ColorInfo, TrackKind}; @@ -55,25 +57,32 @@ const ROWS: &[Row] = &[ Row { name: "alpha_matte", working: SRGB, output: PQ, channels: Channels::Alpha, codes: [1, 1, 1], names: BT709 }, ]; +/// The solid every row renders; with the default output it comes out as these values. +const SOLID: [f32; 3] = [0.75, 0.25, 0.5]; + fn render(row: &Row, path: &Path) { let mut p = Project::default(); p.settings.working_space = row.working; p.settings.output_space = row.output; let mut comp = Comp::new(64, 48, FrameRate::new(10, 1), Tick::from_seconds_f64(0.2)); - let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: [0.75, 0.25, 0.5], width: 64, height: 48, pixel_aspect: 1.0 })); + let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: SOLID, width: 64, height: 48, pixel_aspect: 1.0 })); comp.layers.push(build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (64, 48), None)); let comp = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); - let settings = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() }; let output = OutputModule { channels: row.channels, ..OutputModule::for_format(OutputFormat::ProRes) }; + run(&p, comp, &NoFootage, &output, path); +} + +fn run(p: &Project, comp: effectcraft_project::ItemId, footage: &dyn FootageSource, output: &OutputModule, path: &Path) { + let settings = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() }; let out = path.to_string_lossy().to_string(); let job = Job { - project: &p, - footage: &NoFootage, + project: p, + footage, expr: None, accel: None, comp, settings: &settings, - output: &output, + output, path: &out, sink: None, options: Default::default(), @@ -116,3 +125,34 @@ fn prores_colr_describes_the_frames() { } } } + +/// #2849: a PQ or HLG render, imported into a project with an sRGB working space, gets the +/// Rec. 2100 profile its `colr` atom names and shows the solid it was rendered from. +#[test] +fn hdr_renders_reimport_with_their_colours() { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export/prores_hdr_reimport"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("mkdir"); + let want = SOLID.map(|v| (v * 255.0 + 0.5) as i32); + for (name, space) in [("pq", ColorSpace::Rec2100Pq), ("hlg", ColorSpace::Rec2100Hlg)] { + let row = ROWS.iter().find(|r| r.name == name).expect("row"); + let movie = dir.join(format!("{name}.mov")); + render(row, &movie); + + let footage = effectcraft_media::probe(&movie).expect("probe"); + let profile = footage.color_profile; + let mut p = Project::default(); + p.settings.working_space = SRGB; + let mut comp = Comp::new(64, 48, FrameRate::new(10, 1), Tick::from_seconds_f64(0.1)); + let fid = p.add_item(name, Label::SeaFoam, None, ItemKind::Footage(footage)); + comp.layers.push(build::layer(&mut p, &comp, name, LayerSource::Footage { item: fid }, (64, 48), None)); + let comp = p.add_item("Reimport", Label::Sandstone, None, ItemKind::Comp(comp.into())); + let pattern = dir.join(format!("{name}_[#####].png")).to_string_lossy().to_string(); + run(&p, comp, &MediaPool::new(), &OutputModule::for_format(OutputFormat::PngSequence), Path::new(&pattern)); + + let png = image::open(sequence_path(&pattern, 0)).expect("png").to_rgb8(); + let got = png.get_pixel(32, 24).0.map(i32::from); + assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 2), "{name}: centre {got:?}, want {want:?} (profile {profile:?})"); + assert_eq!(profile, Some(space), "{name}: profile"); + } +} diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 6bae86f5b..7fcd297fb 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -203,11 +203,14 @@ pub(crate) fn footage_from_info(path: &str, info: &filmcraft_media::MediaInfo) - } /// The colour profile a video stream declares (H.273 primaries / transfer from the bitstream or -/// container). `None` (interpreted as sRGB) for Rec. 709 primaries with an sRGB curve. +/// container). `None` (interpreted as sRGB) for Rec. 709 primaries with an sRGB curve. BT.2020 +/// with a PQ or HLG curve is Rec. 2100 (HDR10, HLG), not Rec. 2020's 2.4 gamma (#2849). fn profile_of(c: &filmcraft_color::ColorInfo) -> Option { use effectcraft_project::ColorSpace; use filmcraft_color::{Primaries, Transfer}; match (c.primaries, c.transfer) { + (Primaries::Bt2020, Transfer::Pq) => Some(ColorSpace::Rec2100Pq), + (Primaries::Bt2020, Transfer::Hlg) => Some(ColorSpace::Rec2100Hlg), (Primaries::Bt2020, _) => Some(ColorSpace::Rec2020), (Primaries::P3D65, _) => Some(ColorSpace::DisplayP3), (_, Transfer::Bt709) => Some(ColorSpace::Rec709), @@ -383,6 +386,25 @@ mod tests { (0..n).map(|k| (at(k + 1) - at(k)) as u32).collect() } + /// #2849: a stream's primaries and curve pick its profile; BT.2020 with PQ or HLG is Rec. 2100. + #[test] + fn stream_colour_picks_the_profile() { + use effectcraft_project::ColorSpace; + use filmcraft_color::{Primaries, Transfer}; + let rows = [ + (Primaries::Bt2020, Transfer::Pq, Some(ColorSpace::Rec2100Pq)), + (Primaries::Bt2020, Transfer::Hlg, Some(ColorSpace::Rec2100Hlg)), + (Primaries::Bt2020, Transfer::Bt709, Some(ColorSpace::Rec2020)), + (Primaries::P3D65, Transfer::Srgb, Some(ColorSpace::DisplayP3)), + (Primaries::Bt709, Transfer::Bt709, Some(ColorSpace::Rec709)), + (Primaries::Bt709, Transfer::Srgb, None), + ]; + for (primaries, transfer, want) in rows { + let c = filmcraft_color::ColorInfo { primaries, transfer, ..filmcraft_color::ColorInfo::REC709 }; + assert_eq!(profile_of(&c), want, "{primaries:?} + {transfer:?}"); + } + } + /// A model's side files are read from beside it; a URI to another computer is not followed. #[test] fn model_side_files_are_not_read_from_other_computers() { From 33156f41191ba3e473770285fceca331a55b26e8 Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 13:00:47 -0300 Subject: [PATCH 14/71] G2: --save runs on one project take turns, so no edit is lost (#2851) A run reads the project, edits it and writes the whole file back, so two at once silently dropped the edits of whichever saved first. A run that saves over the file it opened (--save, or --save-as the same file) now holds an OS lock on `..lock` next to the project from before its read until it exits. The next run says so on stderr, waits, then edits what the first one saved. Ported from filmcraft-cli (storytold/filmcraft#3926). The OS drops the lock however the process ends, so a crash leaves nothing stale. If the lock can't be taken the run warns and saves as it did before. A missing project stops the run before it opens anything, with the same error opening gives, so a project created in between is never saved without the lock. The desktop app and `mcp` don't take this lock. --- apps/effectcraft-cli/src/main.rs | 5 + apps/effectcraft-cli/src/project_lock.rs | 150 +++++++++++++++++++++ apps/effectcraft-cli/tests/it/cli.rs | 161 +++++++++++++++++++++++ docs/agents.md | 8 ++ 4 files changed, 324 insertions(+) create mode 100644 apps/effectcraft-cli/src/project_lock.rs diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index 6ba352247..551ff8362 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -47,6 +47,7 @@ use std::io::Write; +mod project_lock; mod template; use effectcraft_automation::tools::{self, Reply}; @@ -508,6 +509,7 @@ fn script_cmd(args: &Args, json_out: bool) -> Result<(), Failure> { (None, Some(f)) => (std::fs::read_to_string(f).map_err(|e| Failure::Error(format!("cannot read {f}: {e}")))?, f.clone()), (None, None) => return usage_err("script needs a .jsx/.js file or --eval CODE"), }; + let _lock = project_lock::for_save(args)?; let mut b = backend(args, false)?; let r = b.exec("script.run", json!({"code": code, "name": name}))?; let saved = maybe_save(&mut b, args)?; @@ -611,6 +613,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { (Some(p), _) | (None, Some(p)) => serde_json::from_str(p).map_err(|e| Failure::Usage(format!("params are not valid JSON: {e}")))?, (None, None) => json!({}), }; + let _lock = project_lock::for_save(args)?; let mut b = backend(args, true)?; let r = b.exec(&id, params)?; if let Some(e) = failed_render(&id, &r) { @@ -654,6 +657,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { if steps.is_empty() { return Err(Failure::Usage(format!("--commands {}: no commands", sources.join(", ")))); } + let _lock = project_lock::for_save(args)?; let mut b = backend(args, true)?; let mut results = vec![]; // The step's index closes the message: a generated list repeats ids, and callers match @@ -708,6 +712,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { } let (comp, layer, path) = (args.pos[0].clone(), args.pos[1].clone(), args.pos[2].clone()); let value = args.pos.get(3).map(|v| value_arg(v)); + let _lock = project_lock::for_save(args)?; let mut b = backend(args, true)?; let a = json!({"comp": reference(&comp), "layer": reference(&layer), "path": path, "value": value, "time": args.num("--time")?, "expression": expr}); diff --git a/apps/effectcraft-cli/src/project_lock.rs b/apps/effectcraft-cli/src/project_lock.rs new file mode 100644 index 000000000..234bbfd33 --- /dev/null +++ b/apps/effectcraft-cli/src/project_lock.rs @@ -0,0 +1,150 @@ +//! One saving run at a time per project file. +//! +//! A `--save` run reads the project, edits it and writes the whole file back, so two at once would +//! silently drop the edits of whichever saved first (#2851). Each run holds an OS lock from before +//! its read until it exits; the next one waits, then reads what the first one saved. This is how +//! filmcraft-cli does it (storytold/filmcraft#3926). +//! +//! The lock is on a file next to the project, not on the project: a save replaces the project's +//! inode, and on Windows a locked file can't be read by other processes. The OS drops the lock when +//! the process ends, however it ends, so a crash leaves nothing stale. The file itself stays, since +//! removing it would let one run wait on the old file while another locks a new one. + +use crate::{Args, Failure}; +use std::fs::{File, TryLockError}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +/// Most bytes of the project's file name kept in the lock's name; the rest of the 255-byte limit +/// is left for the leading dot and `.lock`. +const MAX_NAME_BYTES: usize = 200; + +fn lock_path(project: &Path) -> PathBuf { + let name = project.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + project.with_file_name(format!(".{}.lock", &name[..name.floor_char_boundary(MAX_NAME_BYTES)])) +} + +/// Do two paths name one existing file? +fn same_file(a: &str, b: &str) -> bool { + a == b || matches!((std::fs::canonicalize(a), std::fs::canonicalize(b)), (Ok(a), Ok(b)) if a == b) +} + +/// The path this run saves over, when that is the file it opens. `--save-as` somewhere else replaces +/// that file whatever it held, so there is no edit of its to lose. +/// +/// The lock goes by this path as written, not by where a symlink points: runs that name the +/// project the same way share one lock. +/// +/// A missing project is an error here, before anything else, the same one opening it would give. +/// If the run went on, another one could create the project before it is read, and this run would +/// edit and save it with no lock. Checking first also means the paths compared below exist, so +/// `canonicalize` tells exactly whether they are one file, however they are spelt. A project that +/// exists but isn't a file gets no lock file beside it, and opening it reports why. +fn saved_in_place(a: &Args) -> Result, String> { + // `--bridge` with a project is a usage error, which `backend` reports + let Some(project) = a.project.as_deref().filter(|_| a.opt("--bridge").is_none()) else { return Ok(None) }; + match Path::new(project).metadata() { + Ok(meta) if meta.is_file() => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(format!("cannot read {project}: {e}")), + _ => return Ok(None), + } + match a.opt("--save-as") { + Some(target) if same_file(target, project) => Ok(Some(Path::new(target))), + None if a.flag("--save") => Ok(Some(Path::new(project))), + _ => Ok(None), + } +} + +/// Lock `project`, calling `waiting` first if another run holds the lock. +fn lock(project: &Path, waiting: impl FnOnce()) -> std::io::Result { + let file = File::options().create(true).truncate(false).read(true).write(true).open(lock_path(project))?; + match file.try_lock() { + Ok(()) => {} + Err(TryLockError::WouldBlock) => { + waiting(); + file.lock()?; + } + Err(TryLockError::Error(e)) => return Err(e), + } + Ok(file) +} + +/// Take the project's lock if this run saves over the file it opens, or fail if the project is +/// missing. Call it before opening the project and keep the result until the save is done. Blocks +/// while another run holds the lock, which is fine here: nothing else is running yet. A lock that +/// can't be had (a read-only folder, a file system without locks) is a warning and not an error, so +/// that saving works where it did. +pub(crate) fn for_save(a: &Args) -> Result, Failure> { + let Some(project) = saved_in_place(a).map_err(Failure::Error)? else { return Ok(None) }; + let shown = project.display(); + let waiting = || { + let _ = writeln!(std::io::stderr(), "effectcraft-cli: waiting for another run saving {shown}"); + }; + match lock(project, waiting) { + Ok(file) => Ok(Some(file)), + Err(e) => { + let _ = writeln!(std::io::stderr(), "effectcraft-cli: warning: can't lock {shown} ({e}); another run saving it now could overwrite these edits"); + Ok(None) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(list: &[&str]) -> Args { + Args::parse(list.iter().map(|s| s.to_string()).collect()).unwrap() + } + + #[test] + fn the_lock_sits_next_to_the_project() { + assert_eq!(lock_path(Path::new("/a/b/p.ecproj")), Path::new("/a/b/.p.ecproj.lock")); + assert_eq!(lock_path(Path::new("p.ecproj")), Path::new(".p.ecproj.lock")); + } + + #[test] + fn a_long_project_name_still_gets_a_lock_name_the_file_system_takes() { + for name in ["a".repeat(255), "é".repeat(127)] { + let lock = lock_path(Path::new(&name)); + let lock = lock.to_str().unwrap(); + assert!(lock.len() <= 255, "{} bytes", lock.len()); + assert!(lock.ends_with(".lock")); + } + } + + /// The moment between this check and opening the project can't be hit on demand, so this pins + /// the decision that closes it: a run never goes on to open a project that wasn't there, however + /// its paths are spelt and whatever it saves. + #[test] + fn a_missing_project_stops_the_run_before_anything_opens_it() { + let name = format!("ec-cli-gone-{}.ecproj", std::process::id()); + let gone = std::env::temp_dir().join(&name); + let gone = gone.to_str().unwrap(); + let alias = std::env::temp_dir().join(".").join(&name); + let alias = alias.to_str().unwrap(); + let dotted = format!("./{name}"); + // in a folder that doesn't exist either, so no part of either spelling can be resolved + let new_dir = std::env::temp_dir().join(format!("ec-cli-new-dir-{}", std::process::id())); + let in_new_dir = new_dir.join("p.ecproj"); + let in_new_dir = in_new_dir.to_str().unwrap(); + let in_new_dir_alias = new_dir.join(".").join("p.ecproj"); + let in_new_dir_alias = in_new_dir_alias.to_str().unwrap(); + for (project, list) in [ + (gone, &["exec", "--project", gone, "--save"][..]), + (gone, &["exec", gone, "--save"]), + (gone, &["exec", "--project", gone, "--save-as", gone]), + (gone, &["exec", "--project", gone, "--save-as", alias]), + (name.as_str(), &["exec", "--project", &name, "--save-as", &dotted]), + (in_new_dir, &["exec", "--project", in_new_dir, "--save-as", in_new_dir_alias]), + (gone, &["exec", "--project", gone, "--save-as", "other.ecproj"]), + (gone, &["exec", "--project", gone]), + ] { + let error = saved_in_place(&args(list)).unwrap_err(); + assert_eq!(error.split_once(": ").map(|(head, _)| head), Some(format!("cannot read {project}").as_str()), "{list:?}: {error}"); + } + // a project with --bridge is a usage error, which `backend` reports; no project, no lock + assert_eq!(saved_in_place(&args(&["exec", "--bridge", "9877", "--project", gone, "--save"])), Ok(None)); + assert_eq!(saved_in_place(&args(&["exec", "--demo", "--save-as", gone])), Ok(None)); + } +} diff --git a/apps/effectcraft-cli/tests/it/cli.rs b/apps/effectcraft-cli/tests/it/cli.rs index e9a96dd7e..0623f37fe 100644 --- a/apps/effectcraft-cli/tests/it/cli.rs +++ b/apps/effectcraft-cli/tests/it/cli.rs @@ -110,6 +110,167 @@ fn exec_set_get_with_saved_project() { assert_eq!(v[1]["result"]["time"], v[0]["result"]["time"], "{v}"); } +/// A project in a new folder of its own, for the save lock tests (#2851). +fn lock_test_project(tag: &str) -> (PathBuf, String) { + let dir = tmp(&format!("lock-{tag}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let proj = dir.join("p.ecproj").to_str().unwrap().to_string(); + ok_json(&["exec", "project.newFolder", r#"{"name":"start"}"#, "--empty", "--save-as", &proj]); + (dir, proj) +} + +/// The lock of the project file `name`, held by this process. +fn hold_lock(dir: &std::path::Path, name: &str) -> std::fs::File { + let lock = std::fs::File::options().create(true).truncate(false).read(true).write(true).open(dir.join(format!(".{name}.lock"))).unwrap(); + lock.lock().unwrap(); + lock +} + +/// Start a run that has to wait for a lock this process holds; returns once it says it is waiting. +fn start_waiting_run(args: &[&str]) -> std::process::Child { + let mut child = bin().args(args).stdout(Stdio::null()).stderr(Stdio::piped()).spawn().unwrap(); + let mut stderr = BufReader::new(child.stderr.take().unwrap()); + let mut line = String::new(); + while !line.contains("waiting for another run saving") { + line.clear(); + assert_ne!(stderr.read_line(&mut line).unwrap(), 0, "{args:?} ended without waiting"); + } + assert!(child.try_wait().unwrap().is_none(), "{args:?} did not wait"); + child +} + +/// Folder names in a saved project. +fn has_folder(proj: &str, name: &str) -> bool { + std::fs::read_to_string(proj).unwrap().contains(&format!("\"{name}\"")) +} + +#[test] +fn parallel_saves_keep_every_edit() { + let (dir, proj) = lock_test_project("parallel"); + let names = |writer: usize| (0..300).map(move |i| format!("edit-{writer}-{i}")); + let mut writers: Vec<_> = (0..4) + .map(|_| { + bin() + .args(["run", "--project", &proj, "--commands", "-", "--save"]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap() + }) + .collect(); + // Each writer reads its whole script before it opens the project, so closing the stdins together + // starts all the edits at once, and long scripts keep each read-to-save span over the others. + for (n, writer) in writers.iter_mut().enumerate() { + let script: String = names(n).map(|name| format!("{}\n", json!({"command": "project.newFolder", "params": {"name": name}}))).collect(); + writer.stdin.take().unwrap().write_all(script.as_bytes()).unwrap(); + } + for mut writer in writers { + assert!(writer.wait().unwrap().success()); + } + let lost: Vec = (0..4).flat_map(names).filter(|name| !has_folder(&proj, name)).collect(); + assert!(lost.is_empty(), "lost {} edits: {lost:?}", lost.len()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_save_in_place_waits_for_the_lock_then_edits_the_latest_project() { + let (dir, proj) = lock_test_project("wait"); + for save in [&["--save"][..], &["--save-as", proj.as_str()]] { + let held = hold_lock(&dir, "p.ecproj"); + let before = std::fs::read(&proj).unwrap(); + let params = json!({"name": format!("waited-{}", save.len())}).to_string(); + let args = [&["exec", "project.newFolder", ¶ms, "--project", &proj][..], save].concat(); + let mut child = start_waiting_run(&args); + assert_eq!(std::fs::read(&proj).unwrap(), before, "{save:?} wrote while another run held the lock"); + // an edit made meanwhile by the holder must survive: the waiter reads only after the lock is free + let held_params = json!({"name": format!("held-{}", save.len())}).to_string(); + ok_json(&["exec", "project.newFolder", &held_params, "--project", &proj, "--save-as", &format!("{proj}.tmp")]); + std::fs::rename(format!("{proj}.tmp"), &proj).unwrap(); + drop(held); + assert!(child.wait().unwrap().success(), "{save:?}"); + for want in [format!("waited-{}", save.len()), format!("held-{}", save.len())] { + assert!(has_folder(&proj, &want), "{save:?} lost {want}"); + } + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// Runs through a symlink take the same lock before and after a save, or a run still waiting on +/// the old one could overwrite a later run's save. +#[cfg(unix)] +#[test] +fn a_symlinked_project_keeps_its_lock_after_a_save() { + let (dir, proj) = lock_test_project("symlink"); + let link = dir.join("link.ecproj"); + std::os::unix::fs::symlink(&proj, &link).unwrap(); + for name in [r#"{"name":"first"}"#, r#"{"name":"second"}"#] { + let held = hold_lock(&dir, "link.ecproj"); + let mut child = start_waiting_run(&["exec", "project.newFolder", name, "--project", link.to_str().unwrap(), "--save"]); + drop(held); + assert!(child.wait().unwrap().success(), "{name}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn runs_that_do_not_save_in_place_ignore_the_lock() { + let (dir, proj) = lock_test_project("ignore"); + let other = dir.join("other.ecproj").to_str().unwrap().to_string(); + let _held = hold_lock(&dir, "p.ecproj"); + let params = r#"{"name":"copy-only"}"#; + let cases: [&[&str]; 3] = [ + &["info", "--project", &proj], + &["exec", "project.newFolder", params, "--project", &proj], + &["exec", "project.newFolder", params, "--project", &proj, "--save-as", &other], + ]; + for args in cases { + let mut child = bin().args(args).stdout(Stdio::null()).stderr(Stdio::null()).spawn().unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + if std::time::Instant::now() > deadline { + let _ = child.kill(); + panic!("{args:?} is waiting for a lock it doesn't need"); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + }; + assert!(status.success(), "{args:?}"); + } + assert!(!has_folder(&proj, "copy-only")); + assert!(has_folder(&other, "copy-only")); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_missing_project_gets_no_lock_file_and_keeps_its_error() { + let (dir, _) = lock_test_project("missing"); + let gone = dir.join("gone.ecproj").to_str().unwrap().to_string(); + let other = dir.join("other.ecproj").to_str().unwrap().to_string(); + // what opening it says, after the `effectcraft-cli : ` prefix + let error = |args: &[&str]| { + let out = bin().args(args).output().unwrap(); + assert_eq!(out.status.code(), Some(1), "{args:?}"); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + stderr.split_once(": ").map(|(_, e)| e.to_string()).unwrap_or(stderr) + }; + let plain = error(&["info", "--project", &gone]); + assert!(plain.contains("gone.ecproj"), "{plain}"); + // saving in place, saving elsewhere, or not saving: the error is the one opening gives + for save in [&["--save"][..], &["--save-as", gone.as_str()], &["--save-as", other.as_str()], &[]] { + let args = [&["exec", "project.newFolder", r#"{"name":"copy-only"}"#, "--project", &gone][..], save].concat(); + assert_eq!(error(&args), plain, "{save:?}"); + } + assert!(!dir.join(".gone.ecproj.lock").exists()); + assert!(!dir.join("other.ecproj").exists()); + // a project with --bridge stays a usage error + assert_eq!(bin().args(["exec", "project.newFolder", "--bridge", "9877", "--project", &gone, "--save"]).output().unwrap().status.code(), Some(2)); + let _ = std::fs::remove_dir_all(&dir); +} + /// `run --args-file` reads its command / params pairs from a file, one per line: a batch longer /// than a Windows command line (32 767 characters, os error 206) still runs. #[test] diff --git a/docs/agents.md b/docs/agents.md index 34162aacd..38ed16dca 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -751,6 +751,14 @@ effectcraft-cli exec file.importLottie '{"path":"anim.json"}' main.ecproj --save effectcraft-cli render main.ecproj --comp Main --out main.mov --format prores --progress # progress lines on stderr for a program that reads them ``` +Runs that save over the project they opened (`--save`, or `--save-as` the same file) take turns, so +parallel scripts don't lose edits. Each holds an OS lock on `..lock` next to the project from +before it reads the file until it exits; the next one says so on stderr, waits, and then edits what +the first one saved. A crash releases the lock, and the empty file left behind is harmless. Only these +CLI runs take the lock: the desktop app, `mcp`, and a `file.save` step in a run without `--save` +don't. OS locks can also be unreliable over network shares and synced folders. If the lock can't be +taken, the run warns on stderr and saves anyway. + A long `run` doesn't fit on a command line (Windows caps it at 32 767 characters, Linux one argument at 128 KiB). `run --args-file F` reads the same ` ` arguments one per line; `run --commands FILE` reads steps as a JSON array of `{"command": ID, "params": {…}}` objects, or one From 7f6afdb51a41ef8e3bf9938354a911e8a172a21c Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 13:42:27 -0300 Subject: [PATCH 15/71] Lottie: leave blank font families out of the embedded glyphs warning A `chars` entry with an empty or whitespace-only `fFamily` listed an empty name, so the warning read "of Sans, , Serif". --- crates/lottie/src/import.rs | 2 +- crates/lottie/src/tests.rs | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/lottie/src/import.rs b/crates/lottie/src/import.rs index cc1bacea4..90519a8db 100644 --- a/crates/lottie/src/import.rs +++ b/crates/lottie/src/import.rs @@ -138,7 +138,7 @@ pub fn import( // Glyph outlines embedded for players without the fonts. Text is set in installed fonts. if let Some(chars) = doc.get("chars").and_then(Json::as_array).filter(|c| !c.is_empty()) { let mut fams: Vec<&str> = vec![]; - for fam in chars.iter().filter_map(|c| s(c, "fFamily")) { + for fam in chars.iter().filter_map(|c| s(c, "fFamily")).filter(|f| !f.trim().is_empty()) { if !fams.contains(&fam) { fams.push(fam); } diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index d8fcc5661..cbe89d58c 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -525,10 +525,12 @@ fn embedded_glyphs_are_reported_when_dropped() { let glyph = |ch: &str, fam: &str| json!({"ch": ch, "size": 100, "style": "Regular", "w": 60, "fFamily": fam, "data": {"shapes": []}}); let doc = json!({"w": 20, "h": 20, "fr": 30, "ip": 0, "op": 30, "assets": [], "layers": [], "fonts": {"list": [{"fName": "Sans-Regular", "fFamily": "Sans", "fStyle": "Regular"}]}, - "chars": [glyph("A", "Sans"), glyph("B", "Sans"), glyph("C", "Serif")]}); - let mut q = Project::default(); - let imp = import(&mut q, doc.to_string().as_bytes(), "Imported", "", &mut |_| None).unwrap(); - assert_eq!(imp.warnings, ["embedded glyphs (`chars`) of Sans, Serif are not imported; text uses the installed fonts"]); + "chars": [glyph("A", "Sans"), glyph("B", ""), glyph("C", "Serif")]}); + let warnings = |doc: &Json| import(&mut Project::default(), doc.to_string().as_bytes(), "Imported", "", &mut |_| None).unwrap().warnings; + // A blank family name is left out, not listed as "of , …". + assert_eq!(warnings(&doc), ["embedded glyphs (`chars`) of Sans, Serif are not imported; text uses the installed fonts"]); + let blank = json!({"w": 20, "h": 20, "fr": 30, "layers": [], "chars": [glyph("A", " ")]}); + assert_eq!(warnings(&blank), ["embedded glyphs (`chars`) are not imported; text uses the installed fonts"]); } #[test] From 2913d27d6fb0e1786f23497272086eaf3044e315 Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 13:42:35 -0300 Subject: [PATCH 16/71] Import: an EDL's hour on timecode is counted in its own timecode (#2832) Which clips use an hour or more of timecode was decided in seconds, so in a 29.97 fps drop-frame list a clip cut at 01:00:00;00 (3599.996 s) was left out, and a missing file it used went unnamed. The hour is now 01:00:00:00 at the sequence's rate and drop-frame setting. --- crates/interchange/src/import.rs | 4 +++- crates/interchange/src/tests.rs | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/interchange/src/import.rs b/crates/interchange/src/import.rs index 8a15e874e..6831103a7 100644 --- a/crates/interchange/src/import.rs +++ b/crates/interchange/src/import.rs @@ -189,10 +189,12 @@ const EDL_MEDIA_TIME_NOTE: &str = "EDL source timecodes were imported as media t /// when it links the media of an imported EDL. Returns the names of the media that stay on /// timecode though their clips use an hour or more of it (the file is missing or has none). fn rebase_to_file_timecode(project: &mut fp::Project, probe: &mut dyn FnMut(&str) -> Option) -> Vec { - let hour = filmcraft_time::Tick(3600 * filmcraft_time::TICKS_PER_SECOND); let mut on_timecode: HashSet = HashSet::new(); for it in project.items.values() { if let fp::ItemKind::Sequence(seq) = &it.kind { + // 01:00:00:00 as the list counts it: 01:00:00;00 drop-frame at 29.97 fps is 3599.996 s + let (rate, df) = (seq.settings.frame_rate, seq.settings.drop_frame); + let hour = rate.tick_of(filmcraft_time::fields_to_frames(1, 0, 0, 0, rate, df)); on_timecode.extend(seq.all_tracks().flat_map(|t| &t.items).filter(|ti| ti.source_in >= hour).map(|ti| ti.item)); } } diff --git a/crates/interchange/src/tests.rs b/crates/interchange/src/tests.rs index 6e738e86c..7fec3840b 100644 --- a/crates/interchange/src/tests.rs +++ b/crates/interchange/src/tests.rs @@ -408,6 +408,18 @@ fn edl_media_left_on_timecode_is_named() { assert_eq!(notes, ["EDL source timecodes stay media time for b.mov, c.mov: the file is missing or has no start timecode"], "{:?}", r.warnings); } +/// A drop-frame list counts its hour in timecode: a clip cut at 01:00:00;00 (3599.996 s at 29.97 +/// fps) from a missing file is still named. +#[test] +fn edl_drop_frame_media_left_on_timecode_is_named() { + let edl = "TITLE: CUT\nFCM: DROP FRAME\n\n001 C V C 01:00:00;00 01:00:01;00 01:00:00;00 01:00:01;00\n* FROM CLIP NAME: c.mov\n* SOURCE FILE: /media/c.mov\n"; + let mut p = Project::default(); + let opts = ImportOptions { name: "cut.edl".into(), edl_frame_rate: Some(29.97), ..Default::default() }; + let r = import(&mut p, edl.as_bytes(), None, &opts, &mut |_: &str| None).unwrap(); + let notes: Vec<&String> = r.warnings.iter().filter(|w| w.contains("media time")).collect(); + assert_eq!(notes, ["EDL source timecodes stay media time for c.mov: the file is missing or has no start timecode"], "{:?}", r.warnings); +} + #[test] fn rejects_unknown_documents() { let mut p = Project::default(); From 324a6326daaef9d2508cfb2bcd2e5049a42dfacd Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Mon, 2 Nov 2026 22:14:39 +0530 Subject: [PATCH 17/71] Fix #2803: Timewarp plays an animated Speed as a rate Timewarp's Adjust Time By = Speed showed source time t x speed(t) / 100, using only the Speed value at the current frame. With Speed keyframed (a speed ramp), the source jumped to another part of the footage at the change, and while Speed eased down it ran backwards or held still. An animated Speed (keyframes or an expression) is now integrated over layer time from 0 through the effect host's param_at, on a fixed sub-frame grid with a bounded sample count. A static Speed keeps the exact previous formula, so the Timewarp goldens are unchanged. Local: effectcraft-effects time_fx lib tests (8), timewarp golden, clippy -p effectcraft-effects. Full cargo xtask ci deferred to GitHub CI. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/time_fx.rs | 148 +++++++++++++++++++++++++++++++++- 1 file changed, 145 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/time_fx.rs b/crates/effects/src/time_fx.rs index 27d2264e5..6f76abedb 100644 --- a/crates/effects/src/time_fx.rs +++ b/crates/effects/src/time_fx.rs @@ -451,11 +451,76 @@ fn time_displacement(ctx: &EffectCtx, b: Buf) -> Buf { // ---------------------------------------------------------------- Timewarp -/// Source layer time Timewarp shows at layer time `t`. +/// Source layer time Timewarp shows at layer time `t` when Speed is the same at every time: +/// Source Frame maps directly, Speed (a rate in %) gives `t × speed / 100`. An animated Speed +/// is integrated instead (see [`timewarp_integrate_speed`]). pub fn timewarp_source_time(t: f64, by_frame: bool, speed: f64, source_frame: f64, fps: f64) -> f64 { if by_frame { source_frame / fps } else { t * speed / 100.0 } } +/// Speed samples per composition frame when integrating an animated Speed. +const TW_SPEED_SAMPLES_PER_FRAME: f64 = 2.0; +/// Most Speed samples one integration takes (longer spans widen the step instead). +const TW_SPEED_MAX_SAMPLES: f64 = 50_000.0; + +/// Source layer time of Adjust Time By = Speed at layer time `t` with an animated Speed: Speed +/// is a rate (100 % = real time), so the source time is the integral of `speed / 100` over layer +/// time from 0 to `t` (toward 0 with the sign for negative `t`). `speed_at(lt)` is Speed at layer +/// time `lt`; a missing or non-finite sample counts as `now` (the current Speed), or 0 when that +/// is not finite either. Midpoint rule on a fixed grid from 0, [`TW_SPEED_SAMPLES_PER_FRAME`] +/// samples per frame at `fps`, at most [`TW_SPEED_MAX_SAMPLES`] samples. A non-finite result falls back to +/// `t × now / 100`, then to 0. +pub fn timewarp_integrate_speed(t: f64, fps: f64, now: f64, speed_at: impl Fn(f64) -> Option) -> f64 { + let flat = t * now / 100.0; + let fallback = |v: f64| { + if v.is_finite() { + v + } else if flat.is_finite() { + flat + } else { + 0.0 + } + }; + if !t.is_finite() || t == 0.0 { + return fallback(flat); + } + let fps = if fps.is_finite() && fps > 0.0 { fps } else { 30.0 }; + let span = t.abs(); + // Cells of a fixed step anchored at 0 (whole cells plus a partial last one), so successive + // frames share their samples and a Hold key's jump lands the same way on every frame; only a + // span past the sample budget widens the step. + let mut h = 1.0 / (fps * TW_SPEED_SAMPLES_PER_FRAME); + if !(span / h).is_finite() || span / h >= TW_SPEED_MAX_SAMPLES { + h = span / TW_SPEED_MAX_SAMPLES; + } + if !h.is_finite() || h <= 0.0 { + return fallback(flat); + } + let whole = (span / h).floor().clamp(0.0, TW_SPEED_MAX_SAMPLES); + let rest = (span - whole * h).max(0.0); + let sign = t.signum(); + let held = if now.is_finite() { now } else { 0.0 }; + let sample = |lt: f64| speed_at(sign * lt).filter(|v| v.is_finite()).unwrap_or(held); + // `whole` is a whole number in 0..=TW_SPEED_MAX_SAMPLES, so the count is exact. + let full: f64 = (0..whole as u64).map(|i| sample((i as f64 + 0.5) * h)).sum::() * h; + let part = if rest > 0.0 { sample(whole * h + rest * 0.5) * rest } else { 0.0 }; + fallback(sign * (full + part) / 100.0) +} + +/// Source layer time Timewarp shows at the frame: [`timewarp_source_time`], except that an +/// animated Speed (keyframes or an expression, read through the effect host) is integrated +/// from layer time 0 ([`timewarp_integrate_speed`]), so a speed ramp plays on from where the +/// footage is instead of jumping to another segment. +fn timewarp_source(ctx: &EffectCtx, by_frame: bool, speed: f64, fps: f64) -> f64 { + let direct = timewarp_source_time(ctx.time, by_frame, speed, ctx.params.f("sourceFrame"), fps); + if by_frame { + return direct; + } + let idx = ctx.env.effect_index; + let Some(host) = ctx.env.host.filter(|h| !h.param_is_static(idx, "speed")) else { return direct }; + timewarp_integrate_speed(ctx.time, fps, speed, |lt| host.param_at(idx, "speed", lt).map(|v| v.as_f64())) +} + /// Timewarp's Pixel Motion settings (the Tuning group and Source Crops). #[derive(Clone, Copy, Debug)] pub struct PixelMotion { @@ -747,7 +812,7 @@ fn timewarp_instants(ctx: &EffectCtx) -> (Vec<(f64, f32)>, Vec, f64) { let fps = ctx.fps(); let by_frame = ctx.params.e("adjustTimeBy") == 1; let speed = ctx.params.f("speed"); - let src = timewarp_source_time(ctx.time, by_frame, speed, ctx.params.f("sourceFrame"), fps); + let src = timewarp_source(ctx, by_frame, speed, fps); let mut instants: Vec<(f64, f32)> = vec![]; if ctx.params.b("motionBlur/enableMotionBlur") { let angle = if ctx.params.e("motionBlur/shutterControl") == 1 { ctx.params.f("motionBlur/shutterAngle") } else { 180.0 }; @@ -879,7 +944,8 @@ pub fn timewarp_plan(ctx: &EffectCtx) -> Option { /// vectors (block-matching optical flow, [`effectcraft_raster::flow`]) tuned by the Tuning /// group. A Matte Layer splits foreground and background so each moves on its own vectors; a /// Warp Layer supplies the motion instead of the layer itself. Motion Blur averages Shutter -/// Samples instants across the shutter. +/// Samples instants across the shutter. Adjust Time By = Speed treats Speed as a rate +/// integrated from layer time 0 (keyframing it ramps the playback speed). fn timewarp(ctx: &EffectCtx, b: Buf) -> Buf { match timewarp_plan(ctx) { Some(plan) => plan.render_cpu(), @@ -1188,6 +1254,82 @@ mod tests { assert!(out.img.data[0][0] > 1.0); } + /// [`Clock`] whose Timewarp (effect 2) Speed is animated: `speed(lt)` at layer time `lt`. + struct Ramp { + speed: fn(f64) -> f64, + } + impl EffectHost for Ramp { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn self_at(&self, t: f64, _: usize) -> Option { + let v = t as f32; + Some(Buf { img: Image::filled(6, 4, [v, v, v, 1.0]), offset: [0.0; 2], scale: 1.0 }) + } + fn param_at(&self, effect_index: usize, id: &str, lt: f64) -> Option { + (effect_index == 2 && id == "speed").then(|| num((self.speed)(lt))) + } + fn param_is_static(&self, effect_index: usize, id: &str) -> bool { + !(effect_index == 2 && id == "speed") + } + } + + fn run_ramp(vals: &[(&str, Value)], t: f64, host: &Ramp) -> Buf { + let env = EffectEnv { host: Some(host), frame_rate: 10.0, comp_time: t, effect_index: 2, ..Default::default() }; + run_fx("ec.time.timewarp", vals, Image::filled(6, 4, [0.9, 0.0, 0.0, 1.0]), t, env) + } + + #[test] + fn timewarp_integrates_animated_speed() { + // Speed 100 % for the first second, then 50 %: at t = 2 s the source is 1 + 0.5 = 1.5 s + // (`t × speed` would jump back to 1.0 s). + let h = Ramp { speed: |lt| if lt < 1.0 { 100.0 } else { 50.0 } }; + let vals = [("method", Value::Enum(0)), ("speed", num(50.0))]; + let out = run_ramp(&vals, 2.0, &h); + assert!((out.img.data[0][0] - 1.5).abs() < 1e-4, "{}", out.img.data[0][0]); + // Continuous across the change: just past 1 s the source is just past 1 s. + let out = run_ramp(&vals, 1.2, &h); + assert!((out.img.data[0][0] - 1.1).abs() < 1e-4, "{}", out.img.data[0][0]); + // Negative layer time integrates toward 0. + let src = timewarp_integrate_speed(-2.0, 10.0, 50.0, |lt| Some(if lt > -1.0 { 100.0 } else { 50.0 })); + assert!((src + 1.5).abs() < 1e-9, "{src}"); + // A Hold key between samples (1.03 s) lands the same way on every frame: past it, each + // 0.1 s frame advances the source by exactly 50 % of a frame. + let hold = |lt: f64| Some(if lt < 1.03 { 100.0 } else { 50.0 }); + let at = |t: f64| timewarp_integrate_speed(t, 10.0, 50.0, hold); + for k in 12..40 { + let (a, b) = (at((k - 1) as f64 / 10.0), at(k as f64 / 10.0)); + assert!((b - a - 0.05).abs() < 1e-9, "k={k}: {}", b - a); + } + // A static Speed keeps `t × speed / 100` exactly. + assert_eq!(timewarp_source_time(2.0, false, 37.0, 0.0, 10.0), 2.0 * 37.0 / 100.0); + // Source Frame mode ignores Speed. + let out = run_ramp(&[("method", Value::Enum(0)), ("adjustTimeBy", Value::Enum(1)), ("sourceFrame", num(3.0))], 2.0, &h); + assert!((out.img.data[0][0] - 0.3).abs() < 1e-5); + } + + #[test] + fn timewarp_animated_speed_survives_bad_values() { + // Non-finite samples count as the current Speed (or 0); the result is always finite. + for t in [0.0, 1.0, -3.0, 1e9, f64::MAX, f64::INFINITY, f64::NAN] { + for now in [50.0, f64::NAN, f64::INFINITY, 1e308] { + let src = timewarp_integrate_speed(t, 10.0, now, |lt| Some(if lt < 0.5 { f64::NAN } else { 1e308 })); + assert!(src.is_finite(), "t={t} now={now}: {src}"); + let src = timewarp_integrate_speed(t, f64::NAN, now, |_| None); + assert!(src.is_finite(), "t={t} now={now}: {src}"); + } + } + let h = Ramp { speed: |lt| if lt < 1.0 { f64::NAN } else { f64::INFINITY } }; + let out = run_ramp(&[("method", Value::Enum(2)), ("speed", num(50.0)), ("motionBlur/enableMotionBlur", Value::Bool(true))], 2.0, &h); + assert!(out.img.data.iter().all(|p| p.iter().all(|c| c.is_finite()))); + let h = Ramp { speed: |_| 1e308 }; + let out = run_ramp(&[("method", Value::Enum(1)), ("speed", num(1e308))], 2.0, &h); + assert!(out.img.data.iter().all(|p| p.iter().all(|c| c.is_finite()))); + } + #[test] fn time_displacement_uses_luma_as_offset() { // Static map from a bright self frame: luminance 1.0 → +max seconds. From 6d5e84e7a5b46cda18dd5c9bcdc4ae55d49305f9 Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 14:11:33 -0300 Subject: [PATCH 18/71] Export: H.264, HEVC and AV1 tag the frames' own colour (#2879) Every MP4 export said BT.709 whatever the Output color space, in the colr box and in the bitstream, so Rec. 2020, P3, PQ and HLG renders were labelled as BT.709. Move the ProRes colr mapping to output_codes and use it for every movie path: the nclx colr box, FilmCraft's H.264 ColorConfig (VUI), and new colour fields in effectcraft-hevcenc (VUI) and effectcraft-av1enc (color_config), which wrote 1-1-1 as constants. The matrix stays BT.709, the coefficients every path converts with. Default renders don't change. --- crates/av1enc/src/lib.rs | 25 +++++-- crates/export/src/encode.rs | 82 ++++++++++++---------- crates/export/src/hevc_av1.rs | 22 +++--- crates/export/src/webm_av1.rs | 3 +- crates/export/tests/it/main.rs | 1 + crates/export/tests/it/movie_color.rs | 98 +++++++++++++++++++++++++++ crates/hevcenc/src/lib.rs | 10 +++ crates/hevcenc/src/params.rs | 9 ++- 8 files changed, 198 insertions(+), 52 deletions(-) create mode 100644 crates/export/tests/it/movie_color.rs diff --git a/crates/av1enc/src/lib.rs b/crates/av1enc/src/lib.rs index b86db0fa2..2962833d9 100644 --- a/crates/av1enc/src/lib.rs +++ b/crates/av1enc/src/lib.rs @@ -52,11 +52,28 @@ pub struct EncoderConfig { pub keyint: u32, /// BT.709 limited range is signalled unless this is set. pub full_range: bool, + /// The colour description of `color_config` (ITU-T H.273 code points); BT.709 by default. + pub color_primaries: u8, + pub transfer_characteristics: u8, + pub matrix_coefficients: u8, } impl EncoderConfig { pub fn new(width: u32, height: u32, fps_num: u32, fps_den: u32) -> Self { - EncoderConfig { width, height, fps_num, fps_den, bit_depth: 8, level_idx: None, rate: RateControl::ConstantQ(100), keyint: 60, full_range: false } + EncoderConfig { + width, + height, + fps_num, + fps_den, + bit_depth: 8, + level_idx: None, + rate: RateControl::ConstantQ(100), + keyint: 60, + full_range: false, + color_primaries: 1, + transfer_characteristics: 1, + matrix_coefficients: 1, + } } } @@ -364,9 +381,9 @@ impl Encoder { w.f(1, (c.bit_depth > 8) as u32); // high_bitdepth w.f(1, 0); // mono_chrome w.f(1, 1); // color_description_present_flag - w.f(8, 1); // color_primaries: BT.709 - w.f(8, 1); // transfer_characteristics: BT.709 - w.f(8, 1); // matrix_coefficients: BT.709 + w.f(8, c.color_primaries as u32); // color_primaries + w.f(8, c.transfer_characteristics as u32); // transfer_characteristics + w.f(8, c.matrix_coefficients as u32); // matrix_coefficients w.f(1, c.full_range as u32); // color_range w.f(2, 0); // chroma_sample_position: unknown w.f(1, 0); // separate_uv_delta_q diff --git a/crates/export/src/encode.rs b/crates/export/src/encode.rs index c9888a035..dd972d326 100644 --- a/crates/export/src/encode.rs +++ b/crates/export/src/encode.rs @@ -19,14 +19,41 @@ fn mux_err(e: impl std::fmt::Display) -> ExportError { ExportError::Io(e.to_string()) } -/// Tag a video [`SampleEntry`] with the BT.709 limited-range `colr` (nclx) box every video -/// encoder here outputs (`rgba_to_yuv420`, `rgba_to_yuv_10` and `rgba_to_yuv420_bits` all use -/// the 0.2126/0.7152/0.0722 luma and a 16–235 luma range). Without it a re-import falls back to -/// the container's resolution-based `default_matrix` — BT.601 below 1024×577 — and shifts the +/// What a render's frames are, as ITU-T H.273 code points, for the `colr` box and the encoders' +/// own colour description. The matrix is BT.709, the one every encoder here converts with +/// (`rgba_to_yuv420`, `rgba_to_yuv_10` and `rgba_to_yuv420_bits` all use the 0.2126/0.7152/0.0722 +/// luma). Primaries and transfer follow the render path (`Pipe::output`): only a working space +/// converts to the output space, so unmanaged frames pass through. sRGB output keeps transfer 1 +/// rather than 13 (#340), and an alpha matte is not in the output space. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Codes { + pub(crate) primaries: u8, + pub(crate) transfer: u8, + pub(crate) matrix: u8, +} + +pub(crate) fn output_codes(settings: &ProjectSettings, channels: Channels) -> Codes { + use effectcraft_project::ColorSpace as Space; + let output = settings.working_space.and(settings.output_space).filter(|_| channels != Channels::Alpha); + let (primaries, transfer) = match output { + None | Some(Space::Srgb | Space::Rec709) => (1, 1), + Some(Space::Rec2020) => (9, 1), + Some(Space::DisplayP3) => (12, 13), + Some(Space::Rec2100Pq) => (9, 16), + Some(Space::Rec2100Hlg) => (9, 18), + // H.273 has no code for the ACES primaries: unspecified, in linear light. + Some(Space::AcesCg | Space::Aces2065) => (2, 8), + }; + Codes { primaries, transfer, matrix: 1 } +} + +/// Tag a video [`SampleEntry`] with the limited-range (16–235 luma, as every encoder here +/// writes) `colr` (nclx) box for `c` (#2879). Without it a re-import falls back to the +/// container's resolution-based `default_matrix` — BT.601 below 1024×577 — and shifts the /// colours (pure red came back as [232,0,1]). -pub(crate) fn tag_bt709_limited(entry: &mut SampleEntry) { +pub(crate) fn tag_nclx_limited(entry: &mut SampleEntry, c: Codes) { if let Some(v) = entry.video.as_mut() { - v.color = Some(ColorInfo::Nclx { primaries: 1, transfer: 1, matrix: 1, full_range: false }); + v.color = Some(ColorInfo::Nclx { primaries: c.primaries.into(), transfer: c.transfer.into(), matrix: c.matrix.into(), full_range: false }); } } @@ -94,21 +121,23 @@ struct H264 { w: u32, h: u32, rate: FrameRate, + codes: Codes, y: Vec, u: Vec, v: Vec, } impl H264 { - fn new(w: u32, h: u32, rate: FrameRate, kbps: u32) -> Result { + fn new(w: u32, h: u32, rate: FrameRate, kbps: u32, codes: Codes) -> Result { let mut cfg = filmcraft_h264enc::EncoderConfig::new(w, h, rate.num as u32, rate.den as u32); cfg.format = filmcraft_h264enc::PacketFormat::LengthPrefixed; cfg.aud = false; cfg.keyint = (rate.as_f64() * 2.0).round().max(1.0) as u32; let kbps = kbps.max(100); cfg.rate = filmcraft_h264enc::RateControl::Vbr { target_kbps: kbps, max_kbps: kbps * 3 / 2 }; + cfg.color = filmcraft_h264enc::ColorConfig { primaries: codes.primaries, transfer: codes.transfer, matrix: codes.matrix, full_range: false }; let enc = filmcraft_h264enc::Encoder::new(cfg).map_err(enc)?; - Ok(H264 { enc, w, h, rate, y: vec![], u: vec![], v: vec![] }) + Ok(H264 { enc, w, h, rate, codes, y: vec![], u: vec![], v: vec![] }) } fn packets(&self, ps: Vec) -> Vec { ps.into_iter().map(|p| Packet { data: p.data, key: p.keyframe, cto: (p.pts - p.dts) as i32 }).collect() @@ -122,7 +151,7 @@ impl VideoEncoder for H264 { filmcraft_isobmff::AvcConfig::new(vec![sps], vec![pps], 4) }); let mut entry = SampleEntry::avc(cfg, self.w as u16, self.h as u16); - tag_bt709_limited(&mut entry); + tag_nclx_limited(&mut entry, self.codes); entry } fn encode(&mut self, rgba: &[u8], index: u64) -> Result> { @@ -167,25 +196,10 @@ struct ProRes { colr: ColorInfo, } -/// The `colr` atom of a ProRes render: what its frames are, in H.273 code points. MOV uses -/// QuickTime's `nclc`, which has no range flag; ProRes frames are limited range. The matrix is the -/// one [`rgba_to_yuv_10`] always uses. Primaries and transfer follow the render path -/// (`Pipe::output`): only a working space converts to the output space, so unmanaged frames pass -/// through. sRGB output keeps transfer 1 rather than 13 (#340), and an alpha matte is not in the -/// output space. -fn prores_colr(settings: &ProjectSettings, channels: Channels) -> ColorInfo { - use effectcraft_project::ColorSpace as Space; - let output = settings.working_space.and(settings.output_space).filter(|_| channels != Channels::Alpha); - let (primaries, transfer) = match output { - None | Some(Space::Srgb | Space::Rec709) => (1, 1), - Some(Space::Rec2020) => (9, 1), - Some(Space::DisplayP3) => (12, 13), - Some(Space::Rec2100Pq) => (9, 16), - Some(Space::Rec2100Hlg) => (9, 18), - // H.273 has no code for the ACES primaries: unspecified, in linear light. - Some(Space::AcesCg | Space::Aces2065) => (2, 8), - }; - ColorInfo::Nclc { primaries, transfer, matrix: 1 } +/// The `colr` atom of a ProRes render ([`output_codes`]). MOV uses QuickTime's `nclc`, which has +/// no range flag; ProRes frames are limited range. +fn prores_colr(c: Codes) -> ColorInfo { + ColorInfo::Nclc { primaries: c.primaries.into(), transfer: c.transfer.into(), matrix: c.matrix.into() } } fn prores_profile(p: ProResProfile) -> filmcraft_prores::Profile { @@ -328,15 +342,13 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St Channels::Rgba if !matches!(fmt, OutputFormat::ProRes | OutputFormat::Uncompressed) => Channels::Rgb, c => c, }; + let codes = output_codes(&job.project.settings, channels); let mut venc: Box = match fmt { - OutputFormat::H264 => Box::new(H264::new(w, h, rate, job.output.bitrate_kbps)?), - OutputFormat::Hevc => Box::new(crate::hevc_av1::Hevc::new(w, h, rate, job.output)?), - OutputFormat::Av1 => Box::new(crate::hevc_av1::Av1::new(w, h, rate, job.output)?), + OutputFormat::H264 => Box::new(H264::new(w, h, rate, job.output.bitrate_kbps, codes)?), + OutputFormat::Hevc => Box::new(crate::hevc_av1::Hevc::new(w, h, rate, job.output, codes)?), + OutputFormat::Av1 => Box::new(crate::hevc_av1::Av1::new(w, h, rate, job.output, codes)?), OutputFormat::Uncompressed => Box::new(crate::uncompressed::Raw::new(w, h, channels == Channels::Rgba)?), - _ => { - let colr = prores_colr(&job.project.settings, channels); - Box::new(ProRes::new(w, h, job.output.prores_profile, if channels == Channels::Alpha { Channels::Rgb } else { channels }, colr)) - } + _ => Box::new(ProRes::new(w, h, job.output.prores_profile, if channels == Channels::Alpha { Channels::Rgb } else { channels }, prores_colr(codes))), }; let brand = if matches!(fmt, OutputFormat::ProRes | OutputFormat::Uncompressed) { Brand::Mov } else { Brand::Mp4 }; let batch = batch_size(); diff --git a/crates/export/src/hevc_av1.rs b/crates/export/src/hevc_av1.rs index c363dff35..d43b1c5ed 100644 --- a/crates/export/src/hevc_av1.rs +++ b/crates/export/src/hevc_av1.rs @@ -7,7 +7,7 @@ use effectcraft_time::FrameRate; use filmcraft_isobmff::{Av1Config, CodecConfig, FourCc, HevcConfig, SampleEntry}; use rayon::prelude::*; -use crate::encode::{Packet, VideoEncoder}; +use crate::encode::{Codes, Packet, VideoEncoder}; use crate::{ExportError, Result}; fn enc_err(e: impl std::fmt::Display) -> ExportError { @@ -59,6 +59,7 @@ pub(crate) struct Hevc { w: u32, h: u32, bits: u8, + codes: Codes, planes: Planes, } @@ -81,11 +82,12 @@ pub(crate) fn hevc_config(w: u32, h: u32, rate: FrameRate, om: &OutputModule) -> } impl Hevc { - pub(crate) fn new(w: u32, h: u32, rate: FrameRate, om: &OutputModule) -> Result { - let cfg = hevc_config(w, h, rate, om); + pub(crate) fn new(w: u32, h: u32, rate: FrameRate, om: &OutputModule, codes: Codes) -> Result { + let mut cfg = hevc_config(w, h, rate, om); + (cfg.colour_primaries, cfg.transfer_characteristics, cfg.matrix_coeffs) = (codes.primaries, codes.transfer, codes.matrix); let bits = cfg.profile.bit_depth(); let enc = effectcraft_hevcenc::Encoder::new(cfg).map_err(enc_err)?; - Ok(Hevc { enc, w, h, bits, planes: Planes::default() }) + Ok(Hevc { enc, w, h, bits, codes, planes: Planes::default() }) } } @@ -93,7 +95,7 @@ impl VideoEncoder for Hevc { fn sample_entry(&self) -> SampleEntry { let cfg = HevcConfig::parse(&self.enc.hvcc()).unwrap_or_default(); let mut entry = SampleEntry::hevc(cfg, self.w as u16, self.h as u16); - crate::encode::tag_bt709_limited(&mut entry); + crate::encode::tag_nclx_limited(&mut entry, self.codes); entry } fn encode(&mut self, rgba: &[u8], _index: u64) -> Result> { @@ -116,6 +118,7 @@ pub(crate) struct Av1 { w: u32, h: u32, bits: u8, + codes: Codes, planes: Planes, } @@ -135,11 +138,12 @@ pub(crate) fn av1_config(w: u32, h: u32, rate: FrameRate, om: &OutputModule) -> } impl Av1 { - pub(crate) fn new(w: u32, h: u32, rate: FrameRate, om: &OutputModule) -> Result { - let cfg = av1_config(w, h, rate, om); + pub(crate) fn new(w: u32, h: u32, rate: FrameRate, om: &OutputModule, codes: Codes) -> Result { + let mut cfg = av1_config(w, h, rate, om); + (cfg.color_primaries, cfg.transfer_characteristics, cfg.matrix_coefficients) = (codes.primaries, codes.transfer, codes.matrix); let bits = cfg.bit_depth; let enc = effectcraft_av1enc::Encoder::new(cfg).map_err(enc_err)?; - Ok(Av1 { enc, w, h, bits, planes: Planes::default() }) + Ok(Av1 { enc, w, h, bits, codes, planes: Planes::default() }) } /// The `av1C` record (also the WebM CodecPrivate). pub(crate) fn av1c(&self) -> Vec { @@ -151,7 +155,7 @@ impl VideoEncoder for Av1 { fn sample_entry(&self) -> SampleEntry { let cfg = Av1Config::parse(&self.enc.av1c()).unwrap_or_default(); let mut entry = SampleEntry::video(FourCc(*b"av01"), CodecConfig::Av1(cfg), self.w as u16, self.h as u16); - crate::encode::tag_bt709_limited(&mut entry); + crate::encode::tag_nclx_limited(&mut entry, self.codes); entry } fn encode(&mut self, rgba: &[u8], _index: u64) -> Result> { diff --git a/crates/export/src/webm_av1.rs b/crates/export/src/webm_av1.rs index ca06a3e51..a85a5c9ec 100644 --- a/crates/export/src/webm_av1.rs +++ b/crates/export/src/webm_av1.rs @@ -42,7 +42,8 @@ fn cluster(blocks: &mut [Block]) -> Vec { pub(crate) async fn webm_av1(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut State<'_>) -> Result { let rate = job.settings.rate(comp); - let mut venc = crate::hevc_av1::Av1::new(w, h, rate, job.output)?; + let codes = crate::encode::output_codes(&job.project.settings, job.output.channels); + let mut venc = crate::hevc_av1::Av1::new(w, h, rate, job.output, codes)?; let with_audio = wants_audio(job); let opus_channels = if job.output.audio_channels == 1 { 1usize } else { 2 }; let mut opus = with_audio.then(|| opus_encoder(job)); diff --git a/crates/export/tests/it/main.rs b/crates/export/tests/it/main.rs index a5b9fa99a..f7bfa7a1c 100644 --- a/crates/export/tests/it/main.rs +++ b/crates/export/tests/it/main.rs @@ -7,6 +7,7 @@ mod hevc_av1_roundtrip; #[cfg(not(target_arch = "wasm32"))] mod mov_audio_sample_positions; mod movie_bytes; +mod movie_color; mod ms_rounded_footage; #[cfg(not(target_arch = "wasm32"))] mod native_audio_sample_positions; diff --git a/crates/export/tests/it/movie_color.rs b/crates/export/tests/it/movie_color.rs new file mode 100644 index 000000000..595157637 --- /dev/null +++ b/crates/export/tests/it/movie_color.rs @@ -0,0 +1,98 @@ +//! H.264, HEVC and AV1 MP4s describe the frames the render path made (#2879): the `colr` box, read +//! back with FilmCraft's demuxer, and the bitstream's own colour description (the SPS VUI, AV1's +//! `color_config`), which ffprobe reports for the first decoded frame when it can decode it. + +use std::path::Path; +use std::process::Command; + +use effectcraft_color::Label; +use effectcraft_export::render_queue::{OutputFormat, OutputModule, RenderSettings, TimeSpan}; +use effectcraft_export::{Job, export}; +use effectcraft_project::{ColorSpace, Comp, ItemKind, LayerSource, Project, Solid, build}; +use effectcraft_render::NoFootage; +use effectcraft_time::{FrameRate, Tick}; +use filmcraft_isobmff::{ColorInfo, TrackKind}; + +/// (name, Output color space, H.273 primaries / transfer / matrix, the same as ffprobe names them) +const ROWS: &[(&str, Option, [u16; 3], [&str; 3])] = &[ + ("default", None, [1, 1, 1], ["bt709", "bt709", "bt709"]), + ("rec2020", Some(ColorSpace::Rec2020), [9, 1, 1], ["bt2020", "bt709", "bt709"]), + ("p3", Some(ColorSpace::DisplayP3), [12, 13, 1], ["smpte432", "iec61966-2-1", "bt709"]), + ("pq", Some(ColorSpace::Rec2100Pq), [9, 16, 1], ["bt2020", "smpte2084", "bt709"]), + ("hlg", Some(ColorSpace::Rec2100Hlg), [9, 18, 1], ["bt2020", "arib-std-b67", "bt709"]), +]; + +fn render(output: Option, format: OutputFormat, path: &Path) { + let mut p = Project::default(); + p.settings.working_space = Some(ColorSpace::Srgb); + p.settings.output_space = output; + let mut comp = Comp::new(64, 48, FrameRate::new(10, 1), Tick::from_seconds_f64(0.2)); + let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: [0.75, 0.25, 0.5], width: 64, height: 48, pixel_aspect: 1.0 })); + comp.layers.push(build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (64, 48), None)); + let comp = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); + let settings = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() }; + let om = OutputModule::for_format(format); + let out = path.to_string_lossy().to_string(); + let job = Job { + project: &p, + footage: &NoFootage, + expr: None, + accel: None, + comp, + settings: &settings, + output: &om, + path: &out, + sink: None, + options: Default::default(), + nested_switches: true, + }; + export(&job, &mut |_| true).expect("export"); +} + +/// ffprobe's primaries, transfer and matrix of the first decoded frame, or `None` without ffprobe +/// or when its build can't decode the stream. +fn frame_tags(path: &Path) -> Option> { + let out = Command::new("ffprobe") + .args([ + "-v", + "error", + "-select_streams", + "v:0", + "-read_intervals", + "%+#1", + "-show_entries", + "frame=color_primaries,color_transfer,color_space", + "-of", + "default=nw=1", + ]) + .arg(path) + .output() + .ok()?; + let text = String::from_utf8_lossy(&out.stdout).to_string(); + let tag = |key: &str| text.lines().find_map(|l| l.strip_prefix(key)?.strip_prefix('=')).map(str::to_string); + Some(vec![tag("color_primaries")?, tag("color_transfer")?, tag("color_space")?]) +} + +#[test] +fn mp4_tags_describe_the_frames() { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export/movie_color"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("mkdir"); + for (format, codec) in [(OutputFormat::H264, "h264"), (OutputFormat::Hevc, "hevc"), (OutputFormat::Av1, "av1")] { + for &(name, output, [primaries, transfer, matrix], names) in ROWS { + let path = dir.join(format!("{codec}_{name}.mp4")); + render(output, format, &path); + + let bytes = std::fs::read(&path).expect("read movie"); + let mp4 = filmcraft_isobmff::open(bytes.as_slice()).expect("demux"); + let track = mp4.tracks.iter().find(|t| t.kind == TrackKind::Video).expect("video track"); + let colr = track.entries[0].video.as_ref().and_then(|v| v.color.clone()); + assert_eq!(colr, Some(ColorInfo::Nclx { primaries, transfer, matrix, full_range: false }), "{codec} {name}: colr"); + + match frame_tags(&path) { + Some(tags) => assert_eq!(tags, names, "{codec} {name}: the bitstream's colour description"), + None => eprintln!("{codec} {name}: ffprobe can't decode it here, bitstream not checked"), + } + } + } +} diff --git a/crates/hevcenc/src/lib.rs b/crates/hevcenc/src/lib.rs index 4d38f84d9..1934143a0 100644 --- a/crates/hevcenc/src/lib.rs +++ b/crates/hevcenc/src/lib.rs @@ -83,6 +83,10 @@ pub struct EncoderConfig { pub keyint: u32, /// BT.709 limited range is signalled in the VUI unless this is set. pub full_range: bool, + /// The VUI colour description (ITU-T H.273 code points); BT.709 by default. + pub colour_primaries: u8, + pub transfer_characteristics: u8, + pub matrix_coeffs: u8, } impl EncoderConfig { @@ -97,6 +101,9 @@ impl EncoderConfig { rate: RateControl::ConstantQp(28), keyint: 60, full_range: false, + colour_primaries: 1, + transfer_characteristics: 1, + matrix_coeffs: 1, } } } @@ -234,6 +241,9 @@ impl Encoder { profile_idc: cfg.profile.idc(), level_idc, full_range: cfg.full_range, + colour_primaries: cfg.colour_primaries, + transfer_characteristics: cfg.transfer_characteristics, + matrix_coeffs: cfg.matrix_coeffs, fps_num: cfg.fps_num, fps_den: cfg.fps_den, }; diff --git a/crates/hevcenc/src/params.rs b/crates/hevcenc/src/params.rs index f1b89f731..89bac938e 100644 --- a/crates/hevcenc/src/params.rs +++ b/crates/hevcenc/src/params.rs @@ -25,6 +25,9 @@ pub struct SeqParams { pub profile_idc: u8, pub level_idc: u8, pub full_range: bool, + pub colour_primaries: u8, + pub transfer_characteristics: u8, + pub matrix_coeffs: u8, pub fps_num: u32, pub fps_den: u32, } @@ -166,9 +169,9 @@ fn vui(w: &mut BitWriter, p: &SeqParams) { w.put(5, 3); // video_format: unspecified w.flag(p.full_range); w.put(1, 1); // colour_description_present_flag - w.put(1, 8); // colour_primaries BT.709 - w.put(1, 8); // transfer_characteristics BT.709 - w.put(1, 8); // matrix_coeffs BT.709 + w.put(p.colour_primaries as u32, 8); // colour_primaries + w.put(p.transfer_characteristics as u32, 8); // transfer_characteristics + w.put(p.matrix_coeffs as u32, 8); // matrix_coeffs w.put(0, 1); // chroma_loc_info_present_flag w.put(0, 1); // neutral_chroma_indication_flag w.put(0, 1); // field_seq_flag From da5bf7b45a05f5e104711635122d1a34154aa2ab Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Mon, 2 Nov 2026 22:54:04 +0530 Subject: [PATCH 19/71] Follow-up to #2803: document the Timewarp speed-ramp tests and test host Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/time_fx.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/effects/src/time_fx.rs b/crates/effects/src/time_fx.rs index 6f76abedb..cf7624d92 100644 --- a/crates/effects/src/time_fx.rs +++ b/crates/effects/src/time_fx.rs @@ -1269,9 +1269,11 @@ mod tests { let v = t as f32; Some(Buf { img: Image::filled(6, 4, [v, v, v, 1.0]), offset: [0.0; 2], scale: 1.0 }) } + /// Timewarp's (effect 2) Speed follows `speed`; every other parameter is unknown. fn param_at(&self, effect_index: usize, id: &str, lt: f64) -> Option { (effect_index == 2 && id == "speed").then(|| num((self.speed)(lt))) } + /// Only Timewarp's (effect 2) Speed is animated. fn param_is_static(&self, effect_index: usize, id: &str) -> bool { !(effect_index == 2 && id == "speed") } @@ -1282,6 +1284,8 @@ mod tests { run_fx("ec.time.timewarp", vals, Image::filled(6, 4, [0.9, 0.0, 0.0, 1.0]), t, env) } + /// #2803: an animated Speed is a rate integrated from layer time 0, so a speed ramp plays on + /// from where the footage is instead of jumping to another segment. #[test] fn timewarp_integrates_animated_speed() { // Speed 100 % for the first second, then 50 %: at t = 2 s the source is 1 + 0.5 = 1.5 s @@ -1311,6 +1315,8 @@ mod tests { assert!((out.img.data[0][0] - 0.3).abs() < 1e-5); } + /// Never crash: non-finite or huge Speeds, times and frame rates give finite source times + /// and pixels. #[test] fn timewarp_animated_speed_survives_bad_values() { // Non-finite samples count as the current Speed (or 0); the result is always finite. From 39119ff9cedb60171d6cf3105759a9dc3d404611 Mon Sep 17 00:00:00 2001 From: Bar Borer Date: Mon, 2 Nov 2026 20:36:56 +0200 Subject: [PATCH 20/71] Viewer: dragged layers snap to other layers' midpoints and center lines (#2855) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests/viewer.rs | 62 ++++++++++++++++++++++++++++ crates/engine/src/viewer.rs | 23 ++++++----- crates/ui-egui/tests/it/ui_viewer.rs | 45 ++++++++++++++++++++ 3 files changed, 119 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/tests/viewer.rs b/crates/engine/src/tests/viewer.rs index ef2bd8acc..fa958a60c 100644 --- a/crates/engine/src/tests/viewer.rs +++ b/crates/engine/src/tests/viewer.rs @@ -60,6 +60,68 @@ fn snap_lines_snap_axes_independently_and_priority_breaks_ties() { assert_eq!(snap(&[[0.0, 0.0]], &t3, 5.0).unwrap().hits[0].source, SnapSource::Vertex(LayerId(3))); } +/// #2855: an edge midpoint lies on its edge line, so one line nearer than the point must not win +/// and leave the other axis free. Two lines do win when together they are nearer. +#[test] +fn snap_point_beats_one_line_and_loses_only_to_two_nearer_lines() { + let src = SnapSource::Layer(LayerId(1)); + let mid = SnapTarget::point([100.0, 50.0], PRI_LAYER, src); + let top = SnapTarget::hline(50.0, PRI_LAYER, src); + // The line alone is nearer (1 against √10), and the point still snaps both axes. + let s = snap(&[[103.0, 51.0]], &[top, mid], 5.0).unwrap(); + assert_eq!(s.at, [100.0, 50.0]); + assert_eq!(s.delta, [-3.0, -1.0]); + assert_eq!(s.hits, [mid]); + // Lines through the point itself tie with it: the point. + let centre = SnapTarget::vline(100.0, PRI_LAYER, src); + assert_eq!(snap(&[[103.0, 51.0]], &[top, centre, mid], 5.0).unwrap().hits, [mid]); + // Two lines together nearer than the point: both snap. + let far = SnapTarget::point([101.0, 55.0], PRI_LAYER, src); + let v = SnapTarget::vline(102.0, PRI_GUIDE, SnapSource::Guide); + let h = SnapTarget::hline(52.0, PRI_GUIDE, SnapSource::Guide); + let s = snap(&[[103.0, 51.0]], &[v, h, far], 5.0).unwrap(); + assert_eq!(s.at, [102.0, 52.0]); + assert_eq!(s.hits, [v, h]); + // A line out of its span doesn't count, so the point still wins. + let cut = SnapTarget::vline(103.0, PRI_LAYER, src).within(Some([0.0, 10.0])); + assert_eq!(snap(&[[103.0, 51.0]], &[cut, top, mid], 5.0).unwrap().hits, [mid]); + // No point in reach: a single line still snaps its own axis. + let s = snap(&[[103.0, 51.0]], &[top], 5.0).unwrap(); + assert_eq!(s.at, [103.0, 50.0]); +} + +/// #2855: an unrotated layer's centre lines follow its edges' span rule, reach the outer comp +/// through a collapsed precomp layer, and a rotated layer keeps its points only. +#[test] +fn layer_centre_lines_follow_the_edges() { + use crate::viewer::{SnapFeatures, SnapOptions, targets}; + let mut s = comp(); + // A 100×50 solid moved off the comp's centre lines: x 70–170, y 55–105, centre (120, 80). + let a = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 100, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [120, 80, 0]})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let at = |s: &Session, f: SnapFeatures| { + let comp = s.project.comp(cid).unwrap().clone(); + let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, Tick::ZERO); + targets(&ctx, &[], SnapOptions { layers: true, features: f, guides: false, grid: false, grid_spacing: 0.0 }) + }; + let lines = |t: &[SnapTarget], l: LayerId| { + let of = |kind: SnapKind, i: usize, c: f64| t.iter().find(|t| t.kind == kind && t.source == SnapSource::Layer(l) && t.pos[i] == c).map(|t| t.span); + (of(SnapKind::VLine, 0, 120.0), of(SnapKind::HLine, 1, 80.0)) + }; + assert_eq!(lines(&at(&s, SnapFeatures::default()), LayerId(a)), (Some(None), Some(None))); + let off = SnapFeatures { edges_extended: false, ..Default::default() }; + assert_eq!(lines(&at(&s, off), LayerId(a)), (Some(Some([55.0, 105.0])), Some(Some([70.0, 170.0])))); + // Through a collapsed precomp layer (it fills the comp, unmoved, so the numbers stay). + s.execute("layer.precompose", json!({"layers": [a], "name": "Inner"})).unwrap(); + let pre = s.active_comp().unwrap().layers[0].id; + s.execute("layer.setSwitch", json!({"layers": [pre.0], "switch": "collapse", "value": true})).unwrap(); + assert_eq!(lines(&at(&s, off), pre), (Some(Some([55.0, 105.0])), Some(Some([70.0, 170.0])))); + // Rotated: no lines at all. + s.execute("prop.set", json!({"layer": pre.0, "path": "transform/rotation", "value": 30})).unwrap(); + assert_eq!(lines(&at(&s, off), pre), (None, None)); +} + #[test] fn snap_targets_cover_layers_comp_guides_and_grid() { let mut s = comp(); diff --git a/crates/engine/src/viewer.rs b/crates/engine/src/viewer.rs index 6b6e5ce99..1889b6a83 100644 --- a/crates/engine/src/viewer.rs +++ b/crates/engine/src/viewer.rs @@ -48,7 +48,7 @@ pub struct SnapTarget { pub priority: u8, pub source: SnapSource, /// A line's extent along itself (y of a vertical line, x of a horizontal one): a layer's - /// edge without Snap Edges Extended. `None` runs across the whole comp. + /// edge or centre line without Snap Edges Extended. `None` runs across the whole comp. #[serde(default)] pub span: Option<[f64; 2]>, } @@ -70,7 +70,7 @@ impl SnapTarget { } /// Tools bar ▸ Snapping options, the two After Effects' Snapping menu has: Snap Edges Extended -/// (layer edges snap along their whole line, beyond the layer's bounds) and Snap to Features in +/// (layer edge and centre lines snap along their whole line, beyond the layer's bounds) and Snap to Features in /// Collapsed Compositions and Text Layers (the layers inside a precomp layer with Collapse /// Transformations on are targets too). Both are on by default. Every layer feature snaps /// (corners, edge midpoints, centre, anchor point, mask and shape path points), as in After @@ -126,9 +126,10 @@ fn better(a: (f64, u8), b: (f64, u8)) -> bool { } /// Snap feature points `sources` (comp pixels) to the nearest target within `tol` comp pixels. -/// A point target snaps both axes and wins when it is at least as near as the best lines; -/// otherwise the nearest vertical and horizontal lines snap x and y independently. Ties go to -/// the lower priority value. +/// A point target snaps both axes and wins unless a vertical and a horizontal line both resolve +/// and together lie nearer than it (one line alone leaves the other axis free, so it never beats +/// a point: a layer's edge midpoint lies on its edge line); otherwise the nearest vertical and +/// horizontal lines snap x and y independently. Ties go to the lower priority value. pub fn snap(sources: &[[f64; 2]], targets: &[SnapTarget], tol: f64) -> Option { let mut best_pt: Option<((f64, u8), [f64; 2], SnapTarget)> = None; let mut best_x: Option<((f64, u8), [f64; 2], SnapTarget)> = None; @@ -150,9 +151,9 @@ pub fn snap(sources: &[[f64; 2]], targets: &[SnapTarget], tol: f64) -> Option(ctx: &EvalCtx<'a>, exclude: &'a [LayerId]) -> impl Iterato comp.layers.iter().filter(move |l| l.is_active_at(time) && l.switches.video && (!any_solo || l.switches.solo) && !exclude.contains(&l.id)) } -/// A 2D layer's snap targets: its box points, its edges as lines while unrotated (across the -/// comp with Snap Edges Extended, along the layer otherwise), its anchor point unless `anchor` is +/// A 2D layer's snap targets: its box points, its edges and centre as lines while unrotated (across +/// the comp with Snap Edges Extended, along the layer otherwise), its anchor point unless `anchor` is /// false (Pan Behind drags it, the box stays put), its mask and shape path vertices and, with /// Snap to Features in Collapsed Compositions, the targets of the visible layers inside a /// collapsed precomp layer. None for cameras, lights and 3D layers. @@ -386,10 +387,10 @@ fn layer_targets_at(ctx: &EvalCtx, l: &Layer, anchor: bool, f: SnapFeatures, dep let [nw, ne, _, sw] = b.corners; if (nw[1] - ne[1]).abs() < 1e-6 && (nw[0] - sw[0]).abs() < 1e-6 { let (xs, ys) = if f.edges_extended { (None, None) } else { (Some([nw[0], ne[0]]), Some([nw[1], sw[1]])) }; - for x in [nw[0], ne[0]] { + for x in [nw[0], b.center[0], ne[0]] { out.push(SnapTarget::vline(x, PRI_LAYER, src).within(ys)); } - for y in [nw[1], sw[1]] { + for y in [nw[1], b.center[1], sw[1]] { out.push(SnapTarget::hline(y, PRI_LAYER, src).within(xs)); } } diff --git a/crates/ui-egui/tests/it/ui_viewer.rs b/crates/ui-egui/tests/it/ui_viewer.rs index f0086d28a..39c70eecd 100644 --- a/crates/ui-egui/tests/it/ui_viewer.rs +++ b/crates/ui-egui/tests/it/ui_viewer.rs @@ -420,6 +420,51 @@ fn layer_drag_snaps_to_other_layers_edges_ctrl_toggles_and_options_apply() { assert!(!snapped(p), "edges not extended: {p:?}"); } +/// #2855: a dragged layer's edge midpoints snap to another layer's on both axes, side by side and +/// stacked, with Snap Edges Extended on and off; its centre lines snap along the target's extent, +/// and beyond it only with the option on. +#[test] +fn layer_drag_snaps_to_other_layers_midpoints_and_centre_lines() { + let mut h = harness(); + let box_id = h.state().session.active_comp().unwrap().layers[0].id; + // A 100×100 target at x 450–550, y 50–150, centre (500, 100). + let target = h.state_mut().session.execute("layer.newSolid", json!({"name": "Target", "color": "#20e040", "width": 100, "height": 100})).unwrap()["layer"] + .as_u64() + .unwrap(); + // Grab the 80×80 box (at x 60–140, y 210–290) by the feature at `grab` and take that feature + // to `to`: returns where the box's centre lands. + let drop = |h: &mut Harness<'_, EffectcraftApp>, grab: [f32; 2], to: [f32; 2]| { + let s = &mut h.state_mut().session; + s.execute("prop.set", json!({"layer": target, "path": "transform/position", "value": [500, 100, 0]})).unwrap(); + s.execute("prop.set", json!({"layer": box_id.0, "path": "transform/position", "value": [100, 250, 0]})).unwrap(); + s.execute("edit.deselectAll", json!({})).unwrap(); + h.run_steps(2); + let (from, to) = (screen(h, grab), screen(h, to)); + hold_drag(h, from, to, Default::default()); + let l = h.state().session.active_comp().unwrap().layer(box_id).unwrap().clone(); + let p = l.props.prop("transform/position").unwrap().value.as_vec3(); + [p[0], p[1]] + }; + let near = |p: [f64; 2], x: f64, y: f64| (p[0] - x).abs() < 0.01 && (p[1] - y).abs() < 0.01; + for extended in [true, false] { + h.state_mut().session.execute("view.snappingOptions", json!({"edgesExtended": extended})).unwrap(); + // Side by side: the box's left midpoint onto the target's right midpoint (550, 100). + let p = drop(&mut h, [60.5, 250.0], [553.0, 102.0]); + assert!(near(p, 590.0, 100.0), "left to right midpoint, extended {extended}: {p:?}"); + // Stacked: the box's top midpoint onto the target's bottom midpoint (500, 150). + let p = drop(&mut h, [100.0, 210.5], [502.0, 153.0]); + assert!(near(p, 500.0, 190.0), "top to bottom midpoint, extended {extended}: {p:?}"); + // Along the target, far from its centre point: the box's centre takes x from its centre + // line and y stays free. + let p = drop(&mut h, [100.0, 250.0], [503.0, 125.0]); + assert!((p[0] - 500.0).abs() < 0.01 && (p[1] - 125.0).abs() < 1.0, "centre line along the target, extended {extended}: {p:?}"); + // Beyond the target: its centre line reaches only with the option on. + let p = drop(&mut h, [100.0, 250.0], [503.0, 300.0]); + let snapped = (p[0] - 500.0).abs() < 0.01; + assert_eq!(snapped, extended, "centre line beyond the target, extended {extended}: {p:?}"); + } +} + /// With a shape layer selected the Rectangle tool draws a new group into its Contents (After /// Effects' behaviour); with nothing selected it draws a new shape layer (#227). #[test] From 34b3e13d547ce663a8f458f66da28666d0c61b3d Mon Sep 17 00:00:00 2001 From: Bar Borer Date: Mon, 2 Nov 2026 20:38:40 +0200 Subject: [PATCH 21/71] Timeline: box selection selects a keyframe as soon as it touches it (#2857) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/icons.rs | 23 ++++++ crates/ui-egui/src/panels/timeline.rs | 69 +++++++++++------ crates/ui-egui/src/panels/timeline/tests.rs | 53 +++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + .../ui-egui/tests/it/ui_timeline_marquee.rs | 76 +++++++++++++++++++ 5 files changed, 199 insertions(+), 23 deletions(-) create mode 100644 crates/ui-egui/tests/it/ui_timeline_marquee.rs diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index eec054fff..3f6a22445 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -745,6 +745,12 @@ pub fn keyframe( painter.extend(keyframe_shapes(center, size, left, right, fill)); } +/// The screen rect a [`keyframe`] glyph covers, read off the shapes it draws. +pub fn keyframe_bounds(center: Pos2, size: f32, left: effectcraft_engine::keyframe::KeyHalf, right: effectcraft_engine::keyframe::KeyHalf) -> Rect { + // Any opaque fill: a transparent shape would report no bounds. + egui::Shape::Vec(keyframe_shapes(center, size, left, right, Color32::WHITE)).visual_bounding_rect() +} + /// The halves of a [`keyframe`] glyph. Flat, without a dark outline (as in After Effects), so /// keys that overlap read as one shape instead of a tangle of borders (#284); the hairline in /// the fill colour closes the seam between the halves. @@ -812,6 +818,23 @@ mod tests { } } + /// Each half reaches as far as it is drawn, plus the hairline around it, and the halves are + /// independent (a key can come in linear and go out on hold). + #[test] + fn keyframe_bounds_follow_each_half() { + let c = egui::pos2(100.0, 50.0); + let r = 5.5; + // (half, how far it reaches sideways, how far up and down) + let extents = [(H::Linear, r, r), (H::Hold, r * 0.8, r * 0.8), (H::Bezier, r * 0.75, r), (H::Auto, r * 0.8, r * 0.8)]; + for (l, lx, ly) in extents { + for (rt, rx, ry) in extents { + let b = super::keyframe_bounds(c, 11.0, l, rt); + let want = egui::Rect::from_min_max(c - egui::vec2(lx, ly.max(ry)), c + egui::vec2(rx, ly.max(ry))).expand(0.25); + assert!(b.min.distance(want.min) < 1e-3 && b.max.distance(want.max) < 1e-3, "{l:?}/{rt:?}: {b:?}, want {want:?}"); + } + } + } + /// #284: keyframes have no dark border (overlapping keys looked messy): each half is filled /// and edged in the key's own colour. #[test] diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 246084cc1..5366f042c 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -13,7 +13,7 @@ use effectcraft_engine::project::{ }; use effectcraft_engine::render::EvalCtx; use effectcraft_engine::time::Tick; -use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Vec2, pos2, vec2}; use serde_json::json; use std::sync::Arc; @@ -4215,6 +4215,20 @@ fn key_drag_begin(app: &mut EffectcraftApp, g: &Rw, kh: &KeyHit<'_>) { } } +/// The size of a keyframe glyph and the radius of a roving key's dot. +const KEY_GLYPH_SIZE: f32 = 11.0; +const ROVING_KEY_RADIUS: f32 = 2.5; + +/// The rect a key's marker covers on screen (its hit rect is larger). +fn key_glyph_rect(k: &effectcraft_engine::keyframe::Keyframe, center: Pos2) -> Rect { + if k.roving { + Rect::from_center_size(center, Vec2::splat(ROVING_KEY_RADIUS * 2.0)) + } else { + let icon = k.icon(); + icons::keyframe_bounds(center, KEY_GLYPH_SIZE, icon.left, icon.right) + } +} + /// Paints one keyframe marker; returns its hit rect. fn key_paint(g: &Rw, rc: &RowCtx<'_>, k: &effectcraft_engine::keyframe::Keyframe, x: f32, ks: bool) -> Rect { let t = g.tl.t; @@ -4235,9 +4249,9 @@ fn key_paint(g: &Rw, rc: &RowCtx<'_>, k: &effectcraft_engine::keyframe::Keyframe let kcol = if k.muted { kcol.gamma_multiply(0.35) } else { kcol }; if k.roving { // Roving keys: a small dot (their time follows their neighbours). - gp.circle_filled(c, 2.5, kcol); + gp.circle_filled(c, ROVING_KEY_RADIUS, kcol); } else { - icons::keyframe(&gp, c, 11.0, icon.left, icon.right, kcol); + icons::keyframe(&gp, c, KEY_GLYPH_SIZE, icon.left, icon.right, kcol); } Rect::from_center_size(c, vec2(12.0, 14.0)) } @@ -4661,7 +4675,33 @@ fn key_drag_follow(app: &mut EffectcraftApp, g: &mut Rw) { } } -/// Releasing the key marquee selects the keys inside it. +/// The keys whose markers touch box `br`, in row order (the comp's). `top` is the y of the first +/// row, scrolled. +fn keys_in_box(comp: &Comp, rows: &[Row], tm: &TMap, top: f32, rh: f32, br: Rect) -> Vec { + let mut keys = vec![]; + let mut yy = top; + for row in rows { + let cy = yy + rh / 2.0; + yy += row_height(row, rh); + // No marker reaches a whole glyph from its row's center. + if let RowKind::Prop { uid } = row.kind + && cy >= br.min.y - KEY_GLYPH_SIZE + && cy <= br.max.y + KEY_GLYPH_SIZE + && let Some(l) = comp.layer(row.layer) + && let Some(pr) = l.props.find(uid) + { + for k in &pr.keys { + let x = tm.x(l.comp_time(k.time).seconds()); + if key_glyph_rect(k, pos2(x, cy)).intersects(br) { + keys.push(effectcraft_engine::KeyRef { layer: l.id, prop: uid, time: k.time }); + } + } + } + } + keys +} + +/// Releasing the key marquee selects the keys it touches. fn key_box_select(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw) { let tm = g.tl.tm; let rows_rect = g.tl.rows_rect; @@ -4675,25 +4715,8 @@ fn key_box_select(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw) { && let Some(br) = ctx.data(|d| d.get_temp::(egui::Id::unique("tl-box"))) { ctx.data_mut(|d| d.remove::(egui::Id::unique("tl-box"))); - let mut keys = vec![]; - let mut yy = rows_rect.min.y - app.ui.timeline.scroll_y; - for row in rows.iter() { - let cy = yy + rh / 2.0; - yy += row_height(row, rh); - if let RowKind::Prop { uid } = row.kind - && cy >= br.min.y - && cy <= br.max.y - && let Some(l) = comp.layer(row.layer) - && let Some(pr) = l.props.find(uid) - { - for k in &pr.keys { - let x = tm.x(l.comp_time(k.time).seconds()); - if x >= br.min.x && x <= br.max.x { - keys.push(json!({"layer": l.id.0, "prop": uid, "time": k.time.seconds()})); - } - } - } - } + let touched = keys_in_box(&comp, rows, &tm, rows_rect.min.y - app.ui.timeline.scroll_y, rh, br); + let keys: Vec = touched.iter().map(|k| json!({"layer": k.layer.0, "prop": k.prop, "time": k.time.seconds()})).collect(); let mods = ui.input(|i| i.modifiers); if keys.is_empty() && !(mods.shift || mods.command) { // A marquee that catches nothing leaves nothing selected (layers included). diff --git a/crates/ui-egui/src/panels/timeline/tests.rs b/crates/ui-egui/src/panels/timeline/tests.rs index ebcbfa8b9..a6e2a97a9 100644 --- a/crates/ui-egui/src/panels/timeline/tests.rs +++ b/crates/ui-egui/src/panels/timeline/tests.rs @@ -628,6 +628,59 @@ fn a_key_menu_edit_selects_the_clicked_key_before_applying_the_command() { assert_eq!(app.session.state.selected_keys[0].time, prop.keys[0].time); } +/// A key's marquee target is the marker it draws: the glyph of its two halves, or a roving key's +/// smaller dot whatever its halves are. +#[test] +fn a_keys_glyph_rect_is_what_it_draws() { + use effectcraft_engine::keyframe::{Interp, KeyHalf}; + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"name": "C", "width": 64, "height": 64, "duration": 2})).unwrap(); + let id = s.execute("layer.newSolid", json!({"name": "Box", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.addKey", json!({"layer": id, "path": "transform/opacity", "time": 0.5, "value": 0})).unwrap(); + let comp = s.active_comp().unwrap(); + let mut k = comp.layer(LayerId(id)).unwrap().props.prop("transform/opacity").unwrap().keys[0].clone(); + let c = pos2(100.0, 50.0); + k.in_interp = Interp::Hold; + k.out_interp = Interp::Linear; + assert_eq!(key_glyph_rect(&k, c), icons::keyframe_bounds(c, KEY_GLYPH_SIZE, KeyHalf::Hold, KeyHalf::Linear)); + k.roving = true; + assert_eq!(key_glyph_rect(&k, c), Rect::from_center_size(c, vec2(5.0, 5.0))); +} + +/// Keys come back in row order, and only the rows and keys the box touches. Rows taller than one +/// line (an expression editor) push the keys below them down, as they are drawn. +#[test] +fn keys_in_box_walks_the_rows_and_tests_each_glyph() { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"name": "C", "width": 64, "height": 64, "duration": 4})).unwrap(); + let mut layers = vec![]; + for name in ["A", "B"] { + let id = s.execute("layer.newSolid", json!({"name": name, "color": "#fff"})).unwrap()["layer"].as_u64().unwrap(); + for t in [1.0, 2.0] { + s.execute("prop.addKey", json!({"layer": id, "path": "transform/opacity", "time": t, "value": 50})).unwrap(); + } + layers.push(id); + } + let comp = s.active_comp().unwrap(); + let uid = |id: u64| comp.layer(LayerId(id)).unwrap().props.prop("transform/opacity").unwrap().uid; + let row = |id: u64, kind: RowKind| Row { layer: LayerId(id), depth: 1, kind }; + let rows = vec![ + row(layers[1], RowKind::Prop { uid: uid(layers[1]) }), + row(layers[1], RowKind::Expr { uid: uid(layers[1]), lines: 3 }), + row(layers[0], RowKind::Prop { uid: uid(layers[0]) }), + ]; + // 1 s = 100 pt. Rows are 20 pt: centers at y 10 and, past the 60 pt editor, 90. + let tm = TMap { x0: 0.0, start: 0.0, pps: 100.0 }; + let hit = |br: Rect| -> Vec<(u64, i64)> { keys_in_box(comp, &rows, &tm, 0.0, 20.0, br).iter().map(|k| (k.layer.0, k.time.0)).collect() }; + let (one, two) = (Tick::from_seconds_f64(1.0).0, Tick::from_seconds_f64(2.0).0); + let across = |x0: f32, x1: f32| Rect::from_min_max(pos2(x0, 0.0), pos2(x1, 200.0)); + assert_eq!(hit(across(0.0, 300.0)), [(layers[1], one), (layers[1], two), (layers[0], one), (layers[0], two)], "row order"); + assert_eq!(hit(across(0.0, 94.5)), [(layers[1], one), (layers[0], one)], "the box reaches the glyph's left point"); + assert!(hit(across(0.0, 93.5)).is_empty(), "the box stops short of the glyph"); + assert_eq!(hit(Rect::from_min_max(pos2(0.0, 95.5), pos2(300.0, 200.0))), [(layers[0], one), (layers[0], two)], "the box reaches the glyph's bottom point"); + assert!(hit(Rect::from_min_max(pos2(0.0, 97.0), pos2(300.0, 200.0))).is_empty(), "the box is below the glyph"); +} + #[test] fn layer_drop_target_skips_moving_layers_beyond_the_viewport() { let mut rows: Vec = (1..=6).map(|id| Row { layer: LayerId(id), depth: 0, kind: RowKind::Layer }).collect(); diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 2de7ebee7..4398b4f9d 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -91,6 +91,7 @@ mod ui_time_zoom_controls; mod ui_timeline_audio_levels; mod ui_timeline_effect_label; mod ui_timeline_group_select; +mod ui_timeline_marquee; mod ui_timeline_pan; mod ui_timeline_shapes; mod ui_timeline_switches; diff --git a/crates/ui-egui/tests/it/ui_timeline_marquee.rs b/crates/ui-egui/tests/it/ui_timeline_marquee.rs new file mode 100644 index 000000000..10cfd1a5d --- /dev/null +++ b/crates/ui-egui/tests/it/ui_timeline_marquee.rs @@ -0,0 +1,76 @@ +//! The key marquee in the Timeline (#2857): a box selects a key as soon as it touches any part of +//! the key's glyph, not when it reaches the key's center. + +use effectcraft_engine::Session; +use effectcraft_engine::project::LayerId; +use effectcraft_engine::time::Tick; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Modifiers, Pos2, Vec2, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +/// How far a linear key's diamond reaches from its center, without its hairline (0.25 pt). +const REACH: f32 = 5.5; + +/// A comp with one solid, its Opacity keys at 1 s and 2 s (linear) revealed. +fn harness() -> (Harness<'static, EffectcraftApp>, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Box", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap(); + let id = s.execute("layer.newSolid", json!({"name": "L", "color": "#e04020", "width": 80, "height": 80})).unwrap()["layer"].as_u64().unwrap(); + for (t, v) in [(1.0, 0.0), (2.0, 100.0)] { + s.execute("prop.addKey", json!({"layer": id, "path": "transform/opacity", "time": t, "value": v})).unwrap(); + } + s.execute("layer.select", json!({"layers": [id]})).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, id) +} + +/// The center of the key at `secs`: the middle of its hit rect, which is centered on the glyph. +fn key_center(h: &Harness<'_, EffectcraftApp>, id: u64, secs: f64) -> Pos2 { + let comp = h.state().session.active_comp().unwrap(); + let uid = comp.layer(LayerId(id)).unwrap().props.prop("transform/opacity").unwrap().uid; + let key = format!("timeline.keyAt.{uid}.{}", Tick::from_seconds_f64(secs).0); + let e = h.state().auto.find(&key).unwrap_or_else(|| panic!("no {key}")); + pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0) +} + +/// Draw a marquee from `from` to `to`, then report the selected keys' times in seconds. +fn marquee(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) -> Vec { + let modifiers = Modifiers::default(); + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + 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.run_steps(2); + h.state().session.state.selected_keys.iter().map(|k| k.time.seconds()).collect() +} + +#[test] +fn a_box_selects_a_key_it_grazes_from_any_side_and_not_one_it_stops_short_of() { + let (mut h, id) = harness(); + let c = key_center(&h, id, 1.0); + // Boxes end `gap` from the key's center, coming from the right, the left and below (the row + // above holds the layer bar, where a press would drag the layer). + let sides: [(&str, fn(f32) -> (Vec2, Vec2)); 3] = [ + ("right", |gap| (vec2(30.0, 5.0), vec2(gap, -5.0))), + ("left", |gap| (vec2(-30.0, 5.0), vec2(-gap, -5.0))), + ("below", |gap| (vec2(5.0, 40.0), vec2(-5.0, gap))), + ]; + for (side, gap_to_box) in sides { + let (from, to) = gap_to_box(REACH); + assert_eq!(marquee(&mut h, c + from, c + to), [1.0], "a box touching the glyph's {side} point selects the key"); + // Clear of the glyph and its hairline by 1 pt. + let (from, to) = gap_to_box(REACH + 0.25 + 1.0); + assert!(marquee(&mut h, c + from, c + to).is_empty(), "a box 1 pt short on the {side} misses it"); + assert!(h.state().session.state.selected_layers.is_empty(), "a box that catches nothing deselects (layers included)"); + } +} From f20dd38f94454deaae0b7921c9822932c4a21d3e Mon Sep 17 00:00:00 2001 From: Bar Borer Date: Mon, 2 Nov 2026 20:39:17 +0200 Subject: [PATCH 22/71] Puppet: a click on deformed-away space creates nothing (#2865) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/puppet.rs | 21 +++++++++ crates/effects/src/puppet/mesh.rs | 26 +++++++++++- crates/effects/src/puppet/tests.rs | 37 ++++++++++++++++ crates/engine/src/cmd/puppet.rs | 38 +++++++++++------ crates/engine/src/tests/paint.rs | 54 ++++++++++++++++++++++++ crates/ui-egui/src/panels/puppet_tool.rs | 12 +++++- crates/ui-egui/src/panels/viewer.rs | 8 +++- crates/ui-egui/tests/it/ui_viewer.rs | 36 ++++++++++++++++ 8 files changed, 215 insertions(+), 17 deletions(-) diff --git a/crates/effects/src/puppet.rs b/crates/effects/src/puppet.rs index 6ec2ce5ba..4db76c1f5 100644 --- a/crates/effects/src/puppet.rs +++ b/crates/effects/src/puppet.rs @@ -395,6 +395,27 @@ pub fn contains(mesh: &Mesh, deformed: &[[f64; 2]], p: [f64; 2]) -> bool { }) } +/// Whether `at` (effect space) is art a new mesh with this `expansion` could take: on the expanded +/// alpha itself, and not what a mesh of `params` already covers at rest, even if its deformation +/// has carried that art away. A mesh there would only rebuild the same outline: a full copy (#2865). +pub fn unmeshed_art_at(buf: &Buf, params: &Params, at: [f64; 2], expansion: f64) -> bool { + let (w, h) = (buf.img.width as usize, buf.img.height as usize); + let alpha: Vec = buf.img.data.iter().map(|p| p[3]).collect(); + let mut outline = mesh::outline_under(&alpha, w, h, buf.scale.max(1e-9), buf.offset, at, expansion); + if outline.is_empty() { + return false; + } + // As `triangulate` winds it. The simplified outline can cut a corner past `at`, so a mesh can + // miss the very art it was built from: the same outline is the same art. + if mesh::signed_area(&outline) < 0.0 { + outline.reverse(); + } + !parse(params).iter().any(|m| { + let mesh = mesh_for(buf, m); + mesh.outline == outline || contains(&mesh, &mesh.verts, at) + }) +} + #[inline] fn bary(a: [f64; 2], b: [f64; 2], c: [f64; 2], p: [f64; 2]) -> Option<[f64; 3]> { let d = (b[1] - c[1]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[1] - c[1]); diff --git a/crates/effects/src/puppet/mesh.rs b/crates/effects/src/puppet/mesh.rs index f01ee8f78..1046dfa6e 100644 --- a/crates/effects/src/puppet/mesh.rs +++ b/crates/effects/src/puppet/mesh.rs @@ -194,6 +194,11 @@ pub fn region(g: &Grid, seed: [f64; 2]) -> Option { Some(Grid { w, h, on: outside.iter().map(|o| !o).collect() }) } +/// The region under `seed` only when `seed` is on a cell: `region` without its nearest fallback. +pub fn region_under(g: &Grid, seed: [f64; 2]) -> Option { + if g.at(seed[0].floor() as i64, seed[1].floor() as i64) { region(g, seed) } else { None } +} + /// Trace the outer boundary of a hole-free region along pixel cracks: a closed polygon through /// pixel corners, region on the left when walking (counter-clockwise in y-down coordinates). pub fn trace(g: &Grid) -> Vec<[f64; 2]> { @@ -617,6 +622,25 @@ pub fn triangulate(outline: &[[f64; 2]], opts: &MeshOpts) -> Mesh { /// The outline of the alpha region under `seed` (buffer pixels → layer space via `scale` and /// `offset`), expanded by `expansion` layer pixels. pub fn outline(alpha: &[f32], w: usize, h: usize, scale: f64, offset: [f64; 2], seed: [f64; 2], expansion: f64) -> Vec<[f64; 2]> { + outline_of(alpha, w, h, scale, offset, seed, expansion, region) +} + +/// As `outline`, but empty unless `seed` is on the expanded alpha itself. +pub fn outline_under(alpha: &[f32], w: usize, h: usize, scale: f64, offset: [f64; 2], seed: [f64; 2], expansion: f64) -> Vec<[f64; 2]> { + outline_of(alpha, w, h, scale, offset, seed, expansion, region_under) +} + +#[allow(clippy::too_many_arguments)] +fn outline_of( + alpha: &[f32], + w: usize, + h: usize, + scale: f64, + offset: [f64; 2], + seed: [f64; 2], + expansion: f64, + pick: fn(&Grid, [f64; 2]) -> Option, +) -> Vec<[f64; 2]> { // Pad so expansion can grow past the buffer edge. let pad = (expansion.max(0.0) * scale).ceil() as usize + 2; let (pw, ph) = (w + 2 * pad, h + 2 * pad); @@ -628,7 +652,7 @@ pub fn outline(alpha: &[f32], w: usize, h: usize, scale: f64, offset: [f64; 2], } let g = expand(&Grid { w: pw, h: ph, on }, expansion * scale); let sp = [seed[0] * scale + offset[0] + pad as f64, seed[1] * scale + offset[1] + pad as f64]; - let Some(r) = region(&g, sp) else { return vec![] }; + let Some(r) = pick(&g, sp) else { return vec![] }; let c = trace(&r); let c = simplify(&c, 0.75); c.iter().map(|p| [(p[0] - pad as f64 - offset[0]) / scale, (p[1] - pad as f64 - offset[1]) / scale]).collect() diff --git a/crates/effects/src/puppet/tests.rs b/crates/effects/src/puppet/tests.rs index 10426d46c..56dbbf982 100644 --- a/crates/effects/src/puppet/tests.rs +++ b/crates/effects/src/puppet/tests.rs @@ -323,3 +323,40 @@ fn single_pin_on_a_traced_solid_does_not_rotate_it() { } } } + +/// #2865: only art no mesh has takes a new mesh, however close the rest is. +#[test] +fn unmeshed_art_is_strictly_under_the_point_and_not_already_meshed() { + // Two discs; the mesh is on the left one, at rest. + let (w, h) = (96usize, 48usize); + let (l, r) = (disc_alpha(w, h, [24.0, 24.0], 18.0), disc_alpha(w, h, [72.0, 24.0], 12.0)); + let mut img = Image::new(w as u32, h as u32); + for (i, p) in img.data.iter_mut().enumerate() { + *p = [1.0, 1.0, 1.0, l[i].max(r[i])]; + } + let buf = Buf { img, offset: [0.0; 2], scale: 1.0 }; + let params = flatten_params(&puppet_fx(&[(PinKind::Position, [24.0, 24.0], [24.0, 24.0])]), &mut |p| p.value.clone()); + let e = 3.0; + assert!(unmeshed_art_at(&buf, ¶ms, [72.0, 24.0], e), "the other disc"); + assert!(!unmeshed_art_at(&buf, ¶ms, [24.0, 24.0], e), "the meshed disc"); + assert!(!unmeshed_art_at(&buf, ¶ms, [48.0, 24.0], e), "between them: no nearest fallback"); + assert!(!unmeshed_art_at(&buf, ¶ms, [500.0, 24.0], e), "off the buffer"); + // Pixel corners of the meshed disc that simplifying the outline left just outside the triangles: + // on its art, off its mesh, and a mesh there would be the same mesh again. + let m = parse(¶ms).remove(0); + let mesh = mesh_for(&buf, &m); + let mut slivers = 0; + for y in 0..h { + for x in 0..48 { + for (dx, dy) in [(0.02, 0.02), (0.98, 0.02), (0.02, 0.98), (0.98, 0.98)] { + let p = [x as f64 + dx, y as f64 + dy]; + if contains(&mesh, &mesh.verts, p) || mesh::outline_under(&l, w, h, 1.0, [0.0; 2], p, e).is_empty() { + continue; + } + slivers += 1; + assert!(!unmeshed_art_at(&buf, ¶ms, p, e), "{p:?}"); + } + } + } + assert!(slivers > 0, "no point of the art falls outside the mesh: this test proves nothing"); +} diff --git a/crates/engine/src/cmd/puppet.rs b/crates/engine/src/cmd/puppet.rs index 1d080e773..a11a073b9 100644 --- a/crates/engine/src/cmd/puppet.rs +++ b/crates/engine/src/cmd/puppet.rs @@ -7,7 +7,8 @@ //! //! Pin positions are in effect space. A new pin is attached to the mesh under it: the click is //! mapped back through the current deformation to the rest mesh (its hidden rest position), and -//! Position / Advanced pins get a Position keyframe at the current time, as in After Effects. +//! Position / Advanced pins get a Position keyframe at the current time, as in After Effects. Off +//! every mesh a pin starts a new mesh only on art no mesh has yet; elsewhere nothing happens (#2865). use effectcraft_effects::puppet::{self, MeshOpts, PinKind}; use effectcraft_effects::{Buf, Params}; @@ -62,6 +63,19 @@ fn layer_time(s: &Session, cid: ItemId, lid: LayerId, t: Tick) -> Result { Ok(l.layer_time(t)) } +/// Whether a pin at `at` (effect space) goes on this mesh as `def` deforms it: on it or beside it. +fn on_mesh(mesh: &puppet::Mesh, def: &[[f64; 2]], at: [f64; 2]) -> bool { + !mesh.tris.is_empty() && (puppet::contains(mesh, def, at) || def.iter().map(|v| (v[0] - at[0]).hypot(v[1] - at[1])).fold(f64::MAX, f64::min) < 12.0) +} + +/// Whether `puppet.addPin` at `at` (effect space) places a pin on a layer with a Puppet effect: on +/// a mesh, on art no mesh has, or anywhere when there is no mesh yet (the first pin). +pub fn places_pin(s: &Session, cid: ItemId, lid: LayerId, at: [f64; 2], t: Tick) -> bool { + let Some((buf, params)) = find_puppet(s, cid, lid).and_then(|fu| puppet_eval(s, cid, lid, fu, t)) else { return true }; + let meshes = puppet::overlay(&buf, ¶ms); + meshes.is_empty() || meshes.iter().any(|(_, mesh, def, _)| on_mesh(mesh, def, at)) || puppet::unmeshed_art_at(&buf, ¶ms, at, s.state.puppet.expansion) +} + fn add_pin(s: &mut Session, p: &Value) -> Result { let cmd = "puppet.addPin"; let (cid, lid) = layer_p(s, p, cmd)?; @@ -78,14 +92,17 @@ fn add_pin(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "the Puppet tools work on footage, solid, text, shape and precomp layers")); } } + let opts = MeshOpts { + density: f_p(p, "density").unwrap_or(s.state.puppet.density), + expansion: f_p(p, "expansion").unwrap_or(s.state.puppet.expansion), + triangles: f_p(p, "triangles").unwrap_or(s.state.puppet.triangles), + }; // Which mesh: the one under the click (through the current deformation), else a new one. let fx_uid = find_puppet(s, cid, lid); let want_mesh = p.get("mesh").and_then(Value::as_u64); - let new_mesh = p.get("newMesh").and_then(Value::as_bool).unwrap_or(false); // (mesh, the pin's rest point, where it is now) let mut target: Option<(Uid, [f64; 2], [f64; 2])> = None; if let Some(fu) = fx_uid - && !new_mesh && let Some((buf, params)) = puppet_eval(s, cid, lid, fu, t) { let meshes = puppet::overlay(&buf, ¶ms); @@ -93,9 +110,7 @@ fn add_pin(s: &mut Session, p: &Value) -> Result { if want_mesh.is_some_and(|w| w != *uid) || mesh.tris.is_empty() { continue; } - let inside = puppet::contains(mesh, def, at); - let near = def.iter().map(|v| (v[0] - at[0]).hypot(v[1] - at[1])).fold(f64::MAX, f64::min) < 12.0; - if inside || near || want_mesh.is_some() { + if on_mesh(mesh, def, at) || want_mesh.is_some() { let rest = puppet::unmap(mesh, def, at); // A moving pin holds its nearest mesh vertex at `position + (vertex - rest)` // (puppet::solve). Choose the position that keeps that vertex where it is now, so @@ -120,12 +135,11 @@ fn add_pin(s: &mut Session, p: &Value) -> Result { { return Err(bad(cmd, format!("no mesh {w}"))); } + // Nothing to pin here: empty space, or art a mesh has already carried away (#2865). + if target.is_none() && !meshes.is_empty() && !puppet::unmeshed_art_at(&buf, ¶ms, at, opts.expansion) { + return Ok(json!({"pin": null, "changed": false})); + } } - let opts = MeshOpts { - density: f_p(p, "density").unwrap_or(s.state.puppet.density), - expansion: f_p(p, "expansion").unwrap_or(s.state.puppet.expansion), - triangles: f_p(p, "triangles").unwrap_or(s.state.puppet.triangles), - }; let size = { let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let l = comp.layer(lid).ok_or(EngineError::NoComp)?; @@ -652,7 +666,7 @@ pub fn specs() -> Vec { "Add Puppet Pin", [], None, - "{layer?, kind?: position|advanced|bend|starch|overlap, position: [x,y] (effect space), time? (s) | frame?, mesh?: uid, newMesh?, density?, expansion?, triangles?}", + "{layer?, kind?: position|advanced|bend|starch|overlap, position: [x,y] (effect space), time? (s) | frame?, mesh?: uid, density?, expansion?, triangles?}", has_comp, add_pin ), diff --git a/crates/engine/src/tests/paint.rs b/crates/engine/src/tests/paint.rs index 3659ce0c4..ccb5b3dfe 100644 --- a/crates/engine/src/tests/paint.rs +++ b/crates/engine/src/tests/paint.rs @@ -225,6 +225,60 @@ fn a_pin_added_to_a_bent_mesh_moves_nothing() { } } +fn mesh_count(s: &mut Session, id: u64) -> usize { + s.execute("puppet.info", json!({"layer": id})).unwrap()["meshes"].as_array().unwrap().len() +} + +/// #2865: where the deformed layer is empty a click makes nothing: no copy of the art it has moved +/// off, no undo step, no error. +#[test] +fn a_pin_where_the_deformed_layer_is_empty_creates_nothing() { + let (mut s, id) = setup(); + s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap(); + s.execute("puppet.addPin", json!({"layer": id, "position": [90, 30]})).unwrap(); + s.set_time(Tick::from_seconds_f64(1.0)); + s.execute("puppet.movePin", json!({"layer": id, "pin": "Puppet Pin 1", "position": [10, 70]})).unwrap(); + s.execute("puppet.movePin", json!({"layer": id, "pin": "Puppet Pin 2", "position": [90, 70]})).unwrap(); + let (cid, t) = (s.active_comp_id().unwrap(), Tick::from_seconds_f64(1.0)); + let before = s.render(cid, t, RenderOpts::default()); + let revision = s.revision; + let lid = effectcraft_project::LayerId(id); + // The solid's top, which the pins have carried 40 px down; and well off it. + for at in [[50, 10], [300, 300]] { + assert!(!crate::commands::puppet::places_pin(&s, cid, lid, [at[0] as f64, at[1] as f64], t), "{at:?}"); + let r = s.execute("puppet.addPin", json!({"layer": id, "position": at})).unwrap(); + assert_eq!(r["pin"], Value::Null, "{at:?}"); + assert_eq!(s.revision, revision, "{at:?} changed the project"); + } + assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (1, 2)); + assert_eq!(s.render(cid, t, RenderOpts::default()), before); + // On the deformed shape a pin still goes on the mesh. + assert!(crate::commands::puppet::places_pin(&s, cid, lid, [50.0, 70.0], t)); + assert!(s.execute("puppet.addPin", json!({"layer": id, "position": [50, 70]})).unwrap()["pin"].is_u64()); + assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (1, 3)); +} + +/// #2865: art no mesh has still takes its own mesh; the gap beside it takes none. The first pin of +/// a layer is as ever: it finds the art nearest the click. +#[test] +fn only_unmeshed_art_takes_a_new_mesh() { + let (mut s, id) = setup(); + for x in [[0, 30], [60, 100]] { + s.execute("mask.new", json!({"layer": id, "vertices": [[x[0], 0], [x[1], 0], [x[1], 60], [x[0], 60]], "closed": true})).unwrap(); + } + s.execute("puppet.addPin", json!({"layer": id, "position": [46, 30]})).unwrap(); + assert_eq!(mesh_count(&mut s, id), 1, "the first pin"); + s.undo(); + s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap(); + // In the gap, past reach of the left piece's mesh (Expansion takes it to x 33). + assert_eq!(s.execute("puppet.addPin", json!({"layer": id, "position": [46, 30]})).unwrap()["pin"], Value::Null); + assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (1, 1)); + s.execute("puppet.addPin", json!({"layer": id, "position": [80, 30]})).unwrap(); + assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (2, 2), "the right piece gets its own mesh"); + s.execute("puppet.addPin", json!({"layer": id, "position": [80, 50]})).unwrap(); + assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (2, 3), "and the next pin goes on it"); +} + #[test] fn puppet_pin_kinds_and_mesh_options() { let (mut s, id) = setup(); diff --git a/crates/ui-egui/src/panels/puppet_tool.rs b/crates/ui-egui/src/panels/puppet_tool.rs index 968fafaf2..ac2186bf8 100644 --- a/crates/ui-egui/src/panels/puppet_tool.rs +++ b/crates/ui-egui/src/panels/puppet_tool.rs @@ -84,8 +84,8 @@ pub fn overlay(app: &EffectcraftApp, ctx: &egui::Context, cid: ItemId, layer: &L /// The layer a Puppet tool press at comp point `cpt` works on, of the selected `layers` with a /// Puppet effect (stack order): the one whose mesh, as deformed now, is under it (pins go on the -/// deformed shape, also where it reaches past the layer's own bounds), else the one whose art is -/// (a new mesh there). Never a layer without a Puppet effect: the tools stay on the layers being +/// deformed shape, also where it reaches past the layer's own bounds), else the one `on_art` takes +/// it (see `takes_pin`). Never a layer without a Puppet effect: the tools stay on the layers being /// rigged, as in After Effects (#284). pub fn target(ectx: &EvalCtx, layers: &[(&Layer, Overlay)], cpt: [f64; 2], on_art: impl Fn(&Layer) -> bool) -> Option { let on_mesh = |l: &Layer, ov: &Overlay| { @@ -96,6 +96,14 @@ pub fn target(ectx: &EvalCtx, layers: &[(&Layer, Overlay)], cpt: [f64; 2], on_ar layers.iter().find(|(l, ov)| on_mesh(l, ov)).or_else(|| layers.iter().find(|(l, _)| on_art(l))).map(|(l, _)| l.id) } +/// Whether a press at comp point `cpt` places a pin on `layer`. Where it would not (the deformed +/// layer is empty there) the press is on nothing: a click does nothing, a drag is a marquee (#2865). +pub fn takes_pin(app: &EffectcraftApp, ectx: &EvalCtx, layer: &Layer, cpt: [f64; 2]) -> bool { + let Some(inv) = fx2c(ectx, layer).inverse() else { return false }; + let p = inv.apply(gv2(cpt[0], cpt[1])); + effectcraft_engine::commands::puppet::places_pin(&app.session, ectx.comp_id, layer.id, [p.x, p.y], ectx.time) +} + /// Keep a pin's keyframed Position in view in the Timeline after placing, moving or recording the /// pin in the viewer (#273). pub fn reveal_pin(app: &mut EffectcraftApp, layer: LayerId, pin: u64) { diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index bb7b2d075..5b42488cb 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1092,7 +1092,9 @@ fn phase_dragstart(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, if puppet_layers.is_empty() { return pick(app, ectx, cpt, false); } - super::puppet_tool::target(ectx, &puppet_layers, cpt, |l| layers_at(ectx, cpt).any(|h| h.id == l.id)) + super::puppet_tool::target(ectx, &puppet_layers, cpt, |l| { + layers_at(ectx, cpt).any(|h| h.id == l.id) && super::puppet_tool::takes_pin(app, ectx, l, cpt) + }) }; if resp.drag_started() && !text_owns @@ -1680,7 +1682,9 @@ fn phase_clicked(app: &mut EffectcraftApp, ectx: &EvalCtx, resp: &egui::Response if puppet_layers.is_empty() { return pick(app, ectx, cpt, false); } - super::puppet_tool::target(ectx, &puppet_layers, cpt, |l| layers_at(ectx, cpt).any(|h| h.id == l.id)) + super::puppet_tool::target(ectx, &puppet_layers, cpt, |l| { + layers_at(ectx, cpt).any(|h| h.id == l.id) && super::puppet_tool::takes_pin(app, ectx, l, cpt) + }) }; if resp.clicked() && !text_owns diff --git a/crates/ui-egui/tests/it/ui_viewer.rs b/crates/ui-egui/tests/it/ui_viewer.rs index f0086d28a..4a712dbe5 100644 --- a/crates/ui-egui/tests/it/ui_viewer.rs +++ b/crates/ui-egui/tests/it/ui_viewer.rs @@ -1657,6 +1657,42 @@ fn puppet_pins_go_on_the_deformed_mesh_and_the_tool_stays_on_the_rigged_layer() assert!(!rigged(&h, plate)); } +/// #2865: a press where the deformed layer is empty, though on its original bounds, adds nothing: +/// a click leaves the rig alone and a drag from there is a pin marquee. +#[test] +fn puppet_press_where_the_deformed_layer_is_empty_is_a_marquee_not_a_copy() { + let mut h = harness(); + let s = &mut h.state_mut().session; + s.execute("edit.clear", json!({"layers": ["Plate"]})).unwrap(); + s.execute("layer.select", json!({"layers": ["Box"]})).unwrap(); + let id = s.state.selected_layers[0].0; + let a = s.execute("puppet.addPin", json!({"layer": id, "position": [10, 40]})).unwrap()["pin"].as_u64().unwrap(); + let b = s.execute("puppet.addPin", json!({"layer": id, "position": [70, 40]})).unwrap()["pin"].as_u64().unwrap(); + // Carry the Box 60 px right: its left part (comp x 280..340) is now empty. + s.execute("puppet.movePin", json!({"layer": id, "pin": a, "position": [70, 40]})).unwrap(); + s.execute("puppet.movePin", json!({"layer": id, "pin": b, "position": [130, 40]})).unwrap(); + h.state_mut().ui.tool = Tool::Puppet; + h.run_steps(3); + let rig = |h: &mut Harness<'_, EffectcraftApp>| { + let info = h.state_mut().session.execute("puppet.info", json!({"layer": id})).unwrap(); + let meshes = info["meshes"].as_array().unwrap().clone(); + (meshes.len(), meshes.iter().map(|m| m["pins"].as_array().unwrap().len()).sum::()) + }; + let at = screen(&h, [285.0, 180.0]); + click(&mut h, at); + assert_eq!(rig(&mut h), (1, 2), "the click made nothing"); + let comp = rect(&h, "viewer.comp"); + drag(&mut h, at, comp.max - vec2(4.0, 4.0)); + assert_eq!(rig(&mut h), (1, 2), "nor did the drag"); + let mut sel: Vec = h.state().session.state.selected_props.iter().map(|(_, u)| *u).collect(); + sel.sort(); + assert_eq!(sel, [a, b], "the drag's marquee selected the pins"); + // On the deformed shape a click still pins it. + let at = screen(&h, [380.0, 180.0]); + click(&mut h, at); + assert_eq!(rig(&mut h), (1, 3)); +} + /// Filled circles painted this frame (flattening nested shape lists). fn circles(h: &Harness<'_, EffectcraftApp>) -> Vec { fn walk(s: &egui::Shape, out: &mut Vec) { From 53dc9032c5388a9ac5f2b8b302ae8c1f729c830c Mon Sep 17 00:00:00 2001 From: Bar Borer Date: Mon, 2 Nov 2026 20:38:05 +0200 Subject: [PATCH 23/71] Viewer: the Rotation tool measures its angle in parent space (#2870) Co-Authored-By: Claude Opus 5.5 --- crates/render/src/eval.rs | 9 +- crates/ui-egui/src/panels/viewer.rs | 49 +++--- crates/ui-egui/src/panels/viewer/geometry.rs | 54 +++++++ crates/ui-egui/src/panels/viewer/gesture.rs | 12 +- crates/ui-egui/src/panels/viewer/tests.rs | 49 ++++++ crates/ui-egui/src/panels/viewer_overlays.rs | 13 +- crates/ui-egui/tests/it/ui_viewer.rs | 149 +++++++++++++++++++ 7 files changed, 289 insertions(+), 46 deletions(-) diff --git a/crates/render/src/eval.rs b/crates/render/src/eval.rs index 81fe45d6c..1aaf02d76 100644 --- a/crates/render/src/eval.rs +++ b/crates/render/src/eval.rs @@ -214,7 +214,14 @@ impl<'a> EvalCtx<'a> { /// Layer space → comp (world) space, including parents. pub fn world_matrix(&self, layer: &Layer) -> Mat4 { - let mut m = self.local_matrix_par(layer, self.par_ratio(layer)); + self.parent_matrix(layer) * self.local_matrix_par(layer, self.par_ratio(layer)) + } + + /// The space a layer's Position and Rotation live in → comp (world) space: its parents' + /// transforms, without their pixel aspect, which children don't inherit (identity without a + /// parent). + pub fn parent_matrix(&self, layer: &Layer) -> Mat4 { + let mut m = Mat4::IDENTITY; let mut cur = layer.parent; let mut guard = 0; while let Some(pid) = cur { diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index bb7b2d075..52dd47567 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -565,10 +565,8 @@ fn phase_layers(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: continue; } // Cameras, lights, empty layers: just the anchor. - if let Some(tr) = l.transform() { - let pos = ectx.v3(l, tr, "position", [0.0; 3]); - let s = map.to_screen([pos[0], pos[1]]); - painter.circle_stroke(s, 6.0, Stroke::new(1.0, col)); + if let Some(c) = anchor_in_comp(ectx, l) { + painter.circle_stroke(map.to_screen(c), 6.0, Stroke::new(1.0, col)); } continue; }; @@ -1126,22 +1124,15 @@ fn phase_dragstart(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, } Tool::MaskFeather => ov::begin_feather(app, ectx, &paths, &map, press).map(Gesture::Overlay), Tool::Rotate => pick(app, ectx, cpt, mods.shift).map(|l| { - let others = selected_others(app, comp, l).into_iter().filter_map(|o| Some((o.id, ectx.f(o, o.transform()?, "rotation", 0.0)))).collect(); let layer = comp.layer(l).cloned(); - let center = layer - .as_ref() - .and_then(|l| l.transform().map(|tr| ectx.v3(l, tr, "position", [0.0; 3]))) - .map(|p| map.to_screen([p[0], p[1]])) - .unwrap_or(pos); + let sign = layer.as_ref().map_or(1.0, |l| rotation_sign(ectx, l)); + let others = selected_others(app, comp, l) + .into_iter() + .filter_map(|o| Some((o.id, ectx.f(o, o.transform()?, "rotation", 0.0), rotation_sign(ectx, o) * sign))) + .collect(); + let (c2p, pivot) = layer.as_ref().and_then(|l| rotate_frame(ectx, l)).unwrap_or((Mat3::IDENTITY, cpt)); let rot = layer.as_ref().and_then(|l| l.transform().map(|tr| ectx.f(l, tr, "rotation", 0.0))).unwrap_or(0.0); - Gesture::Rotate { - layer: l, - center, - last_angle: ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees(), - turned: 0.0, - start_rot: rot, - others, - } + Gesture::Rotate { layer: l, c2p, pivot, last_angle: pointer_angle(&c2p, pivot, cpt), turned: 0.0, start_rot: rot, others } }), Tool::PanBehind => pick(app, ectx, cpt, false).and_then(|l| { let layer = comp.layer(l)?; @@ -1399,18 +1390,18 @@ fn phase_drag(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/scale", "value": v, "merge": merge})); } } - Gesture::Rotate { layer, center, last_angle, turned, start_rot, others } => { + Gesture::Rotate { layer, c2p, pivot, 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 ang = pointer_angle(&c2p, pivot, cpt); 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() })); + ui.data_mut(|dd| dd.insert_temp(gid, Gesture::Rotate { layer, c2p, pivot, 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 _ = - app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/rotation", "value": r0 + (r - start_rot), "merge": merge})); + for (lid, r0, sign) in others { + let v = r0 + sign * (r - start_rot); + let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/rotation", "value": v, "merge": merge})); } } Gesture::Anchor { layer, start_anchor, start_pos, start, inv, l2p, own, anchor_only } => { @@ -1901,13 +1892,13 @@ mod view; use frame::{empty_state, rect_of, show_frame}; pub(crate) use frame::{extended_region, minify_factor, snap_to_pixels, styled_line, zoom_sampler, zoom_texture_options}; +use geometry::{ + anchor_in_comp, cam_state, drag_ratio, handle_axes, handle_cursor, handle_point, one_axis, pointer_angle, project_segment, quad_intersects_box, + rotate_frame, rotate_value, rotation_sign, selected_others, snap_handle, turn_delta, +}; #[cfg(test)] use geometry::{box_corners, project_clipped}; -use geometry::{ - cam_state, drag_ratio, handle_axes, handle_cursor, handle_point, one_axis, project_segment, quad_intersects_box, rotate_value, selected_others, - snap_handle, turn_delta, -}; -pub(crate) use geometry::{from_effect_space, fx2c, l2c, layer_outline, layer_quad}; +pub(crate) use geometry::{from_effect_space, fx2c, l2c, layer_outline, layer_quad, parent_to_comp}; use gesture::{Gesture, HANDLE, ROTO_HINT, pen_id, roi_drag, roi_handles, vertex_grab_radius}; use shapes::{create_shape, draw_shape_ghost, shape_drag}; pub(crate) use shapes::{layer_at, layer_at_including_locked, layers_at, pick, selectable_layers}; diff --git a/crates/ui-egui/src/panels/viewer/geometry.rs b/crates/ui-egui/src/panels/viewer/geometry.rs index 847332bcf..39809e247 100644 --- a/crates/ui-egui/src/panels/viewer/geometry.rs +++ b/crates/ui-egui/src/panels/viewer/geometry.rs @@ -120,6 +120,60 @@ pub(crate) fn layer_outline(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> { project_clipped(&l2c(ctx, layer).0, &box_corners(b)) } +/// Parent space → comp matrix of a 2D layer at the context time. +pub(crate) fn parent_to_comp(ctx: &EvalCtx, layer: &Layer) -> Mat3 { + let w = ctx.parent_matrix(layer).0; + Mat3([[w[0][0], w[0][1], w[0][3]], [w[1][0], w[1][1], w[1][3]], [0.0, 0.0, 1.0]]) +} + +/// Where a layer's anchor point shows in the viewer (comp pixels), through the current 3D view for +/// a 3D layer. None without a transform, or when it is at or behind the camera. +pub(super) fn anchor_in_comp(ctx: &EvalCtx, layer: &Layer) -> Option<[f64; 2]> { + let a = ctx.v3(layer, layer.transform()?, "anchor", [0.0; 3]); + let p = ctx.world_matrix(layer).apply(a.into()); + if layer.is_3d() { + let pc = cam_state(ctx).projection(ctx.comp.width as f64, ctx.comp.height as f64); + return project_segment(&pc, p, p).map(|[q, _]| q); + } + Some([p.x, p.y]) +} + +/// −1 when a layer's Rotation turns it counter-clockwise on screen: its parent is flipped or, for +/// a 3D layer, the view sees its parent's XY plane from behind. Otherwise 1. +pub(super) fn rotation_sign(ctx: &EvalCtx, layer: &Layer) -> f64 { + let plane = match layer.transform() { + Some(tr) if layer.is_3d() => { + let at = effectcraft_engine::geom::Mat4::translate(ctx.position(layer, tr).into()); + (cam_state(ctx).projection(ctx.comp.width as f64, ctx.comp.height as f64) * ctx.parent_matrix(layer) * at).plane_to_mat3() + } + _ => parent_to_comp(ctx, layer), + }; + if plane.determinant() < 0.0 { -1.0 } else { 1.0 } +} + +/// The space the Rotation tool measures the pointer's angle in: comp → that space, and the layer's +/// pivot there. A 2D layer turns in parent space about its Position, so it follows the pointer +/// through any parent (a flip or skew needs no special case). A 3D layer, or a parent that collapses +/// the plane, measures on screen about the anchor, mirrored when the turn is reversed. +pub(super) fn rotate_frame(ctx: &EvalCtx, layer: &Layer) -> Option<(Mat3, [f64; 2])> { + if !layer.is_3d() + && let Some(c2p) = parent_to_comp(ctx, layer).inverse() + { + let p = ctx.position(layer, layer.transform()?); + return Some((c2p, [p[0], p[1]])); + } + let a = anchor_in_comp(ctx, layer)?; + let c2p = Mat3::scale(gv2(rotation_sign(ctx, layer), 1.0)); + let p = c2p.apply(gv2(a[0], a[1])); + Some((c2p, [p.x, p.y])) +} + +/// The pointer's angle (degrees, as atan2 gives it) about `pivot`, in the space of [`rotate_frame`]. +pub(super) fn pointer_angle(c2p: &Mat3, pivot: [f64; 2], cpt: [f64; 2]) -> f64 { + let p = c2p.apply(gv2(cpt[0], cpt[1])); + (p.y - pivot[1]).atan2(p.x - pivot[0]).to_degrees() +} + /// Which axes a bounding-box handle scales: corners (0–3) both; the top and bottom edges (4, 6) /// y; the right and left edges (5, 7) x. pub(super) fn handle_axes(handle: usize) -> [bool; 2] { diff --git a/crates/ui-egui/src/panels/viewer/gesture.rs b/crates/ui-egui/src/panels/viewer/gesture.rs index db024e720..797828618 100644 --- a/crates/ui-egui/src/panels/viewer/gesture.rs +++ b/crates/ui-egui/src/panels/viewer/gesture.rs @@ -34,15 +34,19 @@ pub(super) enum Gesture { }, Rotate { layer: LayerId, - center: Pos2, - /// The pointer's angle about the centre on the last frame (degrees, as atan2 gives it). + /// Comp → the space the pointer's angle is measured in, and the pivot there (see + /// `rotate_frame`): comp pixels, not screen points, so zooming mid-drag doesn't turn it. + c2p: Mat3, + pivot: [f64; 2], + /// The pointer's angle about the pivot 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)>, + /// The other selected layers, their rotations and turn direction relative to this layer + /// (−1 when one parent is flipped): they turn by the same angle on screen. + others: Vec<(LayerId, f64, f64)>, }, Anchor { layer: LayerId, diff --git a/crates/ui-egui/src/panels/viewer/tests.rs b/crates/ui-egui/src/panels/viewer/tests.rs index da56dc008..591e02f86 100644 --- a/crates/ui-egui/src/panels/viewer/tests.rs +++ b/crates/ui-egui/src/panels/viewer/tests.rs @@ -140,6 +140,55 @@ fn gizmo_segments_clip_at_the_near_plane() { assert!(project_segment(&pc, vec3(0.0, 0.0, -200.0), vec3(0.0, 0.0, -300.0)).is_none()); } +/// A 640×360 comp whose Box (anchor 40,40) is a child of Parent (anchor 50,50) at (100, 60). +fn parented_session(parent: serde_json::Value) -> (effectcraft_engine::Session, u64) { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 1})).unwrap(); + let id = |v: serde_json::Value| v["layer"].as_u64().unwrap(); + let p = id(s.execute("layer.newSolid", json!({"name": "Parent", "color": "#000000", "width": 100, "height": 100})).unwrap()); + let c = id(s.execute("layer.newSolid", json!({"name": "Box", "color": "#000000", "width": 80, "height": 80})).unwrap()); + for (path, value) in parent.as_object().unwrap() { + s.execute("prop.set", json!({"layer": p, "path": format!("transform/{path}"), "value": value})).unwrap(); + } + s.execute("layer.setParent", json!({"layers": [c], "parent": p, "compensate": false})).unwrap(); + s.execute("prop.set", json!({"layer": c, "path": "transform/position", "value": [100.0, 60.0]})).unwrap(); + (s, c) +} + +fn with_ctx(s: &effectcraft_engine::Session, f: impl FnOnce(&EvalCtx) -> T) -> T { + let comp = s.active_comp().unwrap(); + f(&EvalCtx::new(&s.project, comp.id, comp, Tick::ZERO)) +} + +/// #2870: a parent scaled to nothing has no inverse: the Rotation tool measures on screen about +/// where the anchor collapsed to, with no panic and no NaN. +#[test] +fn rotate_frame_falls_back_on_screen_when_the_parent_collapses() { + let (s, c) = parented_session(json!({"position": [400.0, 200.0], "scale": [0.0, 100.0]})); + with_ctx(&s, |ctx| { + let l = ctx.comp.layer(LayerId(c)).unwrap(); + let (c2p, pivot) = rotate_frame(ctx, l).unwrap(); + assert_eq!(c2p, Mat3::IDENTITY); + assert_eq!(pivot, [400.0, 210.0]); + assert_eq!(pointer_angle(&c2p, pivot, [400.0, 310.0]), 90.0); + }); +} + +/// #2870: the marker and pivot of a child follow its parent, through the camera for a 3D layer +/// and none behind it. +#[test] +fn anchor_in_comp_follows_the_parent_and_the_camera() { + let (mut s, c) = parented_session(json!({"position": [400.0, 200.0]})); + with_ctx(&s, |ctx| assert_eq!(anchor_in_comp(ctx, ctx.comp.layer(LayerId(c)).unwrap()), Some([450.0, 210.0]))); + s.execute("layer.setSwitch", json!({"layers": [c], "switch": "threeD", "value": true})).unwrap(); + with_ctx(&s, |ctx| { + let a = anchor_in_comp(ctx, ctx.comp.layer(LayerId(c)).unwrap()).unwrap(); + assert!((a[0] - 450.0).abs() < 1e-6 && (a[1] - 210.0).abs() < 1e-6, "{a:?}"); + }); + s.execute("prop.set", json!({"layer": c, "path": "transform/position", "value": [100.0, 60.0, -1e6]})).unwrap(); + with_ctx(&s, |ctx| assert_eq!(anchor_in_comp(ctx, ctx.comp.layer(LayerId(c)).unwrap()), None)); +} + /// #999: a vertex drag pans the view only near or past the edge, toward it, faster further out. #[test] fn edge_pan_speed_moves_toward_the_edge() { diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs index 00c07425d..ba16abcc8 100644 --- a/crates/ui-egui/src/panels/viewer_overlays.rs +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -369,17 +369,6 @@ pub(crate) fn motion_frame_at(frames: &[PathFrame], hits: &[KeyHit], pos: Pos2) (!hits.iter().any(|h| h.tangent.is_none() && h.layer == near.layer && h.prop == near.prop && h.time == near.time)).then_some(*near) } -/// Parent space → comp matrix of a 2D layer at the context time. -pub(crate) fn parent_to_comp(ectx: &EvalCtx, l: &Layer) -> Mat3 { - match l.parent.and_then(|p| ectx.comp.layer(p)) { - Some(p) => { - let w = ectx.world_matrix(p).0; - Mat3([[w[0][0], w[0][1], w[0][3]], [w[1][0], w[1][1], w[1][3]], [0.0, 0.0, 1.0]]) - } - None => Mat3::IDENTITY, - } -} - /// Draw the position motion path of a selected 2D layer: frame dots along the path, keyframe /// squares (filled when selected) and spatial Bezier tangent handles. Works through parents. #[allow(clippy::too_many_arguments)] @@ -410,7 +399,7 @@ pub(crate) fn motion_path( Some((x, y)) => separated_value(x, y, t), None => pos.value_at(t).as_vec3(), }; - let p2c = parent_to_comp(ectx, l); + let p2c = super::viewer::parent_to_comp(ectx, l); let to_scr = |v: [f64; 3]| { let c = p2c.apply(gv2(v[0], v[1])); map.to_screen([c.x, c.y]) diff --git a/crates/ui-egui/tests/it/ui_viewer.rs b/crates/ui-egui/tests/it/ui_viewer.rs index f0086d28a..cfd44dc1a 100644 --- a/crates/ui-egui/tests/it/ui_viewer.rs +++ b/crates/ui-egui/tests/it/ui_viewer.rs @@ -2890,6 +2890,155 @@ fn rotate_tool_counts_revolutions_both_ways() { assert_eq!(h.state().session.history.undo.len(), undo_before + 1, "one drag, one undo step"); } +/// A harness whose Box is a child of Plate (anchor 320,180) with `plate` set on Plate first, at +/// parent-space position (200, 120). Returns the two layers' ids. +fn rotate_child_harness(plate: serde_json::Value) -> (Harness<'static, EffectcraftApp>, u64, u64) { + let mut h = harness(); + let (parent, child) = (layer_id(&h, "Plate"), layer_id(&h, "Box")); + let s = &mut h.state_mut().session; + for (path, value) in plate.as_object().unwrap() { + s.execute("prop.set", json!({"layer": parent, "path": format!("transform/{path}"), "value": value})).unwrap(); + } + s.execute("layer.setParent", json!({"layers": [child], "parent": parent, "compensate": false})).unwrap(); + s.execute("prop.set", json!({"layer": child, "path": "transform/position", "value": [200.0, 120.0]})).unwrap(); + s.execute("layer.select", json!({"layers": [child]})).unwrap(); + h.state_mut().ui.tool = Tool::Rotate; + h.run_steps(2); + (h, parent, child) +} + +/// Goes round comp point `pivot` with the Rotation tool, 10° clockwise a frame for 400°, starting +/// inside the 80 px box to its right. The rotations of `layers` on each frame, from the pointer's +/// first move on. +fn rotate_round(h: &mut Harness<'static, EffectcraftApp>, pivot: [f32; 2], layers: &[u64]) -> Vec> { + let center = screen(h, pivot); + let r = (screen(h, [pivot[0] + 30.0, pivot[1]]) - center).x; + let at = |deg: f64| center + vec2((deg.to_radians().cos() as f32) * r, (deg.to_radians().sin() as f32) * r); + 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![]; + for i in 0..=40 { + h.input_mut().events.push(Event::PointerMoved(at(i as f64 * 10.0))); + h.step(); + let comp = h.state().session.active_comp().unwrap(); + seen.push(layers.iter().map(|l| comp.layer(LayerId(*l)).unwrap().props.prop("transform/rotation").unwrap().value.as_f64()).collect()); + } + let end = at(400.0); + h.input_mut().events.push(Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + seen +} + +/// Layer `i` of `rotate_round` turned by `step`° on every frame once the drag was under way. +fn assert_turns_by(seen: &[Vec], i: usize, step: f64) { + let v: Vec = seen.iter().map(|f| f[i]).collect(); + let live: Vec = v.iter().copied().skip_while(|x| *x == v[0]).collect(); + assert!(live.len() > 30, "layer {i} hardly turned: {v:?}"); + assert!(live.windows(2).all(|w| (w[1] - w[0] - step).abs() < 0.5), "layer {i} should turn {step}° a frame: {v:?}"); +} + +/// #2870: a child of a moved parent turns about its anchor where it shows, not about its raw +/// Position read as comp pixels. +#[test] +fn rotate_tool_turns_a_child_of_a_moved_parent_smoothly() { + let (mut h, _, child) = rotate_child_harness(json!({"position": [420.0, 240.0]})); + assert_turns_by(&rotate_round(&mut h, [300.0, 180.0], &[child]), 0, 10.0); +} + +/// #2870: under a mirrored parent the value runs opposite to the pointer, so the layer on screen +/// still follows it. +#[test] +fn rotate_tool_follows_the_pointer_under_a_flipped_parent() { + let (mut h, _, child) = rotate_child_harness(json!({"scale": [-100.0, 100.0]})); + assert_turns_by(&rotate_round(&mut h, [440.0, 120.0], &[child]), 0, -10.0); +} + +/// #2870: a parent's rotation offsets the angle but not the turn. +#[test] +fn rotate_tool_turns_a_child_of_a_rotated_parent_smoothly() { + let (mut h, _, child) = rotate_child_harness(json!({"rotation": 90.0})); + assert_turns_by(&rotate_round(&mut h, [380.0, 60.0], &[child]), 0, 10.0); +} + +/// #2870: under a parent scaled unevenly the angle is the one in parent space, so the layer's edge +/// stays under the pointer, which covers uneven degrees per frame. +#[test] +fn rotate_tool_measures_in_parent_space_under_a_stretched_parent() { + let (mut h, _, child) = rotate_child_harness(json!({"scale": [200.0, 100.0]})); + let seen = rotate_round(&mut h, [80.0, 120.0], &[child]); + let mut turned = 0.0_f64; + let mut last = 0.0_f64; + let want: Vec = (0..=40) + .map(|i| { + let a = (i as f64 * 10.0).to_radians(); + let ang = a.sin().atan2(a.cos() / 2.0).to_degrees(); + turned += (ang - last + 180.0).rem_euclid(360.0) - 180.0; + last = ang; + turned + }) + .collect(); + let end = seen.last().unwrap()[0]; + assert!((end - want[40]).abs() < 1.0, "the layer follows the pointer in parent space: {end}, want {}", want[40]); + assert!(seen.windows(2).all(|w| (w[1][0] - w[0][0]).abs() < 30.0), "no jump: {seen:?}"); +} + +/// #2870: a parent's pixel aspect isn't inherited by its child, so it doesn't move the pivot either. +#[test] +fn rotate_tool_ignores_a_parents_pixel_aspect() { + let mut h = harness(); + let child = layer_id(&h, "Box"); + let s = &mut h.state_mut().session; + let parent = s.execute("layer.newSolid", json!({"name": "Wide", "color": "#000000", "width": 100, "height": 100, "pixelAspect": 2.0})).unwrap()["layer"] + .as_u64() + .unwrap(); + s.execute("layer.setSwitch", json!({"layers": [parent], "switch": "video", "value": false})).unwrap(); + s.execute("prop.set", json!({"layer": parent, "path": "transform/position", "value": [500.0, 300.0]})).unwrap(); + s.execute("layer.setParent", json!({"layers": [child], "parent": parent, "compensate": false})).unwrap(); + s.execute("prop.set", json!({"layer": child, "path": "transform/position", "value": [100.0, 60.0]})).unwrap(); + s.execute("layer.select", json!({"layers": [child]})).unwrap(); + h.state_mut().ui.tool = Tool::Rotate; + h.run_steps(2); + assert_turns_by(&rotate_round(&mut h, [550.0, 310.0], &[child]), 0, 10.0); +} + +/// #2870: every selected layer turns the same way on screen, whichever way its own parent is +/// flipped: the value of a layer whose parent is mirrored relative to the grabbed one's runs opposite. +#[test] +fn rotate_tool_turns_a_mixed_selection_the_same_way_on_screen() { + let (mut h, plate, child) = rotate_child_harness(json!({"scale": [-100.0, 100.0]})); + let s = &mut h.state_mut().session; + let new = |s: &mut Session, name: &str| { + s.execute("layer.newSolid", json!({"name": name, "color": "#00ff00", "width": 20, "height": 20})).unwrap()["layer"].as_u64().unwrap() + }; + let (free, sibling) = (new(s, "Free"), new(s, "Sibling")); + s.execute("layer.setParent", json!({"layers": [sibling], "parent": plate, "compensate": false})).unwrap(); + s.execute("prop.set", json!({"layer": free, "path": "transform/rotation", "value": 15.0})).unwrap(); + s.execute("layer.select", json!({"layers": [child, free, sibling]})).unwrap(); + h.run_steps(2); + let seen = rotate_round(&mut h, [440.0, 120.0], &[child, free, sibling]); + assert_turns_by(&seen, 0, -10.0); + assert_turns_by(&seen, 1, 10.0); + assert_turns_by(&seen, 2, -10.0); +} + +/// #2870: a 3D child turns about its anchor as the camera shows it, and with its value running +/// opposite under a flipped parent. +#[test] +fn rotate_tool_turns_a_3d_child_about_its_anchor_through_the_camera() { + let (mut h, _, child) = rotate_child_harness(json!({"scale": [-100.0, 100.0]})); + let s = &mut h.state_mut().session; + s.execute("layer.setSwitch", json!({"layers": [child], "switch": "threeD", "value": true})).unwrap(); + let cam = s.execute("layer.newCamera", json!({"name": "Cam", "type": "oneNode"})).unwrap()["layer"].as_u64().unwrap(); + let at = s.active_comp().unwrap().layer(LayerId(cam)).unwrap().props.prop("transform/position").unwrap().value.as_vec3(); + // One comp-plane unit to the right moves everything in the comp plane one pixel to the left. + s.execute("prop.set", json!({"layer": cam, "path": "transform/position", "value": [at[0] + 100.0, at[1], at[2]]})).unwrap(); + s.execute("layer.select", json!({"layers": [child]})).unwrap(); + h.run_steps(2); + assert_turns_by(&rotate_round(&mut h, [340.0, 120.0], &[child]), 0, -10.0); +} + /// 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] From cab353eb5b85176cbca99599f7290fbecbce3163 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:40:51 +0100 Subject: [PATCH 24/71] UI: Composition Settings widens to fit a longer translation (#2841) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialog was a fixed 600 points wide inside a scroll area, so a row wider than that in another language (French "Conserver la fréquence d'images en cas d'imbrication ou dans la file d'attente de rendu", the French and Spanish Lock Aspect Ratio row) pushed OK past the right edge, reachable only by scrolling sideways. The dialog now measures the widest row of its three tabs (as it already measured their heights) and takes that width, or 600 points if that is more, capped by the window as before. English keeps 600 points. --- crates/ui-egui/src/panels/comp_settings.rs | 34 +++++++++++++++------- crates/ui-egui/src/panels/dialogs.rs | 4 ++- crates/ui-egui/tests/it/ui_comp_presets.rs | 33 +++++++++++++++++++++ 3 files changed, 60 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index 7e49d8a7d..26d3c3a69 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -469,13 +469,24 @@ fn tab_body( } } -/// The height `tab` would take at `top`, from laying it out unseen: it follows the font, the -/// language and the scale. Nothing it registers or changes is kept. -fn tab_height(ui: &mut egui::Ui, tab: Tab, d: &CompDraft, t: &Tokens, custom: &[CompPreset], top: f32) -> f32 { +/// The width and height `tab` would take at `top`, from laying it out unseen: they follow the +/// font, the language and the scale. Nothing it registers or changes is kept. +fn tab_size(ui: &mut egui::Ui, tab: Tab, d: &CompDraft, t: &Tokens, custom: &[CompPreset], top: f32) -> egui::Vec2 { let rect = egui::Rect::from_min_size(egui::pos2(ui.max_rect().left(), top), vec2(ui.max_rect().width(), 0.0)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).id_salt("comp-tab-measure").invisible()); tab_body(&mut child, tab, &mut d.clone(), t, &mut Registry::default(), custom, &mut Vec::new()); - child.cursor().top() - top + vec2(child.min_rect().width(), child.cursor().top() - top) +} + +fn content_width_id() -> egui::Id { + egui::Id::unique("comp-settings-content-width") +} + +/// The width the dialog's content took when it was last shown: its widest row on any tab. A +/// translation longer than the English (French, #2841) is wider than the 600-point dialog, so the +/// dialog grows to it rather than hiding OK past its right edge. +pub fn content_width(ctx: &egui::Context) -> f32 { + ctx.data(|d| d.get_temp::(content_width_id())).unwrap_or(0.0) } /// Draw the dialog. Returns `Some(params)` when OK is pressed, `Some(Null)` on Cancel. `custom` @@ -490,14 +501,16 @@ pub fn show( preview: Option<&mut bool>, ) -> Option { let mut out = None; - ui.horizontal(|ui| { + // The dialog's visible area, which Cancel and OK stay inside. + auto.add("dialog.comp.view", ui.clip_rect().intersect(ui.max_rect()), "Composition Settings"); + let name_row = ui.horizontal(|ui| { ui.label(tr("Composition Name:")); let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0)); crate::widgets::focus_on_open(ui.ctx(), &r, &d.name); auto.add("dialog.comp.name", r.rect, "Composition Name"); }); ui.add_space(8.0); - ui.horizontal(|ui| { + let tab_row = ui.horizontal(|ui| { for (tab, label, key) in [(Tab::Basic, "Basic", "basic"), (Tab::Advanced, "Advanced", "advanced"), (Tab::Renderer, "3D Renderer", "renderer")] { let r = ui.selectable_label(d.tab == tab, tr(label)); auto.add(&format!("dialog.comp.tab.{key}"), r.rect, label); @@ -511,11 +524,12 @@ pub fn show( let body_top = ui.cursor().top(); tab_body(ui, d.tab, d, t, auto, custom, cmds); let shown = ui.cursor().top() - body_top; - let tallest = [Tab::Basic, Tab::Advanced, Tab::Renderer] - .into_iter() - .filter(|tab| *tab != d.tab) - .fold(shown, |h, tab| h.max(tab_height(ui, tab, d, t, custom, body_top))); + let sizes: Vec = [Tab::Basic, Tab::Advanced, Tab::Renderer].into_iter().map(|tab| tab_size(ui, tab, d, t, custom, body_top)).collect(); + let tallest = sizes.iter().fold(shown, |h, s| h.max(s.y)); ui.add_space((tallest - shown).max(0.0)); + // Every tab takes the width of the widest one too, so the dialog doesn't change size with them. + let widest = sizes.iter().fold(name_row.response.rect.width().max(tab_row.response.rect.width()), |w, s| w.max(s.x)); + ui.ctx().data_mut(|m| m.insert_temp(content_width_id(), widest.ceil())); ui.add_space(14.0); ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index ac26bd0c1..e5c8950e7 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -446,7 +446,9 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis let live = existing && super::live_preview::active(app); let place = super::live_preview::place(app); let mut preview = app.dialog_state.live.on; - modal_placed(ctx, "Composition Settings", vec2(600.0, 470.0), t, place, |ui| { + // 600 points wide, or as wide as the content took last time (with the 18-point margins). + let width = (super::comp_settings::content_width(ctx) + 36.0).max(600.0); + modal_placed(ctx, "Composition Settings", vec2(width, 470.0), t, place, |ui| { result = super::comp_settings::show(ui, &mut d, t, &mut app.auto, &custom, &mut cmds, live.then_some(&mut preview)); }); app.dialog_state.live.on = preview; diff --git a/crates/ui-egui/tests/it/ui_comp_presets.rs b/crates/ui-egui/tests/it/ui_comp_presets.rs index 9e71f8fd7..a109d5324 100644 --- a/crates/ui-egui/tests/it/ui_comp_presets.rs +++ b/crates/ui-egui/tests/it/ui_comp_presets.rs @@ -103,3 +103,36 @@ fn composition_settings_buttons_stay_put_between_tabs() { } } } + +/// Composition Settings keeps Cancel and OK in view in every interface language: a translation +/// wider than the English (French, #2841) widens the dialog instead of pushing OK past its right +/// edge, where it could be reached only by scrolling sideways. +#[test] +fn composition_settings_buttons_fit_every_language() { + // English, then French (#2841) and the other catalogs whose rows overflowed the 600 points. + for language in ["en", "fr", "es", "ka", "cs", "pt-br"] { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Edit", "width": 2560, "height": 1440, "frameRate": 60, "duration": 4})).unwrap(); + s.prefs.general.language = language.into(); + let mut h = + Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).allow_missing_glyphs().build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + open_settings(&mut h); + let mut oks = vec![]; + for tab in ["basic", "advanced", "renderer"] { + click(&mut h, &format!("dialog.comp.tab.{tab}")); + let rect = |id: &str| h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).rect; + let (view, ok, cancel) = (rect("dialog.comp.view"), rect("dialog.comp.ok"), rect("dialog.comp.cancel")); + for (name, r) in [("OK", ok), ("Cancel", cancel)] { + assert!(r[0] >= view[0] && r[0] + r[2] <= view[0] + view[2] + 0.5, "{language} {tab}: {name} {r:?} is outside the dialog {view:?}"); + } + if language == "en" { + // English still fits the 600-point dialog (18-point margins). + assert_eq!(view[2], 600.0 - 36.0, "{tab}"); + } + oks.push(ok); + } + // The dialog takes the width of its widest tab, so OK stays put between tabs. + assert!(oks.windows(2).all(|w| w[0] == w[1]), "{language}: OK moves between tabs: {oks:?}"); + } +} From 50e5a7970eded9fda443ca4aa0284ae3542f2a68 Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 20:18:18 -0300 Subject: [PATCH 25/71] SAM 2: read the wgpu env variables and refuse adapters without compute shaders (#2900) SAM 2's instance now reads wgpu's environment variables, as the render context's does, so a driver such as Mesa's Dozen (WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER=1) is visible to it, and WGPU_POWER_PREF applies. An adapter without compute shaders is refused before a device is made on it, so the CPU takes over with one line instead of a validation error per kernel. The refused adapter is kept for the life of the process, as the device on it was: dropping it crashed the tests on WSL2's GL driver. Gpu::shared no longer repeats "SAM 2 compute:" in its errors. --- crates/sam2/examples/gpu_matmul.rs | 3 +- crates/sam2/src/gpu.rs | 44 ++++++++++++++++++++++++------ crates/sam2/src/gpu/tests.rs | 35 ++++++++++++++++++++++++ docs/roto-brush-sam2.md | 4 ++- 4 files changed, 75 insertions(+), 11 deletions(-) diff --git a/crates/sam2/examples/gpu_matmul.rs b/crates/sam2/examples/gpu_matmul.rs index 1429ebf02..a1f92943b 100644 --- a/crates/sam2/examples/gpu_matmul.rs +++ b/crates/sam2/examples/gpu_matmul.rs @@ -67,7 +67,8 @@ fn main(@builtin(workgroup_id) wg: vec3, @builtin(local_invocation_id) li: "#; fn main() -> Result<(), Box> { - let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + // wgpu's environment variables apply (`WGPU_BACKEND`, ...), as for SAM 2's own instance + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env()); let adapter = pollster::block_on( instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }), )?; diff --git a/crates/sam2/src/gpu.rs b/crates/sam2/src/gpu.rs index bc83b605f..852cc1a08 100644 --- a/crates/sam2/src/gpu.rs +++ b/crates/sam2/src/gpu.rs @@ -99,12 +99,37 @@ pub fn half_weights() -> bool { /// The process's one GPU device, once something has asked for it. static DEVICE: std::sync::OnceLock, String>> = std::sync::OnceLock::new(); +/// The adapter SAM 2 refused and its instance, kept for the life of the process, as the device made on it used to be. +/// Dropping them crashed the tests with a segmentation fault on WSL2's OpenGL driver (Mesa 23.2 on D3D12). At most one: +/// [`Gpu::new`] runs once. +static REFUSED: std::sync::OnceLock<(wgpu::Instance, wgpu::Adapter)> = std::sync::OnceLock::new(); + +/// SAM 2's wgpu instance reads wgpu's environment variables (`WGPU_BACKEND`, `WGPU_VALIDATION`, +/// `WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER`…), as the render context's does, so both choose among the same adapters. +fn instance_descriptor() -> wgpu::InstanceDescriptor { + wgpu::InstanceDescriptor::new_without_display_handle().with_env() +} + +/// Why SAM 2 does not use an adapter, if it does not: a software one (the CPU path is faster), or one without compute +/// shaders (OpenGL ES 3.0, for one), where every kernel would fail validation. Checked before a device is made there. +fn refusal(name: &str, backend: wgpu::Backend, device_type: wgpu::DeviceType, downlevel: wgpu::DownlevelFlags) -> Option { + if device_type == wgpu::DeviceType::Cpu { + return Some(format!("only a software adapter ({name}) is available")); + } + (!downlevel.contains(wgpu::DownlevelFlags::COMPUTE_SHADERS)).then(|| format!("the GPU adapter {name} ({backend:?}) has no compute shaders")) +} + impl Gpu { /// The process's one GPU device, created on first use. Several devices at once upset some Vulkan loaders /// (a crash when two threads create or drop them together), and one is all SAM 2 needs: models come and go, /// the device stays. An error (no GPU) is remembered too. pub fn shared() -> Result> { - DEVICE.get_or_init(|| Gpu::new().map(Arc::new).map_err(|e| e.to_string())).clone().map_err(Error::Compute) + // The message alone is kept, so the error says "SAM 2 compute:" once. + let keep = |e: Error| match e { + Error::Compute(m) => m, + e => e.to_string(), + }; + DEVICE.get_or_init(|| Gpu::new().map(Arc::new).map_err(keep)).clone().map_err(Error::Compute) } /// Whether the shared device has been looked for yet, and found: `None` before anything has asked @@ -114,16 +139,17 @@ impl Gpu { DEVICE.get().map(|d| d.is_ok()) } - /// A device on the best real GPU (a software adapter is refused: the CPU path is faster). + /// A device on the best real GPU (high performance unless `WGPU_POWER_PREF` says otherwise; see [`refusal`] for the + /// adapters the CPU path runs instead of). fn new() -> Result { - let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); - let adapter = pollster::block_on( - instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }), - ) - .map_err(|e| Error::Compute(format!("no GPU adapter: {e}")))?; + let instance = wgpu::Instance::new(instance_descriptor()); + let power_preference = wgpu::PowerPreference::from_env().unwrap_or(wgpu::PowerPreference::HighPerformance); + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference, ..Default::default() })) + .map_err(|e| Error::Compute(format!("no GPU adapter: {e}")))?; let info = adapter.get_info(); - if info.device_type == wgpu::DeviceType::Cpu { - return Err(Error::Compute(format!("only a software adapter ({}) is available", info.name))); + if let Some(why) = refusal(&info.name, info.backend, info.device_type, adapter.get_downlevel_capabilities().flags) { + let _ = REFUSED.set((instance, adapter)); + return Err(Error::Compute(why)); } let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { label: Some("effectcraft sam2"), diff --git a/crates/sam2/src/gpu/tests.rs b/crates/sam2/src/gpu/tests.rs index e974c34df..09297f542 100644 --- a/crates/sam2/src/gpu/tests.rs +++ b/crates/sam2/src/gpu/tests.rs @@ -452,3 +452,38 @@ fn half_precision_weights_give_the_same_product_to_half_precision() { assert_close(&got, &want, 2e-3 * (k as f32).sqrt(), "f16 vs f32 weights"); assert!(got != want, "the weights really were rounded to half precision"); } + +/// The instance reads wgpu's environment variables, as the render context's does (#2900): with +/// `WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER=1` wgpu lists Mesa's Dozen, the only Vulkan driver on WSL2. Run in a +/// child process, which has them set. +#[test] +fn the_instance_reads_the_wgpu_environment() { + const CHILD: &str = "EFFECTCRAFT_TEST_SAM2_WGPU_ENV_CHILD"; + if std::env::var_os(CHILD).is_none() { + let out = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "gpu::tests::the_instance_reads_the_wgpu_environment", "--nocapture"]) + .env(CHILD, "1") + .env("WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER", "1") + .env("WGPU_BACKEND", "vulkan") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(out.status.success() && stdout.contains("1 passed"), "{stdout}\n{}", String::from_utf8_lossy(&out.stderr)); + return; + } + let desc = instance_descriptor(); + assert!(desc.flags.contains(wgpu::InstanceFlags::ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER), "{:?}", desc.flags); + assert_eq!(desc.backends, wgpu::Backends::VULKAN); +} + +/// An adapter without compute shaders (a GL one, here) is refused before a device is made on it, with one error +/// instead of a validation error per kernel; so is a software one. +#[test] +fn adapters_without_compute_shaders_are_refused() { + use wgpu::{Backend, DeviceType, DownlevelFlags}; + assert_eq!(refusal("GPU", Backend::Vulkan, DeviceType::DiscreteGpu, DownlevelFlags::all()), None); + let gl = refusal("GPU", Backend::Gl, DeviceType::Other, DownlevelFlags::all() - DownlevelFlags::COMPUTE_SHADERS); + assert_eq!(gl.as_deref(), Some("the GPU adapter GPU (Gl) has no compute shaders")); + let software = refusal("llvmpipe", Backend::Vulkan, DeviceType::Cpu, DownlevelFlags::all()); + assert_eq!(software.as_deref(), Some("only a software adapter (llvmpipe) is available")); +} diff --git a/docs/roto-brush-sam2.md b/docs/roto-brush-sam2.md index 0204058d5..d1f3ba196 100644 --- a/docs/roto-brush-sam2.md +++ b/docs/roto-brush-sam2.md @@ -155,7 +155,9 @@ of clicks, and measured confidence collapses with many (0.99 with 1–7 points, ## Speed (this PC: AMD RX 7900 GRE and a 16-thread CPU, release build) SAM 2 runs on the GPU when there is one (wgpu compute kernels, so AMD, NVIDIA and Intel alike; candle itself has no AMD backend) and on the CPU otherwise. The model loads on the GPU automatically; `EFFECTCRAFT_SAM2_DEVICE=cpu` (or `gpu`) -overrides it. A GPU that fails (no adapter, out of memory, a lost device) makes the CPU take over for the rest of the +overrides it. wgpu's environment variables apply to the adapter it picks (`WGPU_BACKEND`, `WGPU_POWER_PREF`, +`WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER`…); a software adapter, or one without compute shaders, leaves SAM 2 on +the CPU. A GPU that fails (no adapter, out of memory, a lost device) makes the CPU take over for the rest of the session, and `roto.status` reports `tracker.device` (the GPU's name, or `CPU`). | Step | CPU | GPU | From 0b60a86bfec3490c72088219152e2ff058cd34ea Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 20:20:21 -0300 Subject: [PATCH 26/71] Fix #2904: a second layer or a copy of v-b1.mp4 is v-b1.mp4 2, not v-b1.mp 2 Comp::unique_layer_name and Project::numbered_item_name each stripped every trailing digit before numbering. Both use one helper now, numbered_name, which replaces a trailing number only when a space sets it off (Red Solid 1 -> Red Solid 2); digits that end a word stay. --- crates/project/src/comp.rs | 10 ++++------ crates/project/src/item.rs | 23 +++++++++++++++++------ crates/project/src/tests.rs | 27 +++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 12 deletions(-) diff --git a/crates/project/src/comp.rs b/crates/project/src/comp.rs index aa4ff7201..63edc65be 100644 --- a/crates/project/src/comp.rs +++ b/crates/project/src/comp.rs @@ -265,13 +265,11 @@ impl Comp { self.layers.iter().find(|l| matches!(l.source, LayerSource::Camera) && l.switches.video && l.is_active_at(t)) } - /// Unique layer name with a numeric suffix (`Shape Layer 2`). + /// `base`, or the next free numbered name when a layer already has it (`Shape Layer 2`, + /// `v-b1.mp4 2`; see `numbered_name`). pub fn unique_layer_name(&self, base: &str) -> String { - if !self.layers.iter().any(|l| l.name == base) { - return base.to_string(); - } - let stem = base.trim_end_matches(|c: char| c.is_ascii_digit()).trim_end(); - (2..).map(|i| format!("{stem} {i}")).find(|n| !self.layers.iter().any(|l| &l.name == n)).unwrap_or_default() + let taken = |n: &str| self.layers.iter().any(|l| l.name == n); + if taken(base) { crate::item::numbered_name(base, taken) } else { base.to_string() } } } diff --git a/crates/project/src/item.rs b/crates/project/src/item.rs index 1ef3d1f0a..047f1ef3f 100644 --- a/crates/project/src/item.rs +++ b/crates/project/src/item.rs @@ -4,6 +4,19 @@ use super::layer::LayerSource; use super::settings::{APP_VERSION, ProjectError, ProjectSettings, SCHEMA_VERSION, SavedView}; use super::{Arc, BTreeMap, Deserialize, FrameRate, Label, Project, Serialize, Tick, file_check, limits}; +/// `base` numbered with the lowest counter from 2 for which `taken` is false: a copy of an item or +/// another layer of the same name. A number set off by a space is a counter and is replaced +/// (`Red Solid 1` → `Red Solid 2`, `shot 2` → `shot 3`); digits that end a word belong to the +/// name and stay (`v-b1.mp4` → `v-b1.mp4 2`, `clip2.mov` → `clip2.mov 2`). +pub(crate) fn numbered_name(base: &str, taken: impl Fn(&str) -> bool) -> String { + let head = base.trim_end_matches(|c: char| c.is_ascii_digit()); + let stem = match head.strip_suffix(' ') { + Some(stem) if head.len() < base.len() && !stem.trim_end().is_empty() => stem.trim_end(), + _ => base, + }; + (2u64..).map(|k| format!("{stem} {k}")).find(|n| !taken(n)).unwrap_or_default() +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct ItemId(pub u64); @@ -162,13 +175,11 @@ 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. + /// A numbered name for a copy of `base` that no project item has (Edit ▸ Duplicate), as + /// `numbered_name` gives it (`Comp 1` → `Comp 2`, `Name` → `Name 2`, then `Name 3`, + /// `v-b1.mp4` → `v-b1.mp4 2`). 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() + numbered_name(base, |n| self.items.values().any(|i| i.name == n && !ignore(i))) } /// `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. diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index a47792b89..ed0d05588 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -113,6 +113,33 @@ fn resolution_factors_and_scales() { assert_eq!(Resolution::Third.scale(0.1, 1.0), 1.0 / 3.0); } +/// Another layer of the same source, and a duplicated item, get a numbered name. Only a number set +/// off by a space is a counter; digits that end a word stay (`v-b1.mp4` gave `v-b1.mp 2`). +#[test] +fn numbered_names_keep_digits_that_end_a_word() { + // (the name in use, the next layer's and the item copy's name) + let rows = [ + ("v-b1.mp4", "v-b1.mp4 2"), + ("clip2.mov", "clip2.mov 2"), + ("shot 2", "shot 3"), + ("a.b.c", "a.b.c 2"), + ("Background", "Background 2"), + ("Red Solid 1", "Red Solid 2"), + ]; + for (name, next) in rows { + let mut p = Project::default(); + let mut comp = Comp::new(64, 36, FrameRate::FPS_29_97, Tick::from_seconds_f64(1.0)); + let solid = Solid { color: [0.5; 3], width: 64, height: 36, pixel_aspect: 1.0 }; + let item = p.add_item(name, effectcraft_color::Label::Red, None, ItemKind::Solid(solid)); + let first = build::layer(&mut p, &comp, name, LayerSource::Solid { item }, (64, 36), None); + assert_eq!(first.name, name, "the first layer of {name}"); + comp.layers.push(first); + let second = build::layer(&mut p, &comp, name, LayerSource::Solid { item }, (64, 36), None); + assert_eq!(second.name, next, "the second layer of {name}"); + assert_eq!(p.numbered_item_name(name, |_| false), next, "a copy of the item {name}"); + } +} + #[test] fn unique_names_and_cycles() { let (p, cid) = project_with_layer(); From 5643f39769ab561b04fc43acab4321d8d5c06581 Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 20:22:13 -0300 Subject: [PATCH 27/71] Fix #2907: say that text animator indexes count from 1 layer.addTextAnimatorProperty, layer.addTextSelector and text.animatorFontAxes take `animator: uid|index`, counted from 1 as the code and `#n` references do, but the parameters didn't say so and `animator: 0` failed with only "no animator 0". The parameters now say 1-based, and the error says what the value can be. --- crates/engine/src/cmd/text_anim.rs | 12 ++++++++---- crates/engine/src/tests/text.rs | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/text_anim.rs b/crates/engine/src/cmd/text_anim.rs index 296dc4c37..397e697c4 100644 --- a/crates/engine/src/cmd/text_anim.rs +++ b/crates/engine/src/cmd/text_anim.rs @@ -59,7 +59,11 @@ fn animator_uid(s: &Session, l: &Layer, p: &Value, cmd: &str) -> Result { if let Some(g) = list.iter().find(|g| g.uid == n) { return Ok(g.uid); } - return list.get((n as usize).wrapping_sub(1)).map(|g| g.uid).ok_or_else(|| bad(cmd, format!("no animator {n}"))); + let count = if list.len() == 1 { "1 animator".to_string() } else { format!("{} animators", list.len()) }; + return list + .get((n as usize).wrapping_sub(1)) + .map(|g| g.uid) + .ok_or_else(|| bad(cmd, format!("no animator {n}: `animator` is a uid or a 1-based index, and the layer has {count}"))); } for (lid, uid) in &s.state.selected_props { if *lid != l.id { @@ -454,7 +458,7 @@ pub fn specs() -> Vec { "Add Property", [], None, - "{layer?, animator?: uid|index, property: (see layer.addTextAnimator)}", + "{layer?, animator?: uid|index (1-based), property: (see layer.addTextAnimator)}", has_animator, add_animator_prop ), @@ -463,7 +467,7 @@ pub fn specs() -> Vec { "Add Text Selector", [], None, - "{layer?, animator?: uid|index, kind: range|wiggly|expression}", + "{layer?, animator?: uid|index (1-based), kind: range|wiggly|expression}", has_animator, add_selector ), @@ -482,7 +486,7 @@ pub fn specs() -> Vec { "Variable Font Axes", ["Animation", "Animate Text"], None, - "{layer?, axis?: tag|name (omit to list the font's axes), animator?: uid|index}", + "{layer?, axis?: tag|name (omit to list the font's axes), animator?: uid|index (1-based)}", has_text_layer, font_axes ), diff --git a/crates/engine/src/tests/text.rs b/crates/engine/src/tests/text.rs index 3980c7bb5..132bfc416 100644 --- a/crates/engine/src/tests/text.rs +++ b/crates/engine/src/tests/text.rs @@ -49,6 +49,27 @@ fn animate_text_adds_animators_with_companion_properties() { let _ = s.render(cid, s.time(), Default::default()); } +/// `animator` counts from 1, as `#n` references do: the commands that take it say so, and 0 fails +/// with an error that says so (agents reading `uid|index` tried 0). +#[test] +fn animator_indices_count_from_1_and_say_so() { + let (mut s, t) = session(); + let uid = s.execute("layer.addTextAnimator", json!({"layer": t, "property": "position"})).unwrap()["animator"].as_u64().unwrap(); + for id in ["layer.addTextAnimatorProperty", "layer.addTextSelector", "text.animatorFontAxes"] { + let spec = crate::cmd::command_specs().iter().find(|c| c.id == id).unwrap(); + assert!(spec.params.contains("animator?: uid|index (1-based)"), "{id}: {}", spec.params); + } + for (id, p) in [ + ("layer.addTextAnimatorProperty", json!({"layer": t, "animator": 0, "property": "fillHue"})), + ("layer.addTextSelector", json!({"layer": t, "animator": 0, "kind": "wiggly"})), + ] { + let e = s.execute(id, p).unwrap_err().to_string(); + assert!(e.contains("no animator 0") && e.contains("1-based"), "{id}: {e}"); + } + let r = s.execute("layer.addTextSelector", json!({"layer": t, "animator": 1, "kind": "wiggly"})).unwrap(); + assert_eq!(r["animator"].as_u64(), Some(uid)); +} + #[test] fn add_property_and_selectors_with_undo_redo() { let (mut s, t) = session(); From 5e8e6f829e96e323c7758190d05f1e287918566f Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 20:43:58 -0300 Subject: [PATCH 28/71] SAM 2: the gpu_matmul example reads WGPU_POWER_PREF too (#2900) --- crates/sam2/examples/gpu_matmul.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/sam2/examples/gpu_matmul.rs b/crates/sam2/examples/gpu_matmul.rs index a1f92943b..29d2bcee5 100644 --- a/crates/sam2/examples/gpu_matmul.rs +++ b/crates/sam2/examples/gpu_matmul.rs @@ -67,11 +67,10 @@ fn main(@builtin(workgroup_id) wg: vec3, @builtin(local_invocation_id) li: "#; fn main() -> Result<(), Box> { - // wgpu's environment variables apply (`WGPU_BACKEND`, ...), as for SAM 2's own instance + // wgpu's environment variables apply (`WGPU_BACKEND`, `WGPU_POWER_PREF`, ...), as for SAM 2's own instance let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env()); - let adapter = pollster::block_on( - instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }), - )?; + let power_preference = wgpu::PowerPreference::from_env().unwrap_or(wgpu::PowerPreference::HighPerformance); + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference, ..Default::default() }))?; println!("adapter: {:?}", adapter.get_info().name); let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { required_limits: adapter.limits(), ..Default::default() }))?; let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: None, source: wgpu::ShaderSource::Wgsl(SHADER.into()) }); From f6fd6de3bbab90e894355fc748760bdf2cfc9542 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:03:48 +0100 Subject: [PATCH 29/71] Follow-up to #2841: Composition Settings settles after a width change - `dialog.comp.view` is the scroll area's viewport (its clip rect). Cut by the content's max rect, it lost the scrolled amount at the bottom. - When the dialog's width changes, the pass laid out at the old width is discarded and a repaint requested. - The measured width keeps room for a vertical scroll bar. Without it, switching the language with the dialog open left the two scroll bars trading places every frame: the UI never settled and OK was clipped every other frame. - `content_width` becomes `dialog_width`, which returns the dialog's width, so the 600-point rule is in one place. Tests: the view keeps its place when the dialog scrolls, and switching to French and back with the dialog open settles with OK inside it. --- crates/ui-egui/src/panels/comp_settings.rs | 31 ++++++++--- crates/ui-egui/src/panels/dialogs.rs | 4 +- crates/ui-egui/tests/it/ui_comp_presets.rs | 60 ++++++++++++++++++++++ 3 files changed, 84 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index 26d3c3a69..b778a3999 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -478,15 +478,17 @@ fn tab_size(ui: &mut egui::Ui, tab: Tab, d: &CompDraft, t: &Tokens, custom: &[Co vec2(child.min_rect().width(), child.cursor().top() - top) } +/// Where the width of the dialog's content, measured when it was last shown, is kept. fn content_width_id() -> egui::Id { egui::Id::unique("comp-settings-content-width") } -/// The width the dialog's content took when it was last shown: its widest row on any tab. A -/// translation longer than the English (French, #2841) is wider than the 600-point dialog, so the -/// dialog grows to it rather than hiding OK past its right edge. -pub fn content_width(ctx: &egui::Context) -> f32 { - ctx.data(|d| d.get_temp::(content_width_id())).unwrap_or(0.0) +/// The dialog's width: 600 points, or more when the widest row on any of its tabs (as last shown), +/// a scroll bar and the modal's two 18-point margins need it. A translation longer than the English (French, +/// #2841) widens the dialog rather than hiding OK past its right edge. +pub fn dialog_width(ctx: &egui::Context) -> f32 { + let content = ctx.data(|d| d.get_temp::(content_width_id())).unwrap_or(0.0); + (content + 36.0).max(600.0) } /// Draw the dialog. Returns `Some(params)` when OK is pressed, `Some(Null)` on Cancel. `custom` @@ -501,8 +503,9 @@ pub fn show( preview: Option<&mut bool>, ) -> Option { let mut out = None; - // The dialog's visible area, which Cancel and OK stay inside. - auto.add("dialog.comp.view", ui.clip_rect().intersect(ui.max_rect()), "Composition Settings"); + // The dialog's visible area, which Cancel and OK stay inside: the scroll area's viewport, + // which stays put when the content scrolls. + auto.add("dialog.comp.view", ui.clip_rect(), "Composition Settings"); let name_row = ui.horizontal(|ui| { ui.label(tr("Composition Name:")); let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0)); @@ -529,7 +532,19 @@ pub fn show( ui.add_space((tallest - shown).max(0.0)); // Every tab takes the width of the widest one too, so the dialog doesn't change size with them. let widest = sizes.iter().fold(name_row.response.rect.width().max(tab_row.response.rect.width()), |w, s| w.max(s.x)); - ui.ctx().data_mut(|m| m.insert_temp(content_width_id(), widest.ceil())); + // Room for a vertical scroll bar too: without it, a bar shown while the dialog settles narrows + // the content into a horizontal bar, which takes the height back from it, and the two bars + // trade places every frame. + let needed = widest + ui.spacing().scroll.allocated_width(); + let ctx = ui.ctx().clone(); + let before = dialog_width(&ctx); + ctx.data_mut(|m| m.insert_temp(content_width_id(), needed.ceil())); + if dialog_width(&ctx) != before { + // This pass was laid out at the old width: lay it out again at the new one instead of + // showing it, and paint again in case egui declines the extra pass. + ctx.request_discard("Composition Settings width"); + ctx.request_repaint(); + } ui.add_space(14.0); ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index e5c8950e7..d3ee74e58 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -446,9 +446,7 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis let live = existing && super::live_preview::active(app); let place = super::live_preview::place(app); let mut preview = app.dialog_state.live.on; - // 600 points wide, or as wide as the content took last time (with the 18-point margins). - let width = (super::comp_settings::content_width(ctx) + 36.0).max(600.0); - modal_placed(ctx, "Composition Settings", vec2(width, 470.0), t, place, |ui| { + modal_placed(ctx, "Composition Settings", vec2(super::comp_settings::dialog_width(ctx), 470.0), t, place, |ui| { result = super::comp_settings::show(ui, &mut d, t, &mut app.auto, &custom, &mut cmds, live.then_some(&mut preview)); }); app.dialog_state.live.on = preview; diff --git a/crates/ui-egui/tests/it/ui_comp_presets.rs b/crates/ui-egui/tests/it/ui_comp_presets.rs index a109d5324..262d2183b 100644 --- a/crates/ui-egui/tests/it/ui_comp_presets.rs +++ b/crates/ui-egui/tests/it/ui_comp_presets.rs @@ -136,3 +136,63 @@ fn composition_settings_buttons_fit_every_language() { assert!(oks.windows(2).all(|w| w[0] == w[1]), "{language}: OK moves between tabs: {oks:?}"); } } + +fn rect_of(h: &Harness<'_, EffectcraftApp>, id: &str) -> egui::Rect { + let r = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).rect; + egui::Rect::from_min_size(pos2(r[0], r[1]), egui::vec2(r[2], r[3])) +} + +/// In a window too short for it, the dialog scrolls: its registered view stays the scroll +/// area's viewport (not a part of it shifted by the scroll), and OK scrolls into it. +#[test] +fn composition_settings_view_stays_put_when_it_scrolls() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Edit", "width": 2560, "height": 1440, "frameRate": 60, "duration": 4})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 520.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + open_settings(&mut h); + let (view, ok) = (rect_of(&h, "dialog.comp.view"), rect_of(&h, "dialog.comp.ok")); + assert!(ok.bottom() > view.bottom(), "the dialog must be taller than the window for this test: OK {ok:?}, view {view:?}"); + h.input_mut().events.push(Event::PointerMoved(view.center())); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -2000.0), + modifiers: Modifiers::NONE, + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, + }); + h.run_steps(30); + let (scrolled, ok) = (rect_of(&h, "dialog.comp.view"), rect_of(&h, "dialog.comp.ok")); + // The scroll bar may take a few points on the right while it is shown. + assert_eq!((scrolled.y_range(), scrolled.left()), (view.y_range(), view.left()), "the view is the viewport, wherever the content is scrolled to"); + assert!(scrolled.contains_rect(ok), "OK {ok:?} scrolls into the view {scrolled:?}"); +} + +/// Changing the interface language with the dialog open refits it to the new text and then +/// settles, with OK inside it: its scroll bars don't trade places every frame while it grows. +/// Switching back to English returns it to 600 points. +#[test] +fn composition_settings_refits_when_the_language_changes() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Edit", "width": 2560, "height": 1440, "frameRate": 60, "duration": 4})).unwrap(); + let mut h = Harness::builder() + .with_size(egui::vec2(1600.0, 1000.0)) + .with_step_dt(1.0 / 60.0) + .with_max_steps(30) + // `run` checks accessibility after settling; that is not what this test is about. + .with_accessibility_check(false) + .build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + open_settings(&mut h); + click(&mut h, "dialog.comp.tab.advanced"); + h.run_ok().expect("the open dialog settles"); + assert_eq!(rect_of(&h, "dialog.comp.view").width(), 600.0 - 36.0); + for (language, wider) in [("fr", true), ("en", false)] { + h.state_mut().session.prefs.general.language = language.into(); + // Steps while anything asks for a repaint, and fails if that never stops. + let steps = h.try_run().unwrap_or_else(|e| panic!("{language}: {e}")); + let (view, ok) = (rect_of(&h, "dialog.comp.view"), rect_of(&h, "dialog.comp.ok")); + assert!(view.contains_rect(ok), "{language}: after {steps} steps OK {ok:?} is outside the dialog {view:?}"); + assert_eq!(view.width() > 600.0 - 36.0, wider, "{language}: the dialog is {} wide", view.width()); + } +} From 0b59d2b8c4fc66a53c81d3850092ddcd082f8c07 Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 21:14:22 -0300 Subject: [PATCH 30/71] Follow-up to #2907: say that a uid wins over an index for `animator` animator_uid matches a uid before an index, so an animator whose uid equals an index is picked over the one at that position. The four commands that take `animator` (layer.addTextAnimatorProperty, layer.addTextSelector, text.addSelector, text.animatorFontAxes) now say so; text.addSelector also said only `uid|index` before. The test gives a second animator uid 1 and checks that `animator: 1` picks it. --- crates/engine/src/cmd/animation.rs | 10 +++++++++- crates/engine/src/cmd/text_anim.rs | 11 ++++++----- crates/engine/src/tests/text.rs | 19 +++++++++++++++---- 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/animation.rs b/crates/engine/src/cmd/animation.rs index 2c2f23f1a..4cfaa26ed 100644 --- a/crates/engine/src/cmd/animation.rs +++ b/crates/engine/src/cmd/animation.rs @@ -555,7 +555,15 @@ pub fn specs() -> Vec { has_keys, exponential_scale ), - cmd!("text.addSelector", "Add Text Selector", [], None, "{layer?, animator?: uid|index, kind: range|wiggly|expression}", has_text_layer, add_selector), + cmd!( + "text.addSelector", + "Add Text Selector", + [], + None, + "{layer?, animator?: uid|index (1-based; a uid match wins over an index), kind: range|wiggly|expression}", + has_text_layer, + add_selector + ), cmd!("text.removeAllAnimators", "Remove All Text Animators", ["Animation"], None, "{layers?}", has_text_layer, remove_all_animators), cmd!("anim.reveal", "Reveal Properties", [], None, "{kind: keyframes|animation|modified}", has_comp, reveal), ] diff --git a/crates/engine/src/cmd/text_anim.rs b/crates/engine/src/cmd/text_anim.rs index 397e697c4..ce9c1fe46 100644 --- a/crates/engine/src/cmd/text_anim.rs +++ b/crates/engine/src/cmd/text_anim.rs @@ -49,8 +49,9 @@ fn per_char_on(l: &Layer) -> bool { l.props.prop("text/perChar3d").is_some_and(|p| p.value.as_bool()) } -/// The animator a command targets: `animator` (uid or 1-based index), else the animator holding -/// a selected property, else the last one. +/// The animator a command targets: `animator` (uid or 1-based index; a uid match wins, so an +/// animator whose uid equals an index is picked over the one at that position), else the animator +/// holding a selected property, else the last one. fn animator_uid(s: &Session, l: &Layer, p: &Value, cmd: &str) -> Result { let anims = l.props.group("text/animators").ok_or_else(|| bad(cmd, "not a text layer"))?; let list: Vec<&PropGroup> = anims.groups().collect(); @@ -458,7 +459,7 @@ pub fn specs() -> Vec { "Add Property", [], None, - "{layer?, animator?: uid|index (1-based), property: (see layer.addTextAnimator)}", + "{layer?, animator?: uid|index (1-based; a uid match wins over an index), property: (see layer.addTextAnimator)}", has_animator, add_animator_prop ), @@ -467,7 +468,7 @@ pub fn specs() -> Vec { "Add Text Selector", [], None, - "{layer?, animator?: uid|index (1-based), kind: range|wiggly|expression}", + "{layer?, animator?: uid|index (1-based; a uid match wins over an index), kind: range|wiggly|expression}", has_animator, add_selector ), @@ -486,7 +487,7 @@ pub fn specs() -> Vec { "Variable Font Axes", ["Animation", "Animate Text"], None, - "{layer?, axis?: tag|name (omit to list the font's axes), animator?: uid|index (1-based)}", + "{layer?, axis?: tag|name (omit to list the font's axes), animator?: uid|index (1-based; a uid match wins over an index)}", has_text_layer, font_axes ), diff --git a/crates/engine/src/tests/text.rs b/crates/engine/src/tests/text.rs index 132bfc416..d24a09240 100644 --- a/crates/engine/src/tests/text.rs +++ b/crates/engine/src/tests/text.rs @@ -49,15 +49,16 @@ fn animate_text_adds_animators_with_companion_properties() { let _ = s.render(cid, s.time(), Default::default()); } -/// `animator` counts from 1, as `#n` references do: the commands that take it say so, and 0 fails -/// with an error that says so (agents reading `uid|index` tried 0). +/// `animator` counts from 1, as `#n` references do, and a uid match wins over an index: every +/// command that takes it says so, and 0 fails with an error that says so (agents reading +/// `uid|index` tried 0). #[test] fn animator_indices_count_from_1_and_say_so() { let (mut s, t) = session(); let uid = s.execute("layer.addTextAnimator", json!({"layer": t, "property": "position"})).unwrap()["animator"].as_u64().unwrap(); - for id in ["layer.addTextAnimatorProperty", "layer.addTextSelector", "text.animatorFontAxes"] { + for id in ["layer.addTextAnimatorProperty", "layer.addTextSelector", "text.addSelector", "text.animatorFontAxes"] { let spec = crate::cmd::command_specs().iter().find(|c| c.id == id).unwrap(); - assert!(spec.params.contains("animator?: uid|index (1-based)"), "{id}: {}", spec.params); + assert!(spec.params.contains("animator?: uid|index (1-based; a uid match wins over an index)"), "{id}: {}", spec.params); } for (id, p) in [ ("layer.addTextAnimatorProperty", json!({"layer": t, "animator": 0, "property": "fillHue"})), @@ -68,6 +69,16 @@ fn animator_indices_count_from_1_and_say_so() { } let r = s.execute("layer.addTextSelector", json!({"layer": t, "animator": 1, "kind": "wiggly"})).unwrap(); assert_eq!(r["animator"].as_u64(), Some(uid)); + // A second animator whose uid is 1: `animator: 1` is that uid, not the first animator, and + // index 2 reaches the same animator. + s.execute("layer.addTextAnimator", json!({"layer": t, "property": "opacity"})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let layer = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(t)).unwrap(); + layer.props.group_mut("text/animators").unwrap().children[1].as_group_mut().unwrap().uid = 1; + for (id, animator) in [("layer.addTextSelector", 1), ("text.addSelector", 1), ("layer.addTextSelector", 2)] { + let r = s.execute(id, json!({"layer": t, "animator": animator, "kind": "wiggly"})).unwrap(); + assert_eq!(r["animator"].as_u64(), Some(1), "{id} animator {animator}: {r}"); + } } #[test] From 94310894934f55a838d5d3b4a445a8814996dbe5 Mon Sep 17 00:00:00 2001 From: Bar Borer Date: Tue, 3 Nov 2026 03:37:31 +0200 Subject: [PATCH 31/71] Puppet: decide a marquee from where the button went down (#2865) The drag branch asked what was under the pointer when the drag was recognised, by which time a quick drag has already reached the art, so no gesture started at all. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/viewer.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 5b42488cb..570c0ebca 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1168,8 +1168,9 @@ fn phase_dragstart(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, 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 }) + // Off the pins: Alt or a press outside the art drags a marquee that selects pins. Asked + // of where the button went down: the pointer may be over the art by the time this is a drag. + (mods.alt || puppet_layer(app, map.to_comp(press)).is_none()).then_some(Gesture::PinMarquee { start: press }) } t if t.puppet_kind().is_some() => super::puppet_tool::hit(&pin_hits, press).and_then(|(h, part)| { use super::puppet_tool::PinPart; From c6ab02b83aeb7fa7aba0a4e3fbcd0ad5e7751d9f Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 22:49:52 -0300 Subject: [PATCH 32/71] Fix #2919: Match Grain takes no sample box beside transparent pixels A box's own pixels had to be opaque, but the high-pass that measures its grain reads 3 px around it, and a transparent pixel there is black once unpremultiplied. A blur earlier in the stack pads the layer with transparent pixels, so boxes along that border measured the edge as strong, smooth colour grain, and Match Grain drew 12 px colour cells from a clean source on the frames that picked them. Boxes now need their 3 px surround opaque too (a summed-area table keeps the check O(1) per candidate). Remove Grain picks its boxes the same way. --- crates/effects/src/noise.rs | 32 +++++++++++++++++++++++++++----- crates/effects/src/noise2.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/crates/effects/src/noise.rs b/crates/effects/src/noise.rs index 9d21e77a7..b6f76a268 100644 --- a/crates/effects/src/noise.rs +++ b/crates/effects/src/noise.rs @@ -254,8 +254,14 @@ pub(crate) fn preview_compose(ctx: &EffectCtx, b: &mut Buf, orig: &Image) { }); } +/// How far the grain statistics read around a sample box: their high-pass subtracts a Gaussian +/// of sigma 1.5, three box passes of radius 1 (`noise2::grain_stats_boxes`). +const SAMPLE_REACH: usize = 3; + /// Boxes (x, y, size in buffer px) where grain is sampled: the flattest of a grid of -/// candidate boxes (real grain shows best where the picture has no detail). +/// candidate boxes (real grain shows best where the picture has no detail). A box and the +/// [`SAMPLE_REACH`] px around it are opaque: a transparent pixel there is black once +/// unpremultiplied, and the high-pass would measure that edge as grain. pub(crate) fn sample_boxes(img: &Image, count: usize, size: usize) -> Vec<(usize, usize, usize)> { let (w, h) = (img.width as usize, img.height as usize); let size = size.clamp(3, w.min(h).max(3)); @@ -264,6 +270,19 @@ pub(crate) fn sample_boxes(img: &Image, count: usize, size: usize) -> Vec<(usize } let low = Plane::luma(img); let step = (size / 2).max(1); + // Transparent pixels (alpha 0.5 or less) in a summed-area table: `sat[y * (w + 1) + x]` + // counts them above and left of (x, y). + let sw = w + 1; + let mut sat = vec![0usize; sw * (h + 1)]; + for y in 0..h { + let mut run = 0; + for x in 0..w { + let opaque = img.data[y * w + x][3] > 0.5; + run += usize::from(!opaque); + sat[(y + 1) * sw + x + 1] = sat[y * sw + x + 1] + run; + } + } + let sat = sat.as_slice(); // Candidate rows in parallel; the sort below is a total order (variance, y, x), so the // result does not depend on the collection order. let rows: Vec = (0..=h - size).step_by(step).collect(); @@ -271,20 +290,23 @@ pub(crate) fn sample_boxes(img: &Image, count: usize, size: usize) -> Vec<(usize .par_iter() .flat_map_iter(|&y| { let data = low.data.as_slice(); - let alpha = img.data.as_slice(); (0..=w - size).step_by(step).filter_map(move |x| { + let (x0, y0) = (x.saturating_sub(SAMPLE_REACH), y.saturating_sub(SAMPLE_REACH)); + let (x1, y1) = ((x + size + SAMPLE_REACH).min(w), (y + size + SAMPLE_REACH).min(h)); + if sat[y1 * sw + x1] + sat[y0 * sw + x0] != sat[y0 * sw + x1] + sat[y1 * sw + x0] { + return None; + } // Variance of the picture inside the box (the flattest boxes hold the least detail). - let (mut s, mut sq, mut n, mut opaque) = (0.0f32, 0.0f32, 0.0f32, true); + let (mut s, mut sq, mut n) = (0.0f32, 0.0f32, 0.0f32); for yy in y..y + size { for xx in x..x + size { let v = data[yy * w + xx]; s += v; sq += v * v; n += 1.0; - opaque &= alpha[yy * w + xx][3] > 0.5; } } - opaque.then(|| (sq / n - (s / n).powi(2), x, y)) + Some((sq / n - (s / n).powi(2), x, y)) }) }) .collect(); diff --git a/crates/effects/src/noise2.rs b/crates/effects/src/noise2.rs index 48d840416..5ffdce5f5 100644 --- a/crates/effects/src/noise2.rs +++ b/crates/effects/src/noise2.rs @@ -464,6 +464,36 @@ mod tests { assert_eq!(out.img.data, again.img.data); } + /// #2919: a sample box beside transparent pixels measured the edge as grain. Its own + /// pixels are opaque, but the high-pass blur reads 3 px around it, where transparent pixels + /// are black once unpremultiplied. An earlier blur pads the layer with transparent pixels, + /// so a clean plate got blocky colour grain on the frames whose flattest boxes sat at the + /// edge, and fine grain on the others. + #[test] + fn match_grain_ignores_the_edge_of_a_transparent_border() { + // A flat, noiseless sky inside the 2 px transparent frame of a padded buffer. + let (w, h) = (64u32, 48u32); + let mut sky = Image::filled(w, h, [0.4, 0.6, 0.9, 1.0]); + for y in 0..h { + for x in 0..w { + if x < 2 || y < 2 || x >= w - 2 || y >= h - 2 { + sky.set(x, y, [0.0; 4]); + } + } + } + let boxes = crate::noise::sample_boxes(&sky, 80, 9); + assert!(!boxes.is_empty()); + let st = grain_stats_boxes(&sky, &boxes); + assert!(st.std.iter().all(|s| *s < 1e-5), "{st:?} from {boxes:?}"); + // So Match Grain finds no grain to add. + let host = FakeHost(sky); + let env = EffectEnv { host: Some(&host), ..Default::default() }; + let target = Image::filled(w, h, [0.2, 0.3, 0.5, 1.0]); + let out = run_fx("ec.noise.matchgrain", &[("noiseSourceLayer", Value::Layer(Some(7)))], target.clone(), 0.5, env); + let most = out.img.data.iter().zip(&target.data).flat_map(|(a, b)| (0..3).map(move |k| (a[k] - b[k]).abs())).fold(0.0f32, f32::max); + assert!(most < 1e-5, "Match Grain added {most}"); + } + #[test] fn median_legacy_radius_zero_identity_and_removes_speck() { let mut img = Image::filled(9, 9, [0.2, 0.2, 0.2, 1.0]); From 5593c6e8b3fe40d0f6b3a6205f23ab0c673540d1 Mon Sep 17 00:00:00 2001 From: Bar Borer Date: Tue, 3 Nov 2026 03:57:05 +0200 Subject: [PATCH 33/71] Tests: give the key box test's comp a frame rate, so its keys stay on whole seconds Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/timeline/tests.rs b/crates/ui-egui/src/panels/timeline/tests.rs index a6e2a97a9..9db6288bc 100644 --- a/crates/ui-egui/src/panels/timeline/tests.rs +++ b/crates/ui-egui/src/panels/timeline/tests.rs @@ -652,7 +652,7 @@ fn a_keys_glyph_rect_is_what_it_draws() { #[test] fn keys_in_box_walks_the_rows_and_tests_each_glyph() { let mut s = effectcraft_engine::Session::default(); - s.execute("comp.new", json!({"name": "C", "width": 64, "height": 64, "duration": 4})).unwrap(); + s.execute("comp.new", json!({"name": "C", "width": 64, "height": 64, "duration": 4, "frameRate": 30})).unwrap(); let mut layers = vec![]; for name in ["A", "B"] { let id = s.execute("layer.newSolid", json!({"name": name, "color": "#fff"})).unwrap()["layer"].as_u64().unwrap(); From fa0c260630c26a41d16220bd1eca44791f66c04c Mon Sep 17 00:00:00 2001 From: Bar Borer Date: Tue, 3 Nov 2026 04:00:19 +0200 Subject: [PATCH 34/71] Puppet: refuse only a mesh that repeats one already there (#2865) Cut the fix down: a click off every mesh makes nothing only when the art it resolves to already has a mesh with that outline. The nearest-art fallback in `region` stays, so a near miss beside separate art still meshes it. `mesh::outline` is as it was and the `newMesh` option of puppet.addPin is back. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/puppet.rs | 21 ---------------- crates/effects/src/puppet/mesh.rs | 26 +------------------ crates/effects/src/puppet/tests.rs | 37 --------------------------- crates/engine/src/cmd/puppet.rs | 40 ++++++++++++++++++++---------- crates/engine/src/tests/paint.rs | 27 ++++++++++++++------ 5 files changed, 47 insertions(+), 104 deletions(-) diff --git a/crates/effects/src/puppet.rs b/crates/effects/src/puppet.rs index 4db76c1f5..6ec2ce5ba 100644 --- a/crates/effects/src/puppet.rs +++ b/crates/effects/src/puppet.rs @@ -395,27 +395,6 @@ pub fn contains(mesh: &Mesh, deformed: &[[f64; 2]], p: [f64; 2]) -> bool { }) } -/// Whether `at` (effect space) is art a new mesh with this `expansion` could take: on the expanded -/// alpha itself, and not what a mesh of `params` already covers at rest, even if its deformation -/// has carried that art away. A mesh there would only rebuild the same outline: a full copy (#2865). -pub fn unmeshed_art_at(buf: &Buf, params: &Params, at: [f64; 2], expansion: f64) -> bool { - let (w, h) = (buf.img.width as usize, buf.img.height as usize); - let alpha: Vec = buf.img.data.iter().map(|p| p[3]).collect(); - let mut outline = mesh::outline_under(&alpha, w, h, buf.scale.max(1e-9), buf.offset, at, expansion); - if outline.is_empty() { - return false; - } - // As `triangulate` winds it. The simplified outline can cut a corner past `at`, so a mesh can - // miss the very art it was built from: the same outline is the same art. - if mesh::signed_area(&outline) < 0.0 { - outline.reverse(); - } - !parse(params).iter().any(|m| { - let mesh = mesh_for(buf, m); - mesh.outline == outline || contains(&mesh, &mesh.verts, at) - }) -} - #[inline] fn bary(a: [f64; 2], b: [f64; 2], c: [f64; 2], p: [f64; 2]) -> Option<[f64; 3]> { let d = (b[1] - c[1]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[1] - c[1]); diff --git a/crates/effects/src/puppet/mesh.rs b/crates/effects/src/puppet/mesh.rs index 1046dfa6e..f01ee8f78 100644 --- a/crates/effects/src/puppet/mesh.rs +++ b/crates/effects/src/puppet/mesh.rs @@ -194,11 +194,6 @@ pub fn region(g: &Grid, seed: [f64; 2]) -> Option { Some(Grid { w, h, on: outside.iter().map(|o| !o).collect() }) } -/// The region under `seed` only when `seed` is on a cell: `region` without its nearest fallback. -pub fn region_under(g: &Grid, seed: [f64; 2]) -> Option { - if g.at(seed[0].floor() as i64, seed[1].floor() as i64) { region(g, seed) } else { None } -} - /// Trace the outer boundary of a hole-free region along pixel cracks: a closed polygon through /// pixel corners, region on the left when walking (counter-clockwise in y-down coordinates). pub fn trace(g: &Grid) -> Vec<[f64; 2]> { @@ -622,25 +617,6 @@ pub fn triangulate(outline: &[[f64; 2]], opts: &MeshOpts) -> Mesh { /// The outline of the alpha region under `seed` (buffer pixels → layer space via `scale` and /// `offset`), expanded by `expansion` layer pixels. pub fn outline(alpha: &[f32], w: usize, h: usize, scale: f64, offset: [f64; 2], seed: [f64; 2], expansion: f64) -> Vec<[f64; 2]> { - outline_of(alpha, w, h, scale, offset, seed, expansion, region) -} - -/// As `outline`, but empty unless `seed` is on the expanded alpha itself. -pub fn outline_under(alpha: &[f32], w: usize, h: usize, scale: f64, offset: [f64; 2], seed: [f64; 2], expansion: f64) -> Vec<[f64; 2]> { - outline_of(alpha, w, h, scale, offset, seed, expansion, region_under) -} - -#[allow(clippy::too_many_arguments)] -fn outline_of( - alpha: &[f32], - w: usize, - h: usize, - scale: f64, - offset: [f64; 2], - seed: [f64; 2], - expansion: f64, - pick: fn(&Grid, [f64; 2]) -> Option, -) -> Vec<[f64; 2]> { // Pad so expansion can grow past the buffer edge. let pad = (expansion.max(0.0) * scale).ceil() as usize + 2; let (pw, ph) = (w + 2 * pad, h + 2 * pad); @@ -652,7 +628,7 @@ fn outline_of( } let g = expand(&Grid { w: pw, h: ph, on }, expansion * scale); let sp = [seed[0] * scale + offset[0] + pad as f64, seed[1] * scale + offset[1] + pad as f64]; - let Some(r) = pick(&g, sp) else { return vec![] }; + let Some(r) = region(&g, sp) else { return vec![] }; let c = trace(&r); let c = simplify(&c, 0.75); c.iter().map(|p| [(p[0] - pad as f64 - offset[0]) / scale, (p[1] - pad as f64 - offset[1]) / scale]).collect() diff --git a/crates/effects/src/puppet/tests.rs b/crates/effects/src/puppet/tests.rs index 56dbbf982..10426d46c 100644 --- a/crates/effects/src/puppet/tests.rs +++ b/crates/effects/src/puppet/tests.rs @@ -323,40 +323,3 @@ fn single_pin_on_a_traced_solid_does_not_rotate_it() { } } } - -/// #2865: only art no mesh has takes a new mesh, however close the rest is. -#[test] -fn unmeshed_art_is_strictly_under_the_point_and_not_already_meshed() { - // Two discs; the mesh is on the left one, at rest. - let (w, h) = (96usize, 48usize); - let (l, r) = (disc_alpha(w, h, [24.0, 24.0], 18.0), disc_alpha(w, h, [72.0, 24.0], 12.0)); - let mut img = Image::new(w as u32, h as u32); - for (i, p) in img.data.iter_mut().enumerate() { - *p = [1.0, 1.0, 1.0, l[i].max(r[i])]; - } - let buf = Buf { img, offset: [0.0; 2], scale: 1.0 }; - let params = flatten_params(&puppet_fx(&[(PinKind::Position, [24.0, 24.0], [24.0, 24.0])]), &mut |p| p.value.clone()); - let e = 3.0; - assert!(unmeshed_art_at(&buf, ¶ms, [72.0, 24.0], e), "the other disc"); - assert!(!unmeshed_art_at(&buf, ¶ms, [24.0, 24.0], e), "the meshed disc"); - assert!(!unmeshed_art_at(&buf, ¶ms, [48.0, 24.0], e), "between them: no nearest fallback"); - assert!(!unmeshed_art_at(&buf, ¶ms, [500.0, 24.0], e), "off the buffer"); - // Pixel corners of the meshed disc that simplifying the outline left just outside the triangles: - // on its art, off its mesh, and a mesh there would be the same mesh again. - let m = parse(¶ms).remove(0); - let mesh = mesh_for(&buf, &m); - let mut slivers = 0; - for y in 0..h { - for x in 0..48 { - for (dx, dy) in [(0.02, 0.02), (0.98, 0.02), (0.02, 0.98), (0.98, 0.98)] { - let p = [x as f64 + dx, y as f64 + dy]; - if contains(&mesh, &mesh.verts, p) || mesh::outline_under(&l, w, h, 1.0, [0.0; 2], p, e).is_empty() { - continue; - } - slivers += 1; - assert!(!unmeshed_art_at(&buf, ¶ms, p, e), "{p:?}"); - } - } - } - assert!(slivers > 0, "no point of the art falls outside the mesh: this test proves nothing"); -} diff --git a/crates/engine/src/cmd/puppet.rs b/crates/engine/src/cmd/puppet.rs index a11a073b9..d894516c2 100644 --- a/crates/engine/src/cmd/puppet.rs +++ b/crates/engine/src/cmd/puppet.rs @@ -8,7 +8,7 @@ //! Pin positions are in effect space. A new pin is attached to the mesh under it: the click is //! mapped back through the current deformation to the rest mesh (its hidden rest position), and //! Position / Advanced pins get a Position keyframe at the current time, as in After Effects. Off -//! every mesh a pin starts a new mesh only on art no mesh has yet; elsewhere nothing happens (#2865). +//! every mesh a pin starts a new mesh, unless its art already has one (#2865). use effectcraft_effects::puppet::{self, MeshOpts, PinKind}; use effectcraft_effects::{Buf, Params}; @@ -68,12 +68,25 @@ fn on_mesh(mesh: &puppet::Mesh, def: &[[f64; 2]], at: [f64; 2]) -> bool { !mesh.tris.is_empty() && (puppet::contains(mesh, def, at) || def.iter().map(|v| (v[0] - at[0]).hypot(v[1] - at[1])).fold(f64::MAX, f64::min) < 12.0) } -/// Whether `puppet.addPin` at `at` (effect space) places a pin on a layer with a Puppet effect: on -/// a mesh, on art no mesh has, or anywhere when there is no mesh yet (the first pin). +/// Whether a new mesh at `at` (effect space) would repeat a mesh of `params`: the art it would +/// outline is that mesh's own, even if a deformation has carried the mesh off it (#2865). +fn repeats_mesh(buf: &Buf, params: &Params, at: [f64; 2]) -> bool { + let (w, h) = (buf.img.width as usize, buf.img.height as usize); + let alpha: Vec = buf.img.data.iter().map(|p| p[3]).collect(); + puppet::parse(params).iter().any(|m| { + let mut outline = puppet::mesh::outline(&alpha, w, h, buf.scale.max(1e-9), buf.offset, at, m.opts.expansion); + // As `triangulate` winds it. + if puppet::mesh::signed_area(&outline) < 0.0 { + outline.reverse(); + } + puppet::mesh_for(buf, m).outline == outline + }) +} + +/// Whether `puppet.addPin` at `at` (effect space) places a pin on a layer with a Puppet effect. pub fn places_pin(s: &Session, cid: ItemId, lid: LayerId, at: [f64; 2], t: Tick) -> bool { let Some((buf, params)) = find_puppet(s, cid, lid).and_then(|fu| puppet_eval(s, cid, lid, fu, t)) else { return true }; - let meshes = puppet::overlay(&buf, ¶ms); - meshes.is_empty() || meshes.iter().any(|(_, mesh, def, _)| on_mesh(mesh, def, at)) || puppet::unmeshed_art_at(&buf, ¶ms, at, s.state.puppet.expansion) + !repeats_mesh(&buf, ¶ms, at) || puppet::overlay(&buf, ¶ms).iter().any(|(_, mesh, def, _)| on_mesh(mesh, def, at)) } fn add_pin(s: &mut Session, p: &Value) -> Result { @@ -92,17 +105,14 @@ fn add_pin(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "the Puppet tools work on footage, solid, text, shape and precomp layers")); } } - let opts = MeshOpts { - density: f_p(p, "density").unwrap_or(s.state.puppet.density), - expansion: f_p(p, "expansion").unwrap_or(s.state.puppet.expansion), - triangles: f_p(p, "triangles").unwrap_or(s.state.puppet.triangles), - }; // Which mesh: the one under the click (through the current deformation), else a new one. let fx_uid = find_puppet(s, cid, lid); let want_mesh = p.get("mesh").and_then(Value::as_u64); + let new_mesh = p.get("newMesh").and_then(Value::as_bool).unwrap_or(false); // (mesh, the pin's rest point, where it is now) let mut target: Option<(Uid, [f64; 2], [f64; 2])> = None; if let Some(fu) = fx_uid + && !new_mesh && let Some((buf, params)) = puppet_eval(s, cid, lid, fu, t) { let meshes = puppet::overlay(&buf, ¶ms); @@ -135,11 +145,15 @@ fn add_pin(s: &mut Session, p: &Value) -> Result { { return Err(bad(cmd, format!("no mesh {w}"))); } - // Nothing to pin here: empty space, or art a mesh has already carried away (#2865). - if target.is_none() && !meshes.is_empty() && !puppet::unmeshed_art_at(&buf, ¶ms, at, opts.expansion) { + if target.is_none() && repeats_mesh(&buf, ¶ms, at) { return Ok(json!({"pin": null, "changed": false})); } } + let opts = MeshOpts { + density: f_p(p, "density").unwrap_or(s.state.puppet.density), + expansion: f_p(p, "expansion").unwrap_or(s.state.puppet.expansion), + triangles: f_p(p, "triangles").unwrap_or(s.state.puppet.triangles), + }; let size = { let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let l = comp.layer(lid).ok_or(EngineError::NoComp)?; @@ -666,7 +680,7 @@ pub fn specs() -> Vec { "Add Puppet Pin", [], None, - "{layer?, kind?: position|advanced|bend|starch|overlap, position: [x,y] (effect space), time? (s) | frame?, mesh?: uid, density?, expansion?, triangles?}", + "{layer?, kind?: position|advanced|bend|starch|overlap, position: [x,y] (effect space), time? (s) | frame?, mesh?: uid, newMesh?, density?, expansion?, triangles?}", has_comp, add_pin ), diff --git a/crates/engine/src/tests/paint.rs b/crates/engine/src/tests/paint.rs index ccb5b3dfe..2d2e540a5 100644 --- a/crates/engine/src/tests/paint.rs +++ b/crates/engine/src/tests/paint.rs @@ -252,33 +252,44 @@ fn a_pin_where_the_deformed_layer_is_empty_creates_nothing() { } assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (1, 2)); assert_eq!(s.render(cid, t, RenderOpts::default()), before); + // Whatever Expansion new meshes would now get, the art is the mesh's own. + s.state.puppet.expansion = 9.0; + assert_eq!(s.execute("puppet.addPin", json!({"layer": id, "position": [50, 10]})).unwrap()["pin"], Value::Null); // On the deformed shape a pin still goes on the mesh. assert!(crate::commands::puppet::places_pin(&s, cid, lid, [50.0, 70.0], t)); assert!(s.execute("puppet.addPin", json!({"layer": id, "position": [50, 70]})).unwrap()["pin"].is_u64()); assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (1, 3)); } -/// #2865: art no mesh has still takes its own mesh; the gap beside it takes none. The first pin of -/// a layer is as ever: it finds the art nearest the click. +/// #2865: separate art still gets its own mesh, also from a near miss in the gap beside it (a click +/// off the art finds the art nearest it): only a repeat of a mesh is refused. #[test] -fn only_unmeshed_art_takes_a_new_mesh() { +fn separate_art_still_gets_its_own_mesh() { let (mut s, id) = setup(); for x in [[0, 30], [60, 100]] { s.execute("mask.new", json!({"layer": id, "vertices": [[x[0], 0], [x[1], 0], [x[1], 60], [x[0], 60]], "closed": true})).unwrap(); } - s.execute("puppet.addPin", json!({"layer": id, "position": [46, 30]})).unwrap(); - assert_eq!(mesh_count(&mut s, id), 1, "the first pin"); - s.undo(); s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap(); - // In the gap, past reach of the left piece's mesh (Expansion takes it to x 33). - assert_eq!(s.execute("puppet.addPin", json!({"layer": id, "position": [46, 30]})).unwrap()["pin"], Value::Null); assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (1, 1)); + // In the gap, nearer the right piece and past reach of the left piece's mesh. + s.execute("puppet.addPin", json!({"layer": id, "position": [46, 30]})).unwrap(); + assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (2, 2), "the near miss meshes the right piece"); + s.undo(); s.execute("puppet.addPin", json!({"layer": id, "position": [80, 30]})).unwrap(); assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (2, 2), "the right piece gets its own mesh"); s.execute("puppet.addPin", json!({"layer": id, "position": [80, 50]})).unwrap(); assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (2, 3), "and the next pin goes on it"); } +/// `newMesh` asks for a new mesh wherever the click is, the art's own mesh or not. +#[test] +fn new_mesh_option_starts_a_mesh_on_meshed_art() { + let (mut s, id) = setup(); + s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap(); + s.execute("puppet.addPin", json!({"layer": id, "position": [50, 30], "newMesh": true})).unwrap(); + assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (2, 2)); +} + #[test] fn puppet_pin_kinds_and_mesh_options() { let (mut s, id) = setup(); From 583ba084b67637614cf6060054085223ec141ffb Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 22:50:04 -0300 Subject: [PATCH 35/71] Fix #2917: material.set names the Material Options a layer doesn't have A 3D model layer keeps its file's materials, so it has no Base Color, Metallic, Roughness or Emissive. material.set refused them with "no Material Options property matched (..., baseColor, metallic, roughness, ...)", naming the parameters it had just refused, and next to a parameter the layer has it dropped them and reported success. A parameter that none of the given layers has now fails the command (nothing is set), naming it and the properties the first layer has. A value of the wrong type is its own error. The parameter list says which layer kinds take which. --- crates/engine/src/cmd/model3d.rs | 98 ++++++++++++++++++++++-------- crates/engine/src/tests/model3d.rs | 37 +++++++++++ 2 files changed, 111 insertions(+), 24 deletions(-) diff --git a/crates/engine/src/cmd/model3d.rs b/crates/engine/src/cmd/model3d.rs index 2cc4d34e6..f4e62f6b7 100644 --- a/crates/engine/src/cmd/model3d.rs +++ b/crates/engine/src/cmd/model3d.rs @@ -171,9 +171,29 @@ fn kind_p(p: &Value, cmd: &str) -> Result { PrimitiveKind::parse(k).ok_or_else(|| bad(cmd, format!("kind: cube|sphere|plane|torus|cone|cylinder, not `{k}`"))) } -/// Parse Material Options parameters onto a layer at comp time `t`. -fn apply_material(l: &mut Layer, p: &Value, t: Tick, cmd: &str) -> Result { - let mut n = 0; +/// The Material Options parameters [`apply_material`] reads. Which ones a layer has depends on +/// its kind (see [`material_mismatch`]). +const MATERIAL_PARAMS: [&str; 14] = [ + "castsShadows", + "acceptsShadows", + "acceptsLights", + "appearsInReflections", + "baseColor", + "metallic", + "roughness", + "emissive", + "lightTransmission", + "ambient", + "diffuse", + "specularIntensity", + "specularShininess", + "metal", +]; + +/// Parse Material Options parameters onto a layer at comp time `t`. The parameters the layer +/// had a property for. +fn apply_material(l: &mut Layer, p: &Value, t: Tick, cmd: &str) -> Result> { + let mut set = Vec::new(); let tri = |v: &Value| -> Option { match v { Value::Bool(b) => Some(*b as u32), @@ -187,28 +207,55 @@ fn apply_material(l: &mut Layer, p: &Value, t: Tick, cmd: &str) -> Result _ => None, } }; + let mut put = |l: &mut Layer, k: &'static str, v: KV| { + if set_at(l, &format!("materialOptions/{k}"), t, v) { + set.push(k); + } + }; for k in ["castsShadows", "acceptsShadows"] { if let Some(v) = p.get(k) { let e = tri(v).ok_or_else(|| bad(cmd, format!("{k}: off|on|only")))?; - n += set_at(l, &format!("materialOptions/{k}"), t, KV::Enum(e)) as usize; + put(l, k, KV::Enum(e)); } } + // A value of the wrong type is an error, not a parameter left out. for k in ["acceptsLights", "appearsInReflections"] { - if let Some(v) = b_p(p, k) { - n += set_at(l, &format!("materialOptions/{k}"), t, KV::Bool(v)) as usize; + if p.get(k).is_some() { + let v = b_p(p, k).ok_or_else(|| bad(cmd, format!("{k}: true|false")))?; + put(l, k, KV::Bool(v)); } } for k in ["lightTransmission", "ambient", "diffuse", "specularIntensity", "specularShininess", "metal", "metallic", "roughness"] { - if let Some(v) = f_p(p, k) { - n += set_at(l, &format!("materialOptions/{k}"), t, KV::Scalar(v)) as usize; + if p.get(k).is_some() { + let v = f_p(p, k).ok_or_else(|| bad(cmd, format!("{k}: a number")))?; + put(l, k, KV::Scalar(v)); } } for k in ["baseColor", "emissive"] { - if let Some(c) = color_p(p, k) { - n += set_at(l, &format!("materialOptions/{k}"), t, KV::Color([c[0] as f64, c[1] as f64, c[2] as f64, 1.0])) as usize; + if p.get(k).is_some() { + let c = color_p(p, k).ok_or_else(|| bad(cmd, format!("{k}: \"#rrggbb\" or [r, g, b]")))?; + put(l, k, KV::Color([c[0] as f64, c[1] as f64, c[2] as f64, 1.0])); } } - Ok(n) + Ok(set) +} + +/// The error for Material Options parameters `missing` that none of the layers has: what the +/// first layer has instead, and which layers the missing ones belong to. +fn material_mismatch(l: Option<&Layer>, missing: &[&str]) -> EngineError { + let names = missing.iter().map(|k| format!("`{k}`")).collect::>().join(", "); + let Some(l) = l else { return bad("material.set", format!("no layer has {names}")) }; + let Some(g) = l.props.sub("materialOptions") else { return bad("material.set", format!("`{}` has no Material Options", l.name)) }; + let has: Vec<&str> = g.children.iter().map(|c| c.match_id()).collect(); + let why = if matches!(l.source, LayerSource::Model { .. }) { ": a 3D model layer keeps its model file's materials" } else { "" }; + bad( + "material.set", + format!( + "`{}` has no {names}{why}. Its Material Options: {}. baseColor, metallic, roughness and emissive are for 3D primitive layers; lightTransmission, ambient, diffuse, specularIntensity, specularShininess and metal for footage, solid, text, shape and precomp layers", + l.name, + has.join(", ") + ), + ) } fn new_primitive(s: &mut Session, p: &Value) -> Result { @@ -273,19 +320,22 @@ fn material_set(s: &mut Session, p: &Value) -> Result { } let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let t = super::time_p(s, p, Some(&comp)); - let n = s.edit("Material Options", None, |proj, _| { - let mut n = 0; - for id in &ids { - n += apply_material(layer_mut(proj, cid, *id)?, p, t, "material.set")?; - } - Ok(n) - })?; - if n == 0 { - return Err(bad( - "material.set", - "no Material Options property matched (castsShadows, acceptsShadows, acceptsLights, baseColor, metallic, roughness, …)", - )); + if !MATERIAL_PARAMS.iter().any(|k| p.get(*k).is_some()) { + return Err(bad("material.set", format!("no Material Options parameter given ({})", MATERIAL_PARAMS.join(", ")))); } + // A parameter none of the layers has fails the whole command (the edit is dropped), so + // it is never skipped without a word. + let n = s.edit("Material Options", None, |proj, _| { + let mut set = Vec::new(); + for id in &ids { + set.extend(apply_material(layer_mut(proj, cid, *id)?, p, t, "material.set")?); + } + let missing: Vec<&str> = MATERIAL_PARAMS.into_iter().filter(|k| p.get(*k).is_some() && !set.contains(k)).collect(); + if !missing.is_empty() { + return Err(material_mismatch(ids.first().and_then(|id| proj.comp(cid)?.layer(*id)), &missing)); + } + Ok(set.len()) + })?; Ok(json!({"set": n})) } @@ -411,7 +461,7 @@ pub fn specs() -> Vec { "Material Options", [], None, - "{layers?, castsShadows?: off|on|only, acceptsShadows?: off|on|only, acceptsLights?, appearsInReflections?, lightTransmission?, ambient?, diffuse?, specularIntensity?, specularShininess?, metal?, baseColor?, metallic?, roughness?, emissive?, time?}", + "{layers?, time?, castsShadows?: off|on|only, acceptsShadows?: off|on|only, acceptsLights?, appearsInReflections? (model and primitive layers), baseColor?, metallic?, roughness?, emissive? (primitive layers; a model layer keeps its file's materials), lightTransmission?, ambient?, diffuse?, specularIntensity?, specularShininess?, metal? (footage, solid, text, shape and precomp layers)}; a parameter none of the layers has is an error", has_layers, material_set ), diff --git a/crates/engine/src/tests/model3d.rs b/crates/engine/src/tests/model3d.rs index 40a165799..548e93b20 100644 --- a/crates/engine/src/tests/model3d.rs +++ b/crates/engine/src/tests/model3d.rs @@ -232,6 +232,43 @@ fn material_commands_and_serde() { assert_eq!(&back, &*s.project); } +/// #2917: a 3D model layer keeps its file's materials, so it has no Base Color, Metallic or +/// Roughness. material.set refused them with "no Material Options property matched (…, +/// baseColor, metallic, roughness, …)", naming the very parameters it refused, and next to a +/// parameter the layer does have it dropped them without a word. +#[test] +fn material_set_names_the_parameters_a_layer_lacks() { + let mut s = session(); + let r = s.execute("file.import", json!({"paths": [fixture("quad.glb")]})).unwrap(); + let item = r["items"][0].as_u64().unwrap(); + let m = s.execute("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap(); + let e = s.execute_checked("material.set", json!({"layers": [m], "baseColor": "#3366ff", "roughness": 20})).unwrap_err().to_string(); + assert!(e.contains("`baseColor`, `roughness`") && e.contains("model file") && e.contains("appearsInReflections"), "{e}"); + assert!(!e.contains("`metallic`"), "{e}"); + // A parameter the layer has doesn't hide one it lacks, and nothing is set. + assert!(s.execute_checked("material.set", json!({"layers": [m], "castsShadows": "on", "metallic": 50})).is_err()); + assert_eq!(layer(&s, m).props.prop("materialOptions/castsShadows").unwrap().value, KV::Enum(0)); + s.execute_checked("material.set", json!({"layers": [m], "castsShadows": "on"})).unwrap(); + assert_eq!(layer(&s, m).props.prop("materialOptions/castsShadows").unwrap().value, KV::Enum(1)); + // Across layers, a parameter goes to the ones that have it. + let cube = s.execute("layer.newPrimitive", json!({"kind": "cube"})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(s.execute_checked("material.set", json!({"layers": [m, cube], "roughness": 20})).unwrap()["set"], 1); + assert_eq!(layer(&s, cube).props.prop("materialOptions/roughness").unwrap().value.as_f64(), 20.0); + // A solid has the classic shading, not the primitive's material. + let solid = s.execute("layer.newSolid", json!({"width": 64, "height": 32})).unwrap()["layer"].as_u64().unwrap(); + let e = s.execute_checked("material.set", json!({"layers": [solid], "emissive": "#ffffff"})).unwrap_err().to_string(); + assert!(e.contains("`emissive`") && e.contains("specularShininess"), "{e}"); + assert!(s.execute_checked("material.set", json!({"layers": [m]})).is_err()); + // The parameter list still names every parameter, or agents' calls are refused before they run. + let spec = crate::command_specs().iter().find(|c| c.id == "material.set").unwrap(); + let keys = crate::commands::accepted_params(spec.params).unwrap(); + let want = "layers time castsShadows acceptsShadows acceptsLights appearsInReflections baseColor metallic roughness emissive \ + lightTransmission ambient diffuse specularIntensity specularShininess metal"; + for k in want.split_whitespace() { + assert!(keys.iter().any(|a| a == k), "{k}: {keys:?}"); + } +} + /// #264: only Advanced 3D draws models and primitives, and new comps are Classic 3D, so an /// imported model was invisible. Adding one switches the comp to Advanced 3D (in the same undo /// step, with a toast), as After Effects does. From 1b5c28ee1d07fb6a506490793cd756640464b5aa Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 23:17:04 -0300 Subject: [PATCH 36/71] Follow-up to #2919: Match Grain matches no grain without a clean sample box A source with no box-sized area clear of transparent pixels (a small layer inside a blur's padding) got no sample boxes, and grain_stats_boxes then measured the whole image, edge included. With no clean box Match Grain now matches no grain, as After Effects applies none when its samples hold transparent areas. Compensate for Existing Noise took the same route through the layer's own boxes, so a layer with no clean box now has nothing taken off. grain_stats_boxes keeps its empty-list fallback. --- crates/effects/src/noise2.rs | 65 +++++++++++++++++++++++------------- 1 file changed, 41 insertions(+), 24 deletions(-) diff --git a/crates/effects/src/noise2.rs b/crates/effects/src/noise2.rs index 5ffdce5f5..56de9943e 100644 --- a/crates/effects/src/noise2.rs +++ b/crates/effects/src/noise2.rs @@ -190,15 +190,14 @@ fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { b.img = img; return b; } - let src_stats = grain_stats_boxes(&source, &src_boxes); + // No box with an opaque surround (a small layer inside transparent pixels) means no clean + // sample: no grain to match, as After Effects applies none when its samples hold transparent + // areas. The whole-image fallback of `grain_stats_boxes` would measure the edge again. + let clean_stats = |img: &Image, boxes: &[(usize, usize, usize)]| if boxes.is_empty() { GrainStats::default() } else { grain_stats_boxes(img, boxes) }; + let src_stats = clean_stats(&source, &src_boxes); let comp = (ctx.params.f("compensateForExistingNoise") / 100.0) as f32; // The own-layer boxes only matter for the compensation term; skip them when it is off. - let own = if comp > 0.0 { - let own_boxes = sampling_boxes(ctx, &b.img, b.scale); - grain_stats_boxes(&b.img, &own_boxes) - } else { - GrainStats::default() - }; + let own = if comp > 0.0 { clean_stats(&b.img, &sampling_boxes(ctx, &b.img, b.scale)) } else { GrainStats::default() }; let orig = (view == 0).then(|| b.img.clone()); let look = GrainLook::from_params(ctx); let amp: [f32; 3] = [0, 1, 2].map(|k| { @@ -471,27 +470,45 @@ mod tests { /// edge, and fine grain on the others. #[test] fn match_grain_ignores_the_edge_of_a_transparent_border() { - // A flat, noiseless sky inside the 2 px transparent frame of a padded buffer. - let (w, h) = (64u32, 48u32); - let mut sky = Image::filled(w, h, [0.4, 0.6, 0.9, 1.0]); - for y in 0..h { - for x in 0..w { - if x < 2 || y < 2 || x >= w - 2 || y >= h - 2 { - sky.set(x, y, [0.0; 4]); + // A flat colour inside the 2 px transparent frame of a padded buffer. + let framed = |w: u32, h: u32, c: [f32; 4]| { + let mut img = Image::filled(w, h, c); + for y in 0..h { + for x in 0..w { + if x < 2 || y < 2 || x >= w - 2 || y >= h - 2 { + img.set(x, y, [0.0; 4]); + } } } + img + }; + let most_change = |a: &Image, b: &Image| a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..3).map(move |k| (p[k] - q[k]).abs())).fold(0.0f32, f32::max); + // A noiseless sky. At 16 px no 9 px box has an opaque 3 px surround, so there is no + // clean sample at all (the whole-image fallback would measure the edge). + for (w, h, clean_boxes) in [(64u32, 48u32, true), (16, 16, false)] { + let sky = framed(w, h, [0.4, 0.6, 0.9, 1.0]); + let boxes = crate::noise::sample_boxes(&sky, 80, 9); + assert_eq!(!boxes.is_empty(), clean_boxes, "{w}x{h}: {boxes:?}"); + if clean_boxes { + let st = grain_stats_boxes(&sky, &boxes); + assert!(st.std.iter().all(|s| *s < 1e-5), "{w}x{h}: {st:?} from {boxes:?}"); + } + // So Match Grain finds no grain to add. + let host = FakeHost(sky); + let env = EffectEnv { host: Some(&host), ..Default::default() }; + let target = Image::filled(w, h, [0.2, 0.3, 0.5, 1.0]); + let out = run_fx("ec.noise.matchgrain", &[("noiseSourceLayer", Value::Layer(Some(7)))], target.clone(), 0.5, env); + let most = most_change(&out.img, &target); + assert!(most < 1e-5, "{w}x{h}: Match Grain added {most}"); } - let boxes = crate::noise::sample_boxes(&sky, 80, 9); - assert!(!boxes.is_empty()); - let st = grain_stats_boxes(&sky, &boxes); - assert!(st.std.iter().all(|s| *s < 1e-5), "{st:?} from {boxes:?}"); - // So Match Grain finds no grain to add. - let host = FakeHost(sky); + // Compensate for Existing Noise has no clean sample of such a layer either, so its edge + // doesn't cancel the source's grain. + let host = FakeHost(noisy(16, 16, 0.1, 3)); let env = EffectEnv { host: Some(&host), ..Default::default() }; - let target = Image::filled(w, h, [0.2, 0.3, 0.5, 1.0]); - let out = run_fx("ec.noise.matchgrain", &[("noiseSourceLayer", Value::Layer(Some(7)))], target.clone(), 0.5, env); - let most = out.img.data.iter().zip(&target.data).flat_map(|(a, b)| (0..3).map(move |k| (a[k] - b[k]).abs())).fold(0.0f32, f32::max); - assert!(most < 1e-5, "Match Grain added {most}"); + let target = framed(16, 16, [0.9, 0.9, 0.9, 1.0]); + let params = [("noiseSourceLayer", Value::Layer(Some(7))), ("compensateForExistingNoise", crate::num(100.0))]; + let out = run_fx("ec.noise.matchgrain", ¶ms, target.clone(), 0.5, env); + assert!(most_change(&out.img, &target) > 0.01, "the layer's own edge cancelled the grain"); } #[test] From 12a61c5cc9a0fb730400fb6bad0389725ef5db45 Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 23:24:09 -0300 Subject: [PATCH 37/71] Export tests: check the bitstream colour on the bare elementary stream The bitstream check read ffprobe's first-frame tags from the MP4, which ffmpeg can fill from the colr box when a stream says nothing itself. Copy the video out as a bare h264, hevc or obu stream first, so only the SPS VUI or AV1 color_config counts. --- crates/export/tests/it/movie_color.rs | 35 ++++++++++++--------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/crates/export/tests/it/movie_color.rs b/crates/export/tests/it/movie_color.rs index 595157637..1a5371d91 100644 --- a/crates/export/tests/it/movie_color.rs +++ b/crates/export/tests/it/movie_color.rs @@ -1,6 +1,7 @@ //! H.264, HEVC and AV1 MP4s describe the frames the render path made (#2879): the `colr` box, read //! back with FilmCraft's demuxer, and the bitstream's own colour description (the SPS VUI, AV1's -//! `color_config`), which ffprobe reports for the first decoded frame when it can decode it. +//! `color_config`), which ffprobe reports for the first frame of the bare elementary stream when +//! ffmpeg can copy it out and decode it. use std::path::Path; use std::process::Command; @@ -49,23 +50,19 @@ fn render(output: Option, format: OutputFormat, path: &Path) { export(&job, &mut |_| true).expect("export"); } -/// ffprobe's primaries, transfer and matrix of the first decoded frame, or `None` without ffprobe -/// or when its build can't decode the stream. -fn frame_tags(path: &Path) -> Option> { +/// ffprobe's primaries, transfer and matrix of the first frame of the movie's video copied out as a +/// bare elementary stream (`h264`, `hevc` or `obu`), so no container metadata can stand in for the +/// bitstream's own; `None` without ffmpeg or when its build can't copy or decode the stream. +fn bitstream_tags(path: &Path, format: &str) -> Option> { + let es = path.with_extension(format); + let copied = + Command::new("ffmpeg").args(["-v", "error", "-y", "-i"]).arg(path).args(["-map", "0:v:0", "-c", "copy", "-f", format]).arg(&es).status().ok()?; + if !copied.success() { + return None; + } let out = Command::new("ffprobe") - .args([ - "-v", - "error", - "-select_streams", - "v:0", - "-read_intervals", - "%+#1", - "-show_entries", - "frame=color_primaries,color_transfer,color_space", - "-of", - "default=nw=1", - ]) - .arg(path) + .args(["-v", "error", "-read_intervals", "%+#1", "-show_entries", "frame=color_primaries,color_transfer,color_space", "-of", "default=nw=1"]) + .arg(&es) .output() .ok()?; let text = String::from_utf8_lossy(&out.stdout).to_string(); @@ -78,7 +75,7 @@ fn mp4_tags_describe_the_frames() { let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export/movie_color"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("mkdir"); - for (format, codec) in [(OutputFormat::H264, "h264"), (OutputFormat::Hevc, "hevc"), (OutputFormat::Av1, "av1")] { + for (format, codec, stream) in [(OutputFormat::H264, "h264", "h264"), (OutputFormat::Hevc, "hevc", "hevc"), (OutputFormat::Av1, "av1", "obu")] { for &(name, output, [primaries, transfer, matrix], names) in ROWS { let path = dir.join(format!("{codec}_{name}.mp4")); render(output, format, &path); @@ -89,7 +86,7 @@ fn mp4_tags_describe_the_frames() { let colr = track.entries[0].video.as_ref().and_then(|v| v.color.clone()); assert_eq!(colr, Some(ColorInfo::Nclx { primaries, transfer, matrix, full_range: false }), "{codec} {name}: colr"); - match frame_tags(&path) { + match bitstream_tags(&path, stream) { Some(tags) => assert_eq!(tags, names, "{codec} {name}: the bitstream's colour description"), None => eprintln!("{codec} {name}: ffprobe can't decode it here, bitstream not checked"), } From 34b3795c007565fefa937f35bea53767a725fcaf Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 23:52:47 -0300 Subject: [PATCH 38/71] CLI: props prints an enum's label next to its number (#2928) The text listing printed enum values as bare numbers, which are 0-based where After Effects scripting counts from 1, so Range Selector Shape 2 read as Ramp Up was Ramp Down. Print the label from the property's options, as props --json already lists them: "0 (Square)". --- apps/effectcraft-cli/src/main.rs | 12 ++++++++++- apps/effectcraft-cli/tests/it/cli.rs | 30 ++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index 6ba352247..cbc3bc76a 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -690,7 +690,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { if let Some(e) = p.get("expression").and_then(Value::as_str) { extra += &format!(" expr: {e}"); } - say!(" {:<44} {:<8} {}{extra}", p["path"].as_str().unwrap_or(""), p["type"].as_str().unwrap_or(""), p["value"]); + say!(" {:<44} {:<8} {}{extra}", p["path"].as_str().unwrap_or(""), p["type"].as_str().unwrap_or(""), value_text(p)); } } } @@ -1539,6 +1539,16 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> { Ok(()) } +/// A flat property's value for the text listing: an enum's number with the label `options` gives it +/// (#2928), since the numbers are 0-based where After Effects scripting counts from 1. +fn value_text(p: &Value) -> String { + let label = p["options"].as_array().and_then(|o| o.iter().find(|o| o["value"] == p["value"])).and_then(|o| o["label"].as_str()); + match label { + Some(l) => format!("{} ({l})", p["value"]), + None => p["value"].to_string(), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/apps/effectcraft-cli/tests/it/cli.rs b/apps/effectcraft-cli/tests/it/cli.rs index e9a96dd7e..2f191e560 100644 --- a/apps/effectcraft-cli/tests/it/cli.rs +++ b/apps/effectcraft-cli/tests/it/cli.rs @@ -110,6 +110,36 @@ fn exec_set_get_with_saved_project() { assert_eq!(v[1]["result"]["time"], v[0]["result"]["time"], "{v}"); } +/// #2928: the text listing gives an enum's label next to its number (0-based, unlike After +/// Effects scripting), the way `props --json` lists its `options`. +#[test] +fn props_lists_enum_labels() { + let proj = tmp("enum-labels.ecproj"); + let p = proj.to_str().unwrap(); + let anim = r#"{"layer":"t","properties":["opacity"]}"#; + ok_json(&[ + "run", + "comp.new", + r#"{"name":"T","width":64,"height":36}"#, + "layer.newText", + r#"{"text":"AB","name":"t"}"#, + "layer.addTextAnimator", + anim, + "--empty", + "--save-as", + p, + ]); + ok_json(&["set", "T", "t", "text/animators/animator/selectors/rangeSelector/advanced/shape", "1", p, "--save"]); + let out = bin().args(["props", "T", "t", p]).output().unwrap(); + let text = String::from_utf8_lossy(&out.stdout); + let shape = text.lines().find(|l| l.contains("advanced/shape")).unwrap_or_else(|| panic!("no shape line in:\n{text}")); + assert!(shape.trim_end().ends_with("enum 1 (Ramp Up)"), "{shape}"); + let units = text.lines().find(|l| l.contains("advanced/units")).unwrap_or_default(); + assert!(units.trim_end().ends_with("0 (Percentage)"), "{units}"); + let size = text.lines().find(|l| l.contains("transform/opacity")).unwrap_or_default(); + assert!(size.trim_end().ends_with("100.0"), "a number without options stays bare: {size}"); +} + /// `run --args-file` reads its command / params pairs from a file, one per line: a batch longer /// than a Windows command line (32 767 characters, os error 206) still runs. #[test] From fe6031a36f99052155c7ef00866ad8948b73da5d Mon Sep 17 00:00:00 2001 From: Bar Borer Date: Tue, 3 Nov 2026 04:57:08 +0200 Subject: [PATCH 39/71] Panels: the panel menu stays as wide as its contents (#2864) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels.rs | 60 ++++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 933f90c71..0426dd82f 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -323,12 +323,16 @@ pub(crate) fn panel_menu_area(app: &mut EffectcraftApp, ui: &mut egui::Ui) { return; } let mut close = false; - let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| { + let area_id = id.with("area"); + // measured afresh on each opening, as egui's popups are: its items and its window change between openings + let opening = ui.ctx().read_response(area_id).is_none(); + let area = egui::Area::new(area_id).order(egui::Order::Foreground).fixed_pos(pos).sizing_pass(opening).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { // a small window (a floated panel can be narrower than the menu) gets the menu at its width let width = (ui.ctx().content_rect().width() - 16.0).max(0.0); ui.set_min_width(200.0_f32.min(width)); - ui.set_max_width(width); + // only ever narrows what the area measured: the separator fills whatever width it is given + ui.set_max_width(ui.max_rect().width().min(width)); if ui.button(tr("Close Panel")).clicked() { let _ = app.dock_command(ui.ctx(), craft_ui::docking::Action::Close { panel }); close = true; @@ -487,6 +491,58 @@ mod panel_menu_viewport_tests { open(&ctx, window()); let rect = pass(&mut app, &ctx, window(), 160.0, vec![]).unwrap(); assert!(rect.max.x <= 160.5, "the menu fits the 160 pt window: {rect:?}"); + for _ in 0..4 { + let rect = pass(&mut app, &ctx, window(), 160.0, vec![]).unwrap(); + assert!(rect.min.x >= -0.5 && rect.max.x <= 160.5, "and keeps fitting it: {rect:?}"); + } + } + + #[test] + fn the_menu_is_as_wide_as_its_contents_not_the_window() { + let mut app = EffectcraftApp::new(Session::default()); + let ctx = egui::Context::default(); + open(&ctx, egui::ViewportId::ROOT); + let first = pass(&mut app, &ctx, egui::ViewportId::ROOT, 1200.0, vec![]).unwrap(); + assert!(first.width() < 400.0, "measured to its contents: {first:?}"); + for _ in 0..4 { + let rect = pass(&mut app, &ctx, egui::ViewportId::ROOT, 1200.0, vec![]).unwrap(); + assert!((rect.width() - first.width()).abs() < 1.0, "and stays there, pass after pass: {rect:?}, first {first:?}"); + } + } + + #[test] + fn a_window_narrowed_under_the_open_menu_narrows_the_menu() { + let mut app = EffectcraftApp::new(Session::default()); + let ctx = egui::Context::default(); + open(&ctx, window()); + for _ in 0..3 { + pass(&mut app, &ctx, window(), 1000.0, vec![]); + } + for _ in 0..3 { + pass(&mut app, &ctx, window(), 160.0, vec![]); + } + let rect = pass(&mut app, &ctx, window(), 160.0, vec![]).unwrap(); + assert!(rect.min.x >= -0.5 && rect.max.x <= 160.5, "the menu follows the window: {rect:?}"); + } + + #[test] + fn each_opening_measures_the_menu_afresh() { + let mut app = EffectcraftApp::new(Session::default()); + let ctx = egui::Context::default(); + open(&ctx, window()); + let wide = (0..3).filter_map(|_| pass(&mut app, &ctx, window(), 1000.0, vec![])).last().unwrap(); + // the same menu, opened while the window is narrow, wraps to fit... + ctx.data_mut(|d| d.remove::(menu_id())); + pass(&mut app, &ctx, window(), 160.0, vec![]); + open(&ctx, window()); + let narrow = (0..3).filter_map(|_| pass(&mut app, &ctx, window(), 160.0, vec![])).last().unwrap(); + assert!(narrow.width() < wide.width(), "it fits the narrow window: {narrow:?}, wide {wide:?}"); + // ...and doesn't keep that size when it next opens with room + ctx.data_mut(|d| d.remove::(menu_id())); + pass(&mut app, &ctx, window(), 1000.0, vec![]); + open(&ctx, window()); + let again = (0..3).filter_map(|_| pass(&mut app, &ctx, window(), 1000.0, vec![])).last().unwrap(); + assert!((again.width() - wide.width()).abs() < 1.0, "back to its own width: {again:?}, first {wide:?}"); } #[test] From f1767c3349c2a0d2ead4362936081b5616c13ebe Mon Sep 17 00:00:00 2001 From: Bar Borer Date: Tue, 3 Nov 2026 04:57:14 +0200 Subject: [PATCH 40/71] Panels: the gutter keeps rows clear of the scroll bar (#2867) Co-Authored-By: Claude Opus 5.5 --- .../src/panels/effect_controls/panel.rs | 5 +- crates/ui-egui/src/panels/effects_presets.rs | 3 +- crates/ui-egui/src/panels/info.rs | 3 +- crates/ui-egui/src/panels/media_panels.rs | 3 +- crates/ui-egui/src/panels/misc.rs | 12 +- crates/ui-egui/src/panels/project.rs | 23 +++- crates/ui-egui/src/panels/properties.rs | 4 +- crates/ui-egui/src/panels/render_queue.rs | 12 +- crates/ui-egui/src/panels/text_panels.rs | 8 +- crates/ui-egui/src/panels/timeline.rs | 9 +- crates/ui-egui/src/widgets.rs | 97 ++++++++++++- crates/ui-egui/tests/it/ui_scrolling.rs | 129 ++++++++++++++++++ 12 files changed, 273 insertions(+), 35 deletions(-) diff --git a/crates/ui-egui/src/panels/effect_controls/panel.rs b/crates/ui-egui/src/panels/effect_controls/panel.rs index 122a6abf7..848fbaa1c 100644 --- a/crates/ui-egui/src/panels/effect_controls/panel.rs +++ b/crates/ui-egui/src/panels/effect_controls/panel.rs @@ -181,12 +181,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.line_segment([hdr.left_bottom(), hdr.right_bottom()], Stroke::new(1.0, t.separator)); let body = Rect::from_min_max(pos2(rect.min.x, hdr.max.y), rect.max); let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("ec-scroll"), body); + let body = scroll.content; let mut y = body.min.y + 4.0 - scroll.offset; let mut actions: Actions = vec![]; let bp = p.with_clip_rect(body); - // Rows scroll under the header: clip their widgets (and hit tests) to the body. - let panel_clip = ui.clip_rect(); - ui.set_clip_rect(body.intersect(panel_clip)); let fx: Vec = layer.effects().map(|f| f.groups().cloned().collect()).unwrap_or_default(); if fx.is_empty() { bp.text( @@ -370,7 +368,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.set_min_width(200.0); actions.extend(crate::menus::menu_contents(app, ui, "Effect")); }); - ui.set_clip_rect(panel_clip); let content_h = y + scroll.offset - body.min.y; scroll.end(ui, &mut app.auto, "effectControls.scroll", content_h, &t); drop_payload(ui, rect, &p, &t, layer.id.0, &mut actions); diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index db2e4d83e..e3c97b392 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -636,8 +636,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let force_scan = contents_menu(app, ui, &ctx, rect, sr, &t); let view = app.ui.effects_view.clone(); let list = Rect::from_min_max(pos2(rect.min.x, sr.max.y + 6.0), rect.max); - let lp = p.with_clip_rect(list); let mut scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("fx-scroll"), list); + let list = scroll.content; + let lp = p.with_clip_rect(list); let query = q.trim().to_lowercase(); let searching = !query.is_empty(); let mut y = list.min.y - scroll.offset; diff --git a/crates/ui-egui/src/panels/info.rs b/crates/ui-egui/src/panels/info.rs index 86a1e6444..a02134191 100644 --- a/crates/ui-egui/src/panels/info.rs +++ b/crates/ui-egui/src/panels/info.rs @@ -13,9 +13,10 @@ use crate::{EffectcraftApp, widgets}; /// property, time and value. Read-only: it draws and scrolls but changes no document state. pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; + let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("info-scroll"), rect); + let rect = scroll.content; let p = ui.painter().with_clip_rect(rect); let x0 = rect.min.x + 12.0; - let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("info-scroll"), rect); let mut y = rect.min.y + 14.0 - scroll.offset; if app.pointer_comp.is_some() { // GPU frames: read the shown frame back for sampling. diff --git a/crates/ui-egui/src/panels/media_panels.rs b/crates/ui-egui/src/panels/media_panels.rs index ffe2cc1f7..756959871 100644 --- a/crates/ui-egui/src/panels/media_panels.rs +++ b/crates/ui-egui/src/panels/media_panels.rs @@ -171,10 +171,11 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { return; } }; + let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("mb-scroll"), list); + let list = scroll.content; let tile = vec2(140.0, 112.0); let cols = ((list.width() - 8.0) / tile.x).floor().max(1.0) as usize; let mut budget = 2u32; - let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("mb-scroll"), list); let lp = p.with_clip_rect(list); let mut actions: Vec<(&str, Value)> = vec![]; for (i, e) in entries.iter().enumerate() { diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index 73a0b3a1c..fb3b9063c 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -22,12 +22,11 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { use effectcraft_engine::preview::{PlayFrom, PreviewRange, PreviewResolution, PreviewShortcut}; let t = app.tokens; let ctx = ui.ctx().clone(); - let p = ui.painter().with_clip_rect(rect); // The panel scrolls when it is shorter than its controls (the default workspace shows just // the transport row, as in After Effects). let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("preview-scroll"), rect); - let panel_clip = ui.clip_rect(); - ui.set_clip_rect(rect.intersect(panel_clip)); + let rect = scroll.content; + let p = ui.painter().with_clip_rect(rect); let y = rect.min.y + 12.0 - scroll.offset; let bw = 30.0; let total = bw * 5.0 + 16.0; @@ -251,7 +250,6 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let status = if app.playback.playing && app.playback.caching { " — caching before playback" } else { "" }; p.text(pos2(bar.min.x, bar.max.y + 12.0), Align2::LEFT_CENTER, format!("{cached} / {total} frames cached{status}"), Tokens::ui(11.0), t.text_faint); } - ui.set_clip_rect(panel_clip); let content = yy + 30.0 - (rect.min.y - scroll.offset); scroll.end(ui, &mut app.auto, "preview.scroll", content, &t); } @@ -392,7 +390,6 @@ pub fn audio(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { /// state runs `edit.history.goto`, reporting a failure in `app.ui.status`. pub fn history(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; - let p = ui.painter().with_clip_rect(rect); // The branching history (EffectCraft's History panel): every state, branches indented under // the state they grew from; click any state to go there (`edit.history.goto`). let nodes = app.session.history_tree(); @@ -400,6 +397,8 @@ pub fn history(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let cur = nodes.iter().position(|n| n.current).unwrap_or(0); let scroll_id = egui::Id::unique("history-scroll"); let mut scroll = widgets::PanelScroll::begin(ui, scroll_id, rect); + let rect = scroll.content; + let p = ui.painter().with_clip_rect(rect); // The current state scrolls into view when it changes (undo, redo, a new step). if ui.data(|d| d.get_temp::(scroll_id.with("current"))) != Some(cur) { ui.data_mut(|d| d.insert_temp(scroll_id.with("current"), cur)); @@ -452,9 +451,10 @@ pub fn history(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { /// draws nothing without an active comp. pub fn markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; - let p = ui.painter().with_clip_rect(rect); let Some(c) = app.session.active_comp_arc() else { return }; let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("markers-scroll"), rect); + let rect = scroll.content; + let p = ui.painter().with_clip_rect(rect); let mut y = rect.min.y + 8.0 - scroll.offset; if c.markers.is_empty() { p.text(rect.center(), Align2::CENTER_CENTER, tr("No composition markers"), Tokens::ui(12.0), t.text_faint); diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index f84a49d84..435103fb5 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -1160,12 +1160,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Column header: Name, Label, then the visible optional columns. let hdr = Rect::from_min_size(pos2(rect.min.x, sr.max.y + 6.0), vec2(rect.width(), 20.0)); p.line_segment([hdr.left_bottom(), hdr.right_bottom()], Stroke::new(1.0, t.separator)); - let cl = layout_columns(app, rect); + // Rows and columns stay clear of the scroll bar. (By last frame's overflow: the rows are + // listed below, and how many there are does not depend on the width.) + let gutter_id = egui::Id::unique("project-gutter"); + let gutter = ctx.data(|d| d.get_temp::(gutter_id)).unwrap_or(false); + let content = if gutter { Rect::from_min_max(rect.min, pos2((rect.max.x - widgets::SCROLL_GUTTER).max(rect.min.x), rect.max.y)) } else { rect }; + let cl = layout_columns(app, content); let (name_w, label_x, opt_x0, overflow, hscroll) = (cl.name_w, cl.label_x, cl.opt_x0, cl.overflow, cl.hscroll); draw_header(app, ui, &p, hdr, &cl); // Rows. let footer_h = 28.0; - let list = Rect::from_min_max(pos2(rect.min.x, hdr.max.y), pos2(rect.max.x, rect.max.y - footer_h)); + let list = Rect::from_min_max(pos2(rect.min.x, hdr.max.y), pos2(content.max.x, rect.max.y - footer_h)); let lp = p.with_clip_rect(list); // The list's empty area (under the rows and the scroll bar, which take their own clicks): // a click deselects, a drag draws a selection box, a double-click imports, as in After @@ -1173,7 +1178,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Not a Tab stop: the keyboard reaches the rows, and nothing else here takes a key. let empty = ui.interact(list, egui::Id::unique("proj-empty"), Sense::CLICK | Sense::DRAG); app.auto.add("project.empty", list, "Double-click to import files; drag to select items; right-click for project actions"); - hscroll_wheel(app, ui, list, overflow); + // (The wheel works over the scroll bar too.) + let wheel_area = Rect::from_min_max(list.min, pos2(rect.max.x, list.max.y)); + hscroll_wheel(app, ui, wheel_area, overflow); let moved = keyboard_nav(app, &ctx); let rows = visible_rows(app); enter_renames(app, &ctx); @@ -1185,10 +1192,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let hover_y = ctx.pointer_hover_pos().filter(|p| list.contains(*p)).map(|p| p.y); let mut drop_row: Option = None; let mut actions: Vec<(String, serde_json::Value)> = vec![]; - let (scroll, max_scroll) = update_scroll(app, ui, &ctx, list, &rows, moved); + let (scroll, max_scroll) = update_scroll(app, ui, &ctx, wheel_area, &rows, moved); + if gutter != (max_scroll > 0.0) { + ctx.data_mut(|d| d.insert_temp(gutter_id, max_scroll > 0.0)); + ctx.request_repaint(); + } let first = ((scroll / ROW_H).floor() as usize).min(rows.len()); let mut y = list.min.y - (scroll - first as f32 * ROW_H); let project = app.session.project.clone(); + // Clip the rows' widgets (and hit tests) to the list too. + let panel_clip = ui.clip_rect(); + ui.set_clip_rect(list.intersect(panel_clip)); for (i, (id, depth)) in rows.iter().enumerate().skip(first) { let Some(it) = project.item(*id) else { continue }; let r = Rect::from_min_size(pos2(list.min.x, y), vec2(list.width(), ROW_H)); @@ -1295,6 +1309,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } crate::widgets::context_menu(&resp, |ui| item_menu(app, ui, &ctx, it, *id, selected, &mut actions)); } + ui.set_clip_rect(panel_clip); // The scroll bars, over the rows (which would take their presses otherwise). if overflow > 0.0 { let track = Rect::from_min_max(pos2(opt_x0, list.max.y - 5.0), pos2(rect.max.x - 4.0, list.max.y - 1.0)); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 4ef57b1ab..d828f5fa8 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -80,9 +80,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { property_overrides: &[], media_overrides: &[], }; + let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("props-scroll"), rect); + let rect = scroll.content; + let p = p.with_clip_rect(rect); let x0 = rect.min.x + PAD; let w = rect.width() - 2.0 * PAD; - let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("props-scroll"), rect); let top = rect.min.y + 8.0 - scroll.offset; let mut y = top; let mut actions: Actions = vec![]; diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index 21dc828ca..eee99f87b 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -850,6 +850,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // ---------------------------------------------------------- column header let head = Rect::from_min_size(pos2(rect.min.x, top.max.y), vec2(rect.width(), HEAD_H)); + // (The scroll begins here for the columns: they line up with the items, clear of its bar.) + let list = Rect::from_min_max(pos2(rect.min.x, head.max.y), pos2(rect.max.x, rect.max.y - FOOT_H)); + let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("rq-scroll"), list); + let list = scroll.content; p.rect_filled(head, 0.0, t.panel_bg); p.line_segment([head.left_bottom(), head.right_bottom()], Stroke::new(1.0, t.separator)); // Columns: twirl | Render | label | # | Comp Name | Status | Started | Render Time @@ -861,7 +865,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let w_time = 92.0; let w_started = 156.0; let w_status = 112.0; - let cx_time = (rect.max.x - w_time).max(cx_name + 120.0); + let cx_time = (list.max.x - w_time).max(cx_name + 120.0); let cx_started = cx_time - w_started; let cx_status = cx_started - w_status; let hy = head.center().y; @@ -873,12 +877,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } // ---------------------------------------------------------- items - let list = Rect::from_min_max(pos2(rect.min.x, head.max.y), pos2(rect.max.x, rect.max.y - FOOT_H)); let lp = p.with_clip_rect(list); - let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("rq-scroll"), list); - // Items scroll under the header and footer: clip their widgets (and hit tests) to the list. - let panel_clip = ui.clip_rect(); - ui.set_clip_rect(list.intersect(panel_clip)); let mut y = list.min.y - scroll.offset; if queue.is_empty() { lp.text(pos2(list.center().x, list.min.y + 30.0), Align2::CENTER_CENTER, tr("The render queue is empty."), Tokens::ui(12.0), t.text_faint); @@ -1015,7 +1014,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { output_to(app, ui, &ItemRow { lp: &lp, list, r, n, rendering }, it, dd.max.x, &mut editing, &mut actions); } } - ui.set_clip_rect(panel_clip); let content = y + scroll.offset - list.min.y; scroll.end(ui, &mut app.auto, "renderQueue.scroll", content, &t); ui.data_mut(|d| match &editing { diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 0ab0f10ec..096b41cf2 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -281,12 +281,13 @@ fn layer_fill_stroke(app: &EffectcraftApp, layer: u64) -> u32 { /// edited range; failures show in `app.ui.status`. pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; - let p = ui.painter().with_clip_rect(rect); let ctx = ui.ctx().clone(); let target = text_target(app); let enabled = target.is_some(); let doc = target.as_ref().map(|t| t.doc.clone()).unwrap_or_default(); let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("character-scroll"), rect); + let rect = scroll.content; + let p = ui.painter().with_clip_rect(rect); let top = rect.min.y + 10.0 - scroll.offset; let mut y = top; let x0 = rect.min.x + 10.0; @@ -602,14 +603,15 @@ pub fn paint_justify_glyph(p: &egui::Painter, r: Rect, j: Justify, c: egui::Colo /// target exists; failures show in `app.ui.status`. pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; - let p = ui.painter().with_clip_rect(rect); let ctx = ui.ctx().clone(); let target = text_target(app); let enabled = target.is_some(); let doc = target.as_ref().map(|t| t.doc.clone()).unwrap_or_default(); + let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("paragraph-scroll"), rect); + let rect = scroll.content; + let p = ui.painter().with_clip_rect(rect); let x0 = rect.min.x + 10.0; let w = rect.width() - 20.0; - let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("paragraph-scroll"), rect); let top = rect.min.y + 10.0 - scroll.offset; let mut y = top; let mut actions: Vec = vec![]; diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 246084cc1..160525261 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1809,12 +1809,17 @@ fn begin_rows(app: &mut EffectcraftApp, ui: &mut egui::Ui, tl: Tl) -> Rw { let graph_x0 = tl.graph_x0; let rect = tl.rect; let lp = tl.p.with_clip_rect(rows_rect.intersect(Rect::from_min_max(rows_rect.min, pos2(graph_x0 - 1.0, rows_rect.max.y)))); - let gp = tl.p.with_clip_rect(rows_rect.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max))); + // The rows reach through the gutter right of the time graph, but stop before the scroll bar. + let graph_rows = Rect::from_min_max( + pos2(graph_x0, rows_rect.min.y), + pos2(if tl.max_scroll > 0.0 { rect.max.x - widgets::SCROLL_GUTTER } else { rect.max.x }, rows_rect.max.y), + ); + let gp = tl.p.with_clip_rect(rows_rect.intersect(graph_rows)); let graph_on = app.ui.timeline.graph_editor; super::graph::track_open(ui.ctx(), graph_on); let full_clip = ui.clip_rect(); let left_clip = full_clip.intersect(Rect::from_min_max(pos2(rect.min.x, rows_rect.min.y), pos2(graph_x0 - 1.0, rows_rect.max.y))); - let right_clip = full_clip.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max)); + let right_clip = full_clip.intersect(graph_rows); // Registered before the rows so keys, bars and editors on top of it get the pointer first. let empty_rect = if graph_on { Rect::NOTHING } else { Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max) }; let empty = ui.interact(empty_rect, egui::Id::unique("tl-graph-bg"), Sense::click_and_drag()); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 344cee0cd..162a86c6a 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -647,25 +647,59 @@ pub fn scroll_bar(ui: &mut Ui, auto: &mut Registry, key: &str, track: Rect, scro s } +/// The width a scrolled panel keeps clear on its right for the [`scroll_bar`] (its track, and a +/// little air before it). +pub const SCROLL_GUTTER: f32 = 8.0; + +/// Whether a [`PanelScroll`]'s rows give up the [`SCROLL_GUTTER`], kept from frame to frame. +#[derive(Clone, Copy, Default)] +struct Gutter { + on: bool, + /// The rows' height at full width, until the first frame in the narrower width has measured + /// `shrunk`. + wide: Option, + /// How much shorter the rows got in the narrower width. Most rows only get taller, but a graph + /// sized by the width gets shorter: without this, rows that overflow at full width and fit + /// narrowed would drop the gutter and take it back every frame. + shrunk: f32, +} + /// Vertical scrolling for a panel that lays out its own rows: [`PanelScroll::begin`] gives the -/// offset to draw them at (moved by the mouse wheel over `area`), [`PanelScroll::end`] takes the -/// rows' height and draws a [`scroll_bar`] on the right edge while they overflow, so every -/// panel can be scrolled without a wheel or touchpad (#271). +/// offset to draw them at (moved by the mouse wheel over `area`) and the [`PanelScroll::content`] +/// to lay them out in, which clips `ui` until [`PanelScroll::end`]; that takes the rows' height +/// and draws a [`scroll_bar`] on the right edge while they overflow, so every panel can be +/// scrolled without a wheel or touchpad (#271). pub struct PanelScroll { id: egui::Id, area: Rect, + /// `ui`'s clip before [`PanelScroll::begin`]. + clip: Rect, + gutter: Gutter, /// How far the rows are scrolled up. pub offset: f32, + /// Where the rows go: the area, less the [`SCROLL_GUTTER`] while they overflow, so they are + /// cut off before the scroll bar rather than under it (#2867). Painters made before `begin` + /// keep their own clip: derive them from this. + pub content: Rect, } impl PanelScroll { - pub fn begin(ui: &Ui, id: egui::Id, area: Rect) -> Self { + pub fn begin(ui: &mut Ui, id: egui::Id, area: Rect) -> Self { // (Clamped to last frame's overflow: the rows' height is known once they are drawn.) let (mut offset, max): (f32, f32) = ui.data(|d| d.get_temp(id)).unwrap_or_default(); + // (Before the clip narrows: the wheel works over the scroll bar too.) if ui.rect_contains_pointer(area) { offset -= ui.input(|i| i.smooth_scroll_delta.y); } - Self { id, area, offset: offset.clamp(0.0, max.max(0.0)) } + let gutter: Gutter = ui.data(|d| d.get_temp(id.with("gutter"))).unwrap_or_default(); + let mut content = area; + if gutter.on { + content.max.x = (area.max.x - SCROLL_GUTTER).max(area.min.x); + } + // Rows scroll under whatever is around them: clip their widgets (and hit tests) too. + let clip = ui.clip_rect(); + ui.set_clip_rect(content.intersect(clip)); + Self { id, area, clip, gutter, offset: offset.clamp(0.0, max.max(0.0)), content } } /// Scroll the panel `id` (a [`PanelScroll`] kept under that id) so the content from `top` to @@ -687,6 +721,7 @@ impl PanelScroll { /// The rows were `content` points tall: keep the offset, with the scroll bar registered for /// automation as `key`. pub fn end(self, ui: &mut Ui, auto: &mut Registry, key: &str, content: f32, t: &Tokens) { + ui.set_clip_rect(self.clip); let max = (content - self.area.height()).max(0.0); let mut offset = self.offset.min(max); if max > 0.0 { @@ -694,6 +729,20 @@ impl PanelScroll { offset = scroll_bar(ui, auto, key, track, offset, max, t); } ui.data_mut(|d| d.insert_temp(self.id, (offset, max))); + let mut gutter = self.gutter; + if gutter.on { + if let Some(wide) = gutter.wide.take() { + gutter.shrunk = (wide - content).max(0.0); + } + gutter.on = content + gutter.shrunk > self.area.height(); + } else if max > 0.0 { + gutter = Gutter { on: true, wide: Some(content), shrunk: 0.0 }; + } + ui.data_mut(|d| d.insert_temp(self.id.with("gutter"), gutter)); + // The rows were laid out for the other width: lay them out again. + if gutter.on != self.gutter.on { + ui.ctx().request_repaint(); + } } } @@ -1060,6 +1109,44 @@ pub(crate) fn shifted(c: &[f64], d: usize, v: f64) -> Vec { mod tests { use super::*; + /// One frame of rows `rows(width)` tall in a 100 tall `PanelScroll`: the width they got. + fn scroll_frame(ctx: &egui::Context, rows: &dyn Fn(f32) -> f32) -> f32 { + let t = Tokens::for_kind(Default::default()); + let mut auto = Registry::default(); + let mut width = 0.0; + let area = Rect::from_min_size(pos2(10.0, 10.0), vec2(300.0, 100.0)); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + let outer = ui.clip_rect(); + let scroll = PanelScroll::begin(ui, egui::Id::unique("rows"), area); + width = scroll.content.width(); + assert_eq!(ui.clip_rect(), scroll.content.intersect(outer), "the widgets are clipped to the rows"); + scroll.end(ui, &mut auto, "rows.scroll", rows(width), &t); + assert_eq!(ui.clip_rect(), outer, "and only until the end"); + }); + output.textures_delta.clear(); + width + } + + /// Rows that fit only in the narrower width (a graph sized by the width gets shorter) keep the + /// gutter instead of dropping it and taking it back every frame, and give it up once they fit + /// at full width too (#2867). + #[test] + fn panel_scroll_gutter_settles() { + let (full, narrow) = (300.0, 300.0 - SCROLL_GUTTER); + let ctx = egui::Context::default(); + let widths = |tall: f32, frames: usize| { + let rows = move |w: f32| if w < full { tall - 8.0 } else { tall }; + (0..frames).map(|_| scroll_frame(&ctx, &rows)).collect::>() + }; + assert_eq!(widths(90.0, 3), [full; 3], "rows that fit: full width"); + assert_eq!(widths(104.0, 6), [full, narrow, narrow, narrow, narrow, narrow], "fit only narrowed: stays narrowed"); + assert_eq!(widths(200.0, 3), [narrow; 3], "overflow: narrowed"); + assert_eq!(widths(90.0, 3), [narrow, full, full], "fit again: full width"); + // Rows that overflow only when narrowed stay at full width. + let rows = |w: f32| if w < full { 104.0 } else { 96.0 }; + assert_eq!((0..3).map(|_| scroll_frame(&ctx, &rows)).collect::>(), [full; 3]); + } + fn checkbox_frame(ctx: &egui::Context, values: &mut [Option; 5], events: Vec) { let t = Tokens::for_kind(Default::default()); let input = egui::RawInput { events, ..Default::default() }; diff --git a/crates/ui-egui/tests/it/ui_scrolling.rs b/crates/ui-egui/tests/it/ui_scrolling.rs index 2e0f32ac8..001eb4503 100644 --- a/crates/ui-egui/tests/it/ui_scrolling.rs +++ b/crates/ui-egui/tests/it/ui_scrolling.rs @@ -319,6 +319,135 @@ fn panels_taller_than_their_space_have_a_scroll_bar_to_drag() { } } +/// A window of `height` with one `panel` maximized, with rows to fill it: effects on the selected +/// layers, queued renders, markers with a long comment, a folder of files. +fn panel_with_rows(panel: effectcraft_ui_egui::dock::PanelKind, height: f32, folder: &std::path::Path) -> Harness<'static, EffectcraftApp> { + let mut h = harness(640.0, height); + let app = h.state_mut(); + let layers: Vec = app.session.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect(); + app.session.execute("layer.select", json!({"layers": layers})).unwrap(); + for e in ["slider", "angle", "checkbox", "color", "point"] { + app.session.execute("effect.apply", json!({"effect": format!("ec.control.{e}"), "layers": [layers[0]]})).unwrap(); + } + for i in 0..6 { + app.session.execute("renderQueue.add", json!({})).unwrap(); + let comment = "a marker comment long enough to run right across the panel and off its far edge, under anything there"; + app.session.execute("markers.set", json!({"new": true, "time": 1.0 + i as f64 * 0.5, "comment": comment})).unwrap(); + } + app.session.execute("mediaBrowser.go", json!({"path": folder.to_string_lossy(), "importableOnly": false})).unwrap(); + app.show_panel(panel); + app.ui.maximized = Some(panel); + h.run_steps(4); + h +} + +/// A folder of files for the Media Browser's tiles, under a name only `test` uses. +fn folder_of_files(test: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("ec-ui-scrolling-{test}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for i in 0..24 { + std::fs::write(dir.join(format!("file{i}.txt")), "x").unwrap(); + } + dir +} + +/// The right edge of the elements under `prefix` level with `track` (or anywhere, without one), +/// other than the scroll bar `bar`. +fn rightmost(h: &Harness<'_, EffectcraftApp>, prefix: &str, bar: &str, track: Option) -> Option { + h.state() + .auto + .query(prefix) + .into_iter() + .filter(|e| e.id != bar) + .map(|e| Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))) + .filter(|r| track.is_none_or(|t| r.max.y > t.min.y && r.min.y < t.max.y)) + .map(|r| r.max.x) + .reduce(f32::max) +} + +/// The text painted this frame, with where it shows (cut by its clip). +fn painted_text(h: &Harness<'_, EffectcraftApp>) -> Vec<(String, Rect)> { + h.output() + .shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Text(t) => Some((t.galley.text().to_string(), t.visual_bounding_rect().intersect(c.clip_rect))), + _ => None, + }) + .collect() +} + +/// The panels that scroll their rows: the elements in them (none for a panel that only paints), +/// the scroll bar, and how far in from the panel's edge the rows end at full width (`None`: their +/// width is capped). +const SCROLLED_PANELS: [(effectcraft_ui_egui::dock::PanelKind, &str, &str, Option); 12] = { + use effectcraft_ui_egui::dock::PanelKind as P; + [ + (P::EffectControls, "effectControls.", "effectControls.scroll", Some(0.0)), + (P::EffectsPresets, "effects.", "effects.scroll", Some(0.0)), + (P::Info, "info.", "info.scroll", None), + (P::MediaBrowser, "mediaBrowser.entry.", "mediaBrowser.scroll", None), + (P::Preview, "preview.", "preview.scroll", None), + (P::History, "history.state.", "history.scroll", Some(0.0)), + (P::Markers, "markers.", "markers.scroll", None), + (P::Properties, "properties.", "properties.scroll", Some(12.0)), + (P::RenderQueue, "renderQueue.item.", "renderQueue.scroll", Some(0.0)), + (P::Character, "character.", "character.scroll", Some(10.0)), + (P::Paragraph, "paragraph.", "paragraph.scroll", Some(10.0)), + (P::Project, "project.item.", "project.vscroll", Some(0.0)), + ] +}; + +/// While a panel's rows overflow, they (and their widgets and text) stop before its scroll bar, +/// which was drawn over them (#2867). +#[test] +fn panel_rows_stop_before_their_scroll_bar() { + let folder = folder_of_files("short"); + for (panel, prefix, bar, _) in SCROLLED_PANELS { + // (The Project panel's list starts below its item preview and search: a taller window.) + let h = panel_with_rows(panel, if bar == "project.vscroll" { 330.0 } else { 240.0 }, &folder); + assert!(scrolled(&h, bar).1 > 0.0, "{bar}: the rows overflow"); + let track = rect(&h, bar); + if let Some(right) = rightmost(&h, prefix, bar, Some(track)) { + if right > track.min.x { eprintln!("MUT {}", format!("{bar}: the rows end at {right}, on the track {track:?}")); } + } else { + assert!(matches!(bar, "info.scroll" | "markers.scroll"), "{bar}: no rows beside the track"); + } + let lane = Rect::from_x_y_ranges(track.min.x..=track.max.x, track.y_range()); + for (text, at) in painted_text(&h) { + if at.intersects(lane) { eprintln!("MUT {}", format!("{bar}: {text:?} shows at {at:?}, on the track {track:?}")); } + } + } + let _ = std::fs::remove_dir_all(&folder); +} + +/// Panels whose rows fit show no bar and give up no width for one. +#[test] +fn panels_with_room_keep_their_full_width() { + let folder = folder_of_files("tall"); + for (panel, prefix, bar, inset) in SCROLLED_PANELS { + let h = panel_with_rows(panel, 1600.0, &folder); + assert!(h.state().auto.find(bar).is_none(), "{bar}: the rows fit"); + let edge = rect(&h, &format!("panel.{}", panel.id())).max.x - 1.0; + if let Some(inset) = inset { + let right = rightmost(&h, prefix, bar, None).unwrap_or_else(|| panic!("{bar}: no rows")); + assert!((edge - inset - right).abs() < 0.01, "{bar}: the rows end at {right}, {inset} in from {edge}"); + } + } + let _ = std::fs::remove_dir_all(&folder); +} + +/// The Render Queue's header columns stay over their columns when the rows narrow for the bar. +#[test] +fn render_queue_header_lines_up_with_narrowed_rows() { + use effectcraft_ui_egui::dock::PanelKind; + let h = panel_with_rows(PanelKind::RenderQueue, 240.0, &std::env::temp_dir()); + assert!(scrolled(&h, "renderQueue.scroll").1 > 0.0, "the rows overflow"); + let status = rect(&h, "renderQueue.item.1.status"); + let header = painted_text(&h).into_iter().find(|(text, _)| text == "Status").expect("the Status header"); + assert!((header.1.min.x - status.min.x - 2.0).abs() < 2.0, "the header ({:?}) over the column ({status:?})", header.1); +} + /// In a narrow Effect Controls panel an animated point's values stay clear of its keyframe /// navigator (â—€ â—† â–¶), which was drawn over them (#271). #[test] From 72d0b3aa08e2a0483dec7148e2d5dc4e160b8e4c Mon Sep 17 00:00:00 2001 From: Bar Borer Date: Tue, 3 Nov 2026 04:57:19 +0200 Subject: [PATCH 41/71] Viewer, Timeline: a middle-drag pans and moves no layer (#2854) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline.rs | 32 ++++++++---- crates/ui-egui/src/panels/viewer.rs | 32 +++++++++--- crates/ui-egui/tests/it/ui_keyframes.rs | 41 +++++++++++++++ crates/ui-egui/tests/it/ui_timeline_pan.rs | 38 ++++++++++++-- crates/ui-egui/tests/it/ui_viewer.rs | 60 ++++++++++++++++++++++ 5 files changed, 185 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 246084cc1..474023cdc 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -77,6 +77,17 @@ pub(crate) fn bar_color(label: Color32, selected: bool) -> Color32 { const TRIM_OUTSIDE: f32 = 4.0; const TRIM_INSIDE: f32 = 5.0; +/// A middle-drag in the timeline pans it (`hand_tool_pan`, #2854): a middle press here starts no +/// drag, so it can't move a bar or a key. A widget that takes a drag on press was given it this frame, +/// before anything here ran. Handing that drag to an id nothing uses and ending it means no +/// widget sees it start or stop. +fn ignore_middle_press(ui: &egui::Ui, rect: Rect) { + if ui.rect_contains_pointer(rect) && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Middle) && !i.pointer.primary_down()) { + ui.ctx().set_dragged_id(egui::Id::unique("tl-middle-press")); + ui.ctx().stop_dragging(); + } +} + /// A layer bar's body (drag to move) and its In/Out trim zones. Short bars keep their middle /// third as body; a bar too short for that is all trim zones (the Out one on top). fn trim_zones(bar: Rect) -> (Rect, Rect, Rect) { @@ -1850,6 +1861,7 @@ fn begin_rows(app: &mut EffectcraftApp, ui: &mut egui::Ui, tl: Tl) -> Rw { /// open rows and the drags in progress) lives in /// `app.ui.timeline` and egui's temp data. pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { + ignore_middle_press(ui, rect); let Some(cid) = app.session.active_comp_id() else { empty_timeline(app, ui, rect); return; @@ -4835,7 +4847,7 @@ fn comp_markers_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Rw) { super::markers_ui::marker_bin(app, ui, &comp, tm, bin, mstrip); } -/// Spacebar held: the Hand tool. +/// Spacebar held: the Hand tool. A middle-drag begun in the rows, layer list included, pans too. fn hand_tool_pan(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Rw) { let rect = g.tl.rect; let graph_x0 = g.tl.graph_x0; @@ -4847,6 +4859,7 @@ fn hand_tool_pan(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Rw) { let max_scroll = g.tl.max_scroll; let ctx = g.tl.ctx.clone(); let comp = g.tl.comp.clone(); + let mut pan = ui.input(|i| i.pointer.middle_down() && !i.pointer.primary_down() && i.pointer.press_origin().is_some_and(|p| rows_rect.contains(p))); if super::space_hand(&ctx) { let area = Rect::from_min_max(pos2(graph_x0, ruler.min.y), pos2(rect.max.x, rows_rect.max.y)); let hand = ui.interact(area, egui::Id::unique("tl-hand"), Sense::drag()); @@ -4854,14 +4867,15 @@ fn hand_tool_pan(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Rw) { if hand.hovered() || hand.dragged() { ctx.set_cursor_icon(if hand.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }); } - if hand.dragged() { - let dy = ui.input(|i| i.pointer.delta().y); - app.ui.timeline.scroll_y = (app.ui.timeline.scroll_y - dy).clamp(0.0, max_scroll); - if app.ui.timeline.pps.is_some() { - let start = app.ui.timeline.start; - let last = (comp.duration.seconds() - (tm.t(graph_x1) - tm.start)).max(start).max(0.0); - app.ui.timeline.start = (start - hand.drag_delta().x as f64 / pps).clamp(0.0, last); - } + pan |= hand.dragged(); + } + if pan { + let delta = ui.input(|i| i.pointer.delta()); + app.ui.timeline.scroll_y = (app.ui.timeline.scroll_y - delta.y).clamp(0.0, max_scroll); + if app.ui.timeline.pps.is_some() { + let start = app.ui.timeline.start; + let last = (comp.duration.seconds() - (tm.t(graph_x1) - tm.start)).max(start).max(0.0); + app.ui.timeline.start = (start - delta.x as f64 / pps).clamp(0.0, last); } } } diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index bb7b2d075..70c6d12d5 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -216,11 +216,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let resp = phase_menu(app, ui, &ectx, &mut v); phase_hover(app, ui, &ectx, &resp, &mut v); phase_press(app, ui, &ectx, &resp, &mut v); - phase_dragstart(app, ui, &ectx, &resp, &mut v); - phase_drag(app, ui, &ectx, &resp, &mut v); - phase_stopped(app, ui, &ectx, &resp, &mut v); - phase_clicked(app, &ectx, &resp, &mut v); - phase_dblclick(app, &ectx, &resp, &mut v); + if !middle_pan(app, ui, &resp, &v) { + phase_dragstart(app, ui, &ectx, &resp, &mut v); + phase_drag(app, ui, &ectx, &resp, &mut v); + phase_stopped(app, ui, &ectx, &resp, &mut v); + phase_clicked(app, &ectx, &resp, &mut v); + phase_dblclick(app, &ectx, &resp, &mut v); + } phase_tail(app, ui, &ectx, &mut v); } @@ -1064,6 +1066,24 @@ fn phase_press(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp v.text_owns = text_owns; } +/// A middle-drag pans with any tool and doesn't enter the gesture phases, so nothing else can +/// happen under it (#2854). A left-button gesture already running keeps going until it ends. +fn middle_pan(app: &mut EffectcraftApp, ui: &egui::Ui, resp: &egui::Response, v: &Vw<'_>) -> bool { + let (middle, primary) = ui.input(|i| (i.pointer.middle_down(), i.pointer.primary_down() || i.pointer.primary_released())); + if !middle || !resp.is_pointer_button_down_on() || (primary && ui.data(|d| d.get_temp::(v.gid)).is_some()) { + return false; + } + // No left button: a stored gesture is one whose release the viewer missed. + ui.data_mut(|d| d.remove::(v.gid)); + // Movement from before the press (in its frame) is not part of the drag. + let d = ui.input(|i| if i.pointer.button_pressed(egui::PointerButton::Middle) { egui::Vec2::ZERO } else { i.pointer.delta() }); + if d != egui::Vec2::ZERO && d.x.is_finite() && d.y.is_finite() { + app.ui.viewer.pan = [app.ui.viewer.pan[0] + d.x, app.ui.viewer.pan[1] + d.y]; + } + v.ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + true +} + fn phase_dragstart(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) { let comp_rect = v.comp_rect; let zoom = v.zoom; @@ -1100,7 +1120,7 @@ fn phase_dragstart(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, { let cpt = map.to_comp(pos); let press = ui.input(|i| i.pointer.press_origin()).unwrap_or(pos); - let tool = if space_pan || resp.dragged_by(egui::PointerButton::Middle) { Tool::Hand } else { app.ui.tool }; + let tool = if space_pan { Tool::Hand } else { app.ui.tool }; let g = match tool { Tool::Hand => Some(Gesture::Pan { start_pan: app.ui.viewer.pan }), // Region of Interest armed: the drag draws it. diff --git a/crates/ui-egui/tests/it/ui_keyframes.rs b/crates/ui-egui/tests/it/ui_keyframes.rs index b137a06fc..d8cce232d 100644 --- a/crates/ui-egui/tests/it/ui_keyframes.rs +++ b/crates/ui-egui/tests/it/ui_keyframes.rs @@ -42,6 +42,47 @@ fn keys(h: &Harness<'_, EffectcraftApp>, uid: u64) -> Vec { ks } +/// #2854: a middle-drag on a key, a layer bar, its trim edge or the marker bin edits nothing and +/// adds no undo step; it only pans. +#[test] +fn middle_drag_on_a_key_or_layer_bar_changes_nothing() { + let (mut h, id, uid) = harness(); + let center = |h: &Harness<'_, EffectcraftApp>, name: String| { + let e = h.state().auto.find(&name).unwrap_or_else(|| panic!("no {name}")).clone(); + pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0) + }; + let bar = center(&h, format!("timeline.layer.{}.bar", id.0)); + let trim = center(&h, format!("timeline.layer.{}.bar.in", id.0)); + let bin = center(&h, "timeline.markerBin".to_string()); + let key = keys(&h, uid)[1]; + let state = |h: &Harness<'_, EffectcraftApp>| { + let comp = h.state().session.active_comp().unwrap(); + let l = comp.layer(id).unwrap(); + (l.in_point, l.out_point, l.start_time, comp.markers.len(), key_times(h, id), h.state().session.history.undo.len()) + }; + h.state_mut().session.execute("edit.deselectAll", json!({})).unwrap(); + h.run_steps(2); + let before = state(&h); + for from in [key, bar, trim, bin] { + let to = from + egui::vec2(90.0, 0.0); + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Middle, pressed: true, modifiers: Default::default() }); + 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::Middle, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + assert_eq!(state(&h), before, "from {from:?}"); + assert!(h.state().session.state.selected_layers.is_empty(), "from {from:?}: the press selects nothing"); + } + // The left button still drags the key. + drag(&mut h, key, key + egui::vec2(90.0, 0.0), egui::Modifiers::NONE); + assert_ne!(key_times(&h, id), before.4); +} + /// Press at `from`, move to `to` in small steps (a real drag), release. fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, modifiers: egui::Modifiers) { h.input_mut().events.push(Event::PointerMoved(from)); diff --git a/crates/ui-egui/tests/it/ui_timeline_pan.rs b/crates/ui-egui/tests/it/ui_timeline_pan.rs index 8523ded30..56bbd8a45 100644 --- a/crates/ui-egui/tests/it/ui_timeline_pan.rs +++ b/crates/ui-egui/tests/it/ui_timeline_pan.rs @@ -1,5 +1,6 @@ //! Spacebar-drag panning in the Timeline: the Hand tool scrolls the layer rows vertically as well -//! as the time graph horizontally (#2083). +//! as the time graph horizontally (#2083). A middle-drag does the same, from the layer list too +//! (#2854). use effectcraft_engine::Session; use effectcraft_ui_egui::EffectcraftApp; @@ -28,18 +29,49 @@ fn space(h: &mut Harness<'_, EffectcraftApp>, pressed: bool) { } fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { + drag_with(h, egui::PointerButton::Primary, from, to); +} + +fn drag_with(h: &mut Harness<'_, EffectcraftApp>, button: egui::PointerButton, from: Pos2, to: Pos2) { h.event(Event::PointerMoved(from)); h.step(); - h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.event(Event::PointerButton { pos: from, button, pressed: true, modifiers: Modifiers::NONE }); h.step(); for k in 1..=10 { h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 10.0))); h.step(); } - h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.event(Event::PointerButton { pos: to, button, pressed: false, modifiers: Modifiers::NONE }); h.run_steps(3); } +#[test] +fn middle_drag_pans_from_the_layer_list_and_the_bars() { + let (mut h, layer) = harness(); + h.state_mut().ui.timeline.pps = Some(400.0); + h.state_mut().ui.timeline.start = 1.0; + h.run_steps(3); + let order = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect::>(); + let before = order(&h); + let center = |r: [f32; 4]| pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0); + let row = center(h.state().auto.find(&format!("timeline.layer.{layer}.row")).expect("layer row").rect); + drag_with(&mut h, egui::PointerButton::Middle, row, row - vec2(0.0, 180.0)); + let y = h.state().ui.timeline.scroll_y; + assert!(y > 100.0, "the list scrolled: {y}"); + assert_eq!(h.state().ui.timeline.start, 1.0, "a vertical drag leaves time alone"); + assert_eq!(order(&h), before, "the layers stay in order"); + assert!(h.state().session.state.selected_layers.is_empty(), "the press selects nothing"); + // In the time graph, just right of the row, whatever the zoom has left of its bar. + h.state_mut().ui.timeline.scroll_y = 0.0; + h.run_steps(2); + let r = h.state().auto.find(&format!("timeline.layer.{layer}.row")).expect("layer row").rect; + let graph = pos2(r[0] + r[2] + 100.0, r[1] + r[3] / 2.0); + drag_with(&mut h, egui::PointerButton::Middle, graph, graph - vec2(80.0, 0.0)); + let start = h.state().ui.timeline.start; + assert!(start > 1.1, "time panned: {start}"); + assert_eq!(order(&h), before); +} + #[test] fn spacebar_drag_pans_the_rows_vertically() { let (mut h, layer) = harness(); diff --git a/crates/ui-egui/tests/it/ui_viewer.rs b/crates/ui-egui/tests/it/ui_viewer.rs index f0086d28a..afb8a78de 100644 --- a/crates/ui-egui/tests/it/ui_viewer.rs +++ b/crates/ui-egui/tests/it/ui_viewer.rs @@ -1771,6 +1771,66 @@ fn middle_and_hand_drags_pan_the_viewer_and_the_pan_stays_after_release() { assert!((end[0] - after[0] + 50.0).abs() < 1.0 && (end[1] - after[1] + 30.0).abs() < 1.0, "{after:?} → {end:?}"); } +/// #2854: a middle-drag over a layer pans the view and leaves the layer alone, selected or not, +/// with any tool, and adds no undo step. +#[test] +fn middle_drag_over_a_layer_pans_and_leaves_the_layer_where_it_is() { + let mut h = harness(); + let id = layer_named(&h, "Box"); + let c = rect(&h, "viewer.comp").center(); + for (select, tool) in [(false, Tool::Selection), (true, Tool::Selection), (true, Tool::Rotate), (true, Tool::Type)] { + let selection = if select { json!({"layers": [id.0]}) } else { json!({"layers": []}) }; + h.state_mut().session.execute("layer.select", selection).unwrap(); + h.state_mut().ui.tool = tool; + h.state_mut().ui.viewer.pan = [0.0, 0.0]; + h.run_steps(2); + let (p0, steps) = (position_of(&h, id), h.state().session.history.undo.len()); + drag_with(&mut h, egui::PointerButton::Middle, c, c + vec2(120.0, 60.0)); + let pan = h.state().ui.viewer.pan; + assert!((pan[0] - 120.0).abs() < 1.0 && (pan[1] - 60.0).abs() < 1.0, "{select} {tool:?}: {pan:?}"); + assert_eq!(position_of(&h, id), p0, "{select} {tool:?}: the layer stays"); + assert_eq!(h.state().session.history.undo.len(), steps, "{select} {tool:?}: no undo step"); + } +} + +/// #2854: a left drag whose release the viewer never saw (the panel wasn't drawn on that frame) +/// leaves its move gesture stored. A middle press must not run it. +#[test] +fn middle_drag_does_not_run_a_gesture_left_over_from_a_missed_release() { + let mut h = harness(); + let id = layer_named(&h, "Box"); + h.state_mut().session.execute("layer.select", json!({"layers": [id.0]})).unwrap(); + h.run_steps(2); + let c = rect(&h, "viewer.comp").center(); + let p = egui::PointerButton::Primary; + h.input_mut().events.push(Event::PointerMoved(c)); + h.input_mut().events.push(Event::PointerButton { pos: c, button: p, pressed: true, modifiers: Default::default() }); + h.step(); + for i in 1..=4 { + h.input_mut().events.push(Event::PointerMoved(c + vec2(10.0 * i as f32, 0.0))); + h.step(); + } + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.openLayer", json!({"layer": id.0})).unwrap(); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: c + vec2(40.0, 0.0), button: p, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + h.state_mut().show_panel(effectcraft_ui_egui::dock::PanelKind::Composition); + h.run_steps(3); + let c = rect(&h, "viewer.comp").center(); + let p0 = position_of(&h, id); + drag_with(&mut h, egui::PointerButton::Middle, c, c + vec2(120.0, 60.0)); + assert_eq!(position_of(&h, id), p0, "the stored move didn't run"); +} + +fn layer_named(h: &Harness<'_, EffectcraftApp>, name: &str) -> LayerId { + h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == name).unwrap().id +} + +fn position_of(h: &Harness<'_, EffectcraftApp>, id: LayerId) -> [f64; 3] { + h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop("transform/position").unwrap().value.as_vec3() +} + /// A Spacebar press or release (`repeat`: the keyboard's auto-repeat while it is held). fn space(h: &mut Harness<'_, EffectcraftApp>, pressed: bool, repeat: bool) { h.input_mut().events.push(Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat, modifiers: Default::default() }); From 325c029d067312059dad8d28a958dd3ce4013e2b Mon Sep 17 00:00:00 2001 From: Bar Borer Date: Tue, 3 Nov 2026 04:59:16 +0200 Subject: [PATCH 42/71] Viewer: the Rotation tool measures its angle on screen about the anchor (#2870) One comp-space path: the pivot is the anchor where it shows and a flipped parent reverses the turn, so the separate parent-space frame and the moved parent_to_comp go. Under uneven or skewed parents the layer's edge tracks the pointer slightly less exactly. The empty-layer marker goes back to main's. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/eval.rs | 9 +--- crates/ui-egui/src/panels/viewer.rs | 29 +++++++------ crates/ui-egui/src/panels/viewer/geometry.rs | 43 ++------------------ crates/ui-egui/src/panels/viewer/gesture.rs | 7 ++-- crates/ui-egui/src/panels/viewer/tests.rs | 16 +------- crates/ui-egui/src/panels/viewer_overlays.rs | 13 +++++- crates/ui-egui/tests/it/ui_viewer.rs | 22 ---------- 7 files changed, 38 insertions(+), 101 deletions(-) diff --git a/crates/render/src/eval.rs b/crates/render/src/eval.rs index 1aaf02d76..81fe45d6c 100644 --- a/crates/render/src/eval.rs +++ b/crates/render/src/eval.rs @@ -214,14 +214,7 @@ impl<'a> EvalCtx<'a> { /// Layer space → comp (world) space, including parents. pub fn world_matrix(&self, layer: &Layer) -> Mat4 { - self.parent_matrix(layer) * self.local_matrix_par(layer, self.par_ratio(layer)) - } - - /// The space a layer's Position and Rotation live in → comp (world) space: its parents' - /// transforms, without their pixel aspect, which children don't inherit (identity without a - /// parent). - pub fn parent_matrix(&self, layer: &Layer) -> Mat4 { - let mut m = Mat4::IDENTITY; + let mut m = self.local_matrix_par(layer, self.par_ratio(layer)); let mut cur = layer.parent; let mut guard = 0; while let Some(pid) = cur { diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 52dd47567..4fdac5ae4 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -565,8 +565,10 @@ fn phase_layers(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: continue; } // Cameras, lights, empty layers: just the anchor. - if let Some(c) = anchor_in_comp(ectx, l) { - painter.circle_stroke(map.to_screen(c), 6.0, Stroke::new(1.0, col)); + if let Some(tr) = l.transform() { + let pos = ectx.v3(l, tr, "position", [0.0; 3]); + let s = map.to_screen([pos[0], pos[1]]); + painter.circle_stroke(s, 6.0, Stroke::new(1.0, col)); } continue; }; @@ -1130,9 +1132,10 @@ fn phase_dragstart(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, .into_iter() .filter_map(|o| Some((o.id, ectx.f(o, o.transform()?, "rotation", 0.0), rotation_sign(ectx, o) * sign))) .collect(); - let (c2p, pivot) = layer.as_ref().and_then(|l| rotate_frame(ectx, l)).unwrap_or((Mat3::IDENTITY, cpt)); + let pivot = layer.as_ref().and_then(|l| anchor_in_comp(ectx, l)).unwrap_or(cpt); let rot = layer.as_ref().and_then(|l| l.transform().map(|tr| ectx.f(l, tr, "rotation", 0.0))).unwrap_or(0.0); - Gesture::Rotate { layer: l, c2p, pivot, last_angle: pointer_angle(&c2p, pivot, cpt), turned: 0.0, start_rot: rot, others } + let last_angle = (cpt[1] - pivot[1]).atan2(cpt[0] - pivot[0]).to_degrees(); + Gesture::Rotate { layer: l, pivot, sign, last_angle, turned: 0.0, start_rot: rot, others } }), Tool::PanBehind => pick(app, ectx, cpt, false).and_then(|l| { let layer = comp.layer(l)?; @@ -1390,17 +1393,17 @@ fn phase_drag(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/scale", "value": v, "merge": merge})); } } - Gesture::Rotate { layer, c2p, pivot, last_angle, turned, start_rot, others } => { + Gesture::Rotate { layer, pivot, sign, 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 = pointer_angle(&c2p, pivot, cpt); - let turned = turned + turn_delta(last_angle, ang); - ui.data_mut(|dd| dd.insert_temp(gid, Gesture::Rotate { layer, c2p, pivot, last_angle: ang, turned, start_rot, others: others.clone() })); + let ang = (cpt[1] - pivot[1]).atan2(cpt[0] - pivot[0]).to_degrees(); + let turned = turned + sign * turn_delta(last_angle, ang); + ui.data_mut(|dd| dd.insert_temp(gid, Gesture::Rotate { layer, pivot, sign, 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, sign) in others { - let v = r0 + sign * (r - start_rot); + for (lid, r0, other) in others { + let v = r0 + other * (r - start_rot); let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/rotation", "value": v, "merge": merge})); } } @@ -1893,12 +1896,12 @@ mod view; use frame::{empty_state, rect_of, show_frame}; pub(crate) use frame::{extended_region, minify_factor, snap_to_pixels, styled_line, zoom_sampler, zoom_texture_options}; use geometry::{ - anchor_in_comp, cam_state, drag_ratio, handle_axes, handle_cursor, handle_point, one_axis, pointer_angle, project_segment, quad_intersects_box, - rotate_frame, rotate_value, rotation_sign, selected_others, snap_handle, turn_delta, + anchor_in_comp, cam_state, drag_ratio, handle_axes, handle_cursor, handle_point, one_axis, project_segment, quad_intersects_box, rotate_value, + rotation_sign, selected_others, snap_handle, turn_delta, }; #[cfg(test)] use geometry::{box_corners, project_clipped}; -pub(crate) use geometry::{from_effect_space, fx2c, l2c, layer_outline, layer_quad, parent_to_comp}; +pub(crate) use geometry::{from_effect_space, fx2c, l2c, layer_outline, layer_quad}; use gesture::{Gesture, HANDLE, ROTO_HINT, pen_id, roi_drag, roi_handles, vertex_grab_radius}; use shapes::{create_shape, draw_shape_ghost, shape_drag}; pub(crate) use shapes::{layer_at, layer_at_including_locked, layers_at, pick, selectable_layers}; diff --git a/crates/ui-egui/src/panels/viewer/geometry.rs b/crates/ui-egui/src/panels/viewer/geometry.rs index 39809e247..144047f32 100644 --- a/crates/ui-egui/src/panels/viewer/geometry.rs +++ b/crates/ui-egui/src/panels/viewer/geometry.rs @@ -1,3 +1,4 @@ +use super::ov::parent_to_comp; use super::{CameraState, Comp, EffectcraftApp, EvalCtx, Layer, LayerId, Mat3, Pos2, VIEW_CAM, gv2, three_d, vec2}; /// The camera the viewer shows (view camera or the comp's active camera). @@ -120,12 +121,6 @@ pub(crate) fn layer_outline(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> { project_clipped(&l2c(ctx, layer).0, &box_corners(b)) } -/// Parent space → comp matrix of a 2D layer at the context time. -pub(crate) fn parent_to_comp(ctx: &EvalCtx, layer: &Layer) -> Mat3 { - let w = ctx.parent_matrix(layer).0; - Mat3([[w[0][0], w[0][1], w[0][3]], [w[1][0], w[1][1], w[1][3]], [0.0, 0.0, 1.0]]) -} - /// Where a layer's anchor point shows in the viewer (comp pixels), through the current 3D view for /// a 3D layer. None without a transform, or when it is at or behind the camera. pub(super) fn anchor_in_comp(ctx: &EvalCtx, layer: &Layer) -> Option<[f64; 2]> { @@ -138,40 +133,10 @@ pub(super) fn anchor_in_comp(ctx: &EvalCtx, layer: &Layer) -> Option<[f64; 2]> { Some([p.x, p.y]) } -/// −1 when a layer's Rotation turns it counter-clockwise on screen: its parent is flipped or, for -/// a 3D layer, the view sees its parent's XY plane from behind. Otherwise 1. +/// −1 when a layer's Rotation turns it counter-clockwise on screen, its parent being flipped. +/// Otherwise 1. pub(super) fn rotation_sign(ctx: &EvalCtx, layer: &Layer) -> f64 { - let plane = match layer.transform() { - Some(tr) if layer.is_3d() => { - let at = effectcraft_engine::geom::Mat4::translate(ctx.position(layer, tr).into()); - (cam_state(ctx).projection(ctx.comp.width as f64, ctx.comp.height as f64) * ctx.parent_matrix(layer) * at).plane_to_mat3() - } - _ => parent_to_comp(ctx, layer), - }; - if plane.determinant() < 0.0 { -1.0 } else { 1.0 } -} - -/// The space the Rotation tool measures the pointer's angle in: comp → that space, and the layer's -/// pivot there. A 2D layer turns in parent space about its Position, so it follows the pointer -/// through any parent (a flip or skew needs no special case). A 3D layer, or a parent that collapses -/// the plane, measures on screen about the anchor, mirrored when the turn is reversed. -pub(super) fn rotate_frame(ctx: &EvalCtx, layer: &Layer) -> Option<(Mat3, [f64; 2])> { - if !layer.is_3d() - && let Some(c2p) = parent_to_comp(ctx, layer).inverse() - { - let p = ctx.position(layer, layer.transform()?); - return Some((c2p, [p[0], p[1]])); - } - let a = anchor_in_comp(ctx, layer)?; - let c2p = Mat3::scale(gv2(rotation_sign(ctx, layer), 1.0)); - let p = c2p.apply(gv2(a[0], a[1])); - Some((c2p, [p.x, p.y])) -} - -/// The pointer's angle (degrees, as atan2 gives it) about `pivot`, in the space of [`rotate_frame`]. -pub(super) fn pointer_angle(c2p: &Mat3, pivot: [f64; 2], cpt: [f64; 2]) -> f64 { - let p = c2p.apply(gv2(cpt[0], cpt[1])); - (p.y - pivot[1]).atan2(p.x - pivot[0]).to_degrees() + if parent_to_comp(ctx, layer).determinant() < 0.0 { -1.0 } else { 1.0 } } /// Which axes a bounding-box handle scales: corners (0–3) both; the top and bottom edges (4, 6) diff --git a/crates/ui-egui/src/panels/viewer/gesture.rs b/crates/ui-egui/src/panels/viewer/gesture.rs index 797828618..72ef22a84 100644 --- a/crates/ui-egui/src/panels/viewer/gesture.rs +++ b/crates/ui-egui/src/panels/viewer/gesture.rs @@ -34,10 +34,11 @@ pub(super) enum Gesture { }, Rotate { layer: LayerId, - /// Comp → the space the pointer's angle is measured in, and the pivot there (see - /// `rotate_frame`): comp pixels, not screen points, so zooming mid-drag doesn't turn it. - c2p: Mat3, + /// The anchor where it shows, in comp pixels, not screen points, so zooming mid-drag + /// doesn't turn it. pivot: [f64; 2], + /// −1 when the layer's value runs opposite to the pointer (see `rotation_sign`). + sign: f64, /// The pointer's angle about the pivot 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 diff --git a/crates/ui-egui/src/panels/viewer/tests.rs b/crates/ui-egui/src/panels/viewer/tests.rs index 591e02f86..9cc8afc4c 100644 --- a/crates/ui-egui/src/panels/viewer/tests.rs +++ b/crates/ui-egui/src/panels/viewer/tests.rs @@ -160,21 +160,7 @@ fn with_ctx(s: &effectcraft_engine::Session, f: impl FnOnce(&EvalCtx) -> T) - f(&EvalCtx::new(&s.project, comp.id, comp, Tick::ZERO)) } -/// #2870: a parent scaled to nothing has no inverse: the Rotation tool measures on screen about -/// where the anchor collapsed to, with no panic and no NaN. -#[test] -fn rotate_frame_falls_back_on_screen_when_the_parent_collapses() { - let (s, c) = parented_session(json!({"position": [400.0, 200.0], "scale": [0.0, 100.0]})); - with_ctx(&s, |ctx| { - let l = ctx.comp.layer(LayerId(c)).unwrap(); - let (c2p, pivot) = rotate_frame(ctx, l).unwrap(); - assert_eq!(c2p, Mat3::IDENTITY); - assert_eq!(pivot, [400.0, 210.0]); - assert_eq!(pointer_angle(&c2p, pivot, [400.0, 310.0]), 90.0); - }); -} - -/// #2870: the marker and pivot of a child follow its parent, through the camera for a 3D layer +/// #2870: the pivot of a child follows its parent, through the camera for a 3D layer /// and none behind it. #[test] fn anchor_in_comp_follows_the_parent_and_the_camera() { diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs index ba16abcc8..00c07425d 100644 --- a/crates/ui-egui/src/panels/viewer_overlays.rs +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -369,6 +369,17 @@ pub(crate) fn motion_frame_at(frames: &[PathFrame], hits: &[KeyHit], pos: Pos2) (!hits.iter().any(|h| h.tangent.is_none() && h.layer == near.layer && h.prop == near.prop && h.time == near.time)).then_some(*near) } +/// Parent space → comp matrix of a 2D layer at the context time. +pub(crate) fn parent_to_comp(ectx: &EvalCtx, l: &Layer) -> Mat3 { + match l.parent.and_then(|p| ectx.comp.layer(p)) { + Some(p) => { + let w = ectx.world_matrix(p).0; + Mat3([[w[0][0], w[0][1], w[0][3]], [w[1][0], w[1][1], w[1][3]], [0.0, 0.0, 1.0]]) + } + None => Mat3::IDENTITY, + } +} + /// Draw the position motion path of a selected 2D layer: frame dots along the path, keyframe /// squares (filled when selected) and spatial Bezier tangent handles. Works through parents. #[allow(clippy::too_many_arguments)] @@ -399,7 +410,7 @@ pub(crate) fn motion_path( Some((x, y)) => separated_value(x, y, t), None => pos.value_at(t).as_vec3(), }; - let p2c = super::viewer::parent_to_comp(ectx, l); + let p2c = parent_to_comp(ectx, l); let to_scr = |v: [f64; 3]| { let c = p2c.apply(gv2(v[0], v[1])); map.to_screen([c.x, c.y]) diff --git a/crates/ui-egui/tests/it/ui_viewer.rs b/crates/ui-egui/tests/it/ui_viewer.rs index cfd44dc1a..71ea2ed79 100644 --- a/crates/ui-egui/tests/it/ui_viewer.rs +++ b/crates/ui-egui/tests/it/ui_viewer.rs @@ -2962,28 +2962,6 @@ fn rotate_tool_turns_a_child_of_a_rotated_parent_smoothly() { assert_turns_by(&rotate_round(&mut h, [380.0, 60.0], &[child]), 0, 10.0); } -/// #2870: under a parent scaled unevenly the angle is the one in parent space, so the layer's edge -/// stays under the pointer, which covers uneven degrees per frame. -#[test] -fn rotate_tool_measures_in_parent_space_under_a_stretched_parent() { - let (mut h, _, child) = rotate_child_harness(json!({"scale": [200.0, 100.0]})); - let seen = rotate_round(&mut h, [80.0, 120.0], &[child]); - let mut turned = 0.0_f64; - let mut last = 0.0_f64; - let want: Vec = (0..=40) - .map(|i| { - let a = (i as f64 * 10.0).to_radians(); - let ang = a.sin().atan2(a.cos() / 2.0).to_degrees(); - turned += (ang - last + 180.0).rem_euclid(360.0) - 180.0; - last = ang; - turned - }) - .collect(); - let end = seen.last().unwrap()[0]; - assert!((end - want[40]).abs() < 1.0, "the layer follows the pointer in parent space: {end}, want {}", want[40]); - assert!(seen.windows(2).all(|w| (w[1][0] - w[0][0]).abs() < 30.0), "no jump: {seen:?}"); -} - /// #2870: a parent's pixel aspect isn't inherited by its child, so it doesn't move the pivot either. #[test] fn rotate_tool_ignores_a_parents_pixel_aspect() { From 8a37ff6b43b65a48d20005b7f9bf6019dd159bc1 Mon Sep 17 00:00:00 2001 From: Bar Borer Date: Tue, 3 Nov 2026 05:07:59 +0200 Subject: [PATCH 43/71] Tests: trim the midpoint snap tests to what #2855 changes Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests/viewer.rs | 6 ------ crates/ui-egui/tests/it/ui_viewer.rs | 13 ++----------- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/tests/viewer.rs b/crates/engine/src/tests/viewer.rs index fa958a60c..9a0b9cc58 100644 --- a/crates/engine/src/tests/viewer.rs +++ b/crates/engine/src/tests/viewer.rs @@ -82,12 +82,6 @@ fn snap_point_beats_one_line_and_loses_only_to_two_nearer_lines() { let s = snap(&[[103.0, 51.0]], &[v, h, far], 5.0).unwrap(); assert_eq!(s.at, [102.0, 52.0]); assert_eq!(s.hits, [v, h]); - // A line out of its span doesn't count, so the point still wins. - let cut = SnapTarget::vline(103.0, PRI_LAYER, src).within(Some([0.0, 10.0])); - assert_eq!(snap(&[[103.0, 51.0]], &[cut, top, mid], 5.0).unwrap().hits, [mid]); - // No point in reach: a single line still snaps its own axis. - let s = snap(&[[103.0, 51.0]], &[top], 5.0).unwrap(); - assert_eq!(s.at, [103.0, 50.0]); } /// #2855: an unrotated layer's centre lines follow its edges' span rule, reach the outer comp diff --git a/crates/ui-egui/tests/it/ui_viewer.rs b/crates/ui-egui/tests/it/ui_viewer.rs index 39c70eecd..617de83ce 100644 --- a/crates/ui-egui/tests/it/ui_viewer.rs +++ b/crates/ui-egui/tests/it/ui_viewer.rs @@ -421,10 +421,9 @@ fn layer_drag_snaps_to_other_layers_edges_ctrl_toggles_and_options_apply() { } /// #2855: a dragged layer's edge midpoints snap to another layer's on both axes, side by side and -/// stacked, with Snap Edges Extended on and off; its centre lines snap along the target's extent, -/// and beyond it only with the option on. +/// stacked, with Snap Edges Extended on and off. #[test] -fn layer_drag_snaps_to_other_layers_midpoints_and_centre_lines() { +fn layer_drag_snaps_to_other_layers_midpoints() { let mut h = harness(); let box_id = h.state().session.active_comp().unwrap().layers[0].id; // A 100×100 target at x 450–550, y 50–150, centre (500, 100). @@ -454,14 +453,6 @@ fn layer_drag_snaps_to_other_layers_midpoints_and_centre_lines() { // Stacked: the box's top midpoint onto the target's bottom midpoint (500, 150). let p = drop(&mut h, [100.0, 210.5], [502.0, 153.0]); assert!(near(p, 500.0, 190.0), "top to bottom midpoint, extended {extended}: {p:?}"); - // Along the target, far from its centre point: the box's centre takes x from its centre - // line and y stays free. - let p = drop(&mut h, [100.0, 250.0], [503.0, 125.0]); - assert!((p[0] - 500.0).abs() < 0.01 && (p[1] - 125.0).abs() < 1.0, "centre line along the target, extended {extended}: {p:?}"); - // Beyond the target: its centre line reaches only with the option on. - let p = drop(&mut h, [100.0, 250.0], [503.0, 300.0]); - let snapped = (p[0] - 500.0).abs() < 0.01; - assert_eq!(snapped, extended, "centre line beyond the target, extended {extended}: {p:?}"); } } From 6c49ef819da9364f97e6fa795674c31d13587a59 Mon Sep 17 00:00:00 2001 From: Bar Borer Date: Tue, 3 Nov 2026 05:16:27 +0200 Subject: [PATCH 44/71] Tests: trim the key box tests to what #2857 changes Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline/tests.rs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline/tests.rs b/crates/ui-egui/src/panels/timeline/tests.rs index 9db6288bc..5aa7cedc5 100644 --- a/crates/ui-egui/src/panels/timeline/tests.rs +++ b/crates/ui-egui/src/panels/timeline/tests.rs @@ -628,11 +628,9 @@ fn a_key_menu_edit_selects_the_clicked_key_before_applying_the_command() { assert_eq!(app.session.state.selected_keys[0].time, prop.keys[0].time); } -/// A key's marquee target is the marker it draws: the glyph of its two halves, or a roving key's -/// smaller dot whatever its halves are. +/// A key's marquee target is the marker it draws: a roving key's is its smaller dot. #[test] fn a_keys_glyph_rect_is_what_it_draws() { - use effectcraft_engine::keyframe::{Interp, KeyHalf}; let mut s = effectcraft_engine::Session::default(); s.execute("comp.new", json!({"name": "C", "width": 64, "height": 64, "duration": 2})).unwrap(); let id = s.execute("layer.newSolid", json!({"name": "Box", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap(); @@ -640,9 +638,6 @@ fn a_keys_glyph_rect_is_what_it_draws() { let comp = s.active_comp().unwrap(); let mut k = comp.layer(LayerId(id)).unwrap().props.prop("transform/opacity").unwrap().keys[0].clone(); let c = pos2(100.0, 50.0); - k.in_interp = Interp::Hold; - k.out_interp = Interp::Linear; - assert_eq!(key_glyph_rect(&k, c), icons::keyframe_bounds(c, KEY_GLYPH_SIZE, KeyHalf::Hold, KeyHalf::Linear)); k.roving = true; assert_eq!(key_glyph_rect(&k, c), Rect::from_center_size(c, vec2(5.0, 5.0))); } @@ -675,8 +670,6 @@ fn keys_in_box_walks_the_rows_and_tests_each_glyph() { let (one, two) = (Tick::from_seconds_f64(1.0).0, Tick::from_seconds_f64(2.0).0); let across = |x0: f32, x1: f32| Rect::from_min_max(pos2(x0, 0.0), pos2(x1, 200.0)); assert_eq!(hit(across(0.0, 300.0)), [(layers[1], one), (layers[1], two), (layers[0], one), (layers[0], two)], "row order"); - assert_eq!(hit(across(0.0, 94.5)), [(layers[1], one), (layers[0], one)], "the box reaches the glyph's left point"); - assert!(hit(across(0.0, 93.5)).is_empty(), "the box stops short of the glyph"); assert_eq!(hit(Rect::from_min_max(pos2(0.0, 95.5), pos2(300.0, 200.0))), [(layers[0], one), (layers[0], two)], "the box reaches the glyph's bottom point"); assert!(hit(Rect::from_min_max(pos2(0.0, 97.0), pos2(300.0, 200.0))).is_empty(), "the box is below the glyph"); } From dc288839935f9390e855b6f3fc6146070faf38a4 Mon Sep 17 00:00:00 2001 From: Bar Borer Date: Tue, 3 Nov 2026 05:35:02 +0200 Subject: [PATCH 45/71] Tests: build the viewer tests' EvalCtx from the session's active comp Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/viewer/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/viewer/tests.rs b/crates/ui-egui/src/panels/viewer/tests.rs index 9cc8afc4c..066df8c3f 100644 --- a/crates/ui-egui/src/panels/viewer/tests.rs +++ b/crates/ui-egui/src/panels/viewer/tests.rs @@ -157,7 +157,7 @@ fn parented_session(parent: serde_json::Value) -> (effectcraft_engine::Session, fn with_ctx(s: &effectcraft_engine::Session, f: impl FnOnce(&EvalCtx) -> T) -> T { let comp = s.active_comp().unwrap(); - f(&EvalCtx::new(&s.project, comp.id, comp, Tick::ZERO)) + f(&EvalCtx::new(&s.project, s.active_comp_id().unwrap(), comp, Tick::ZERO)) } /// #2870: the pivot of a child follows its parent, through the camera for a 3D layer From 8ff461b35b19a35273a2ca861010911fbe7d71ea Mon Sep 17 00:00:00 2001 From: Bar Borer Date: Tue, 3 Nov 2026 06:17:54 +0200 Subject: [PATCH 46/71] Panels: a window widened under the open panel menu gives it its width back (#2864) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels.rs | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 0426dd82f..ead983912 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -324,12 +324,18 @@ pub(crate) fn panel_menu_area(app: &mut EffectcraftApp, ui: &mut egui::Ui) { } let mut close = false; let area_id = id.with("area"); - // measured afresh on each opening, as egui's popups are: its items and its window change between openings - let opening = ui.ctx().read_response(area_id).is_none(); + // a small window (a floated panel can be narrower than the menu) gets the menu at its width + let width = (ui.ctx().content_rect().width() - 16.0).max(0.0); + let grew = ui.ctx().data_mut(|d| { + let last = d.get_temp::(area_id); + d.insert_temp(area_id, width); + last.is_some_and(|last| width > last) + }); + // measured afresh on each opening, as egui's popups are: its items and its window change between + // openings. A window that grows under it gives back the width a narrower one took. + let opening = ui.ctx().read_response(area_id).is_none() || grew; let area = egui::Area::new(area_id).order(egui::Order::Foreground).fixed_pos(pos).sizing_pass(opening).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { - // a small window (a floated panel can be narrower than the menu) gets the menu at its width - let width = (ui.ctx().content_rect().width() - 16.0).max(0.0); ui.set_min_width(200.0_f32.min(width)); // only ever narrows what the area measured: the separator fills whatever width it is given ui.set_max_width(ui.max_rect().width().min(width)); @@ -525,6 +531,22 @@ mod panel_menu_viewport_tests { assert!(rect.min.x >= -0.5 && rect.max.x <= 160.5, "the menu follows the window: {rect:?}"); } + /// A long translation (here French) makes the menu wider than its minimum. + #[test] + fn a_window_widened_under_the_open_menu_gives_the_labels_their_room_back() { + crate::i18n::set_current("fr"); + let mut app = EffectcraftApp::new(Session::default()); + let ctx = egui::Context::default(); + ctx.set_embed_viewports(false); + open(&ctx, window()); + let wide = (0..3).filter_map(|_| pass(&mut app, &ctx, window(), 1000.0, vec![])).last().unwrap(); + let narrow = (0..3).filter_map(|_| pass(&mut app, &ctx, window(), 160.0, vec![])).last().unwrap(); + let again = (0..3).filter_map(|_| pass(&mut app, &ctx, window(), 1000.0, vec![])).last().unwrap(); + crate::i18n::set_current("en"); + assert!(narrow.width() < wide.width(), "it fits the narrow window: {narrow:?}, wide {wide:?}"); + assert!((again.width() - wide.width()).abs() < 1.0, "back to its own width: {again:?}, first {wide:?}"); + } + #[test] fn each_opening_measures_the_menu_afresh() { let mut app = EffectcraftApp::new(Session::default()); From 6b1f91ffb88323175b4c1f5379cbc353c2c20059 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= Date: Tue, 3 Nov 2026 07:22:46 +0100 Subject: [PATCH 47/71] Cryptomatte: read an ID's coverage from the kept probe (#473) Session::crypto_coverage returns the coverage of one Cryptomatte ID over the layer, read from the probe Session::crypto_probe_job keeps: at most max_side samples on the long side, each at the centre of its cell, or None until the probe is built. It is what the Cryptomatte eyedropper's hover highlight will draw. Tests cover the coverage of an ID from the kept probe and the cost of a highlight with six ranks. --- crates/engine/src/cmd.rs | 4 +- crates/engine/src/cmd/effect.rs | 33 ++++++++ crates/engine/src/session/render.rs | 7 ++ crates/engine/src/tests/crypto_pick.rs | 104 ++++++++++++++++++++++++- 4 files changed, 145 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 1168c431b..bcaedac52 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -18,8 +18,8 @@ pub mod content_fill; mod create; mod edit; mod effect; -pub use effect::CryptoProbeJob; -pub(crate) use effect::{CryptoProbeSlot, crypto_probe_job, crypto_probe_ready, keep_crypto_probe, kept_crypto_probe}; +pub use effect::{CryptoCoverage, CryptoProbeJob}; +pub(crate) use effect::{CryptoProbeSlot, crypto_coverage, crypto_probe_job, crypto_probe_ready, keep_crypto_probe, kept_crypto_probe}; pub mod essential; pub mod expr_tools; mod file; diff --git a/crates/engine/src/cmd/effect.rs b/crates/engine/src/cmd/effect.rs index f41ba3f9c..f417c123f 100644 --- a/crates/engine/src/cmd/effect.rs +++ b/crates/engine/src/cmd/effect.rs @@ -500,6 +500,39 @@ pub(crate) fn crypto_probe_job(s: &Session, p: &Value) -> Result Option { + let (key, ..) = probe_key(s, p, "effect.cryptomatteAt").ok()?; + let probe = cached_probe(s, key)?; + let b = probe.grid.as_ref().ok()?; + let (aw, ah) = (b.aux.width as usize, b.aux.height as usize); + if aw == 0 || ah == 0 { + return None; + } + let step = aw.max(ah).div_ceil(max_side.max(1)).max(1); + let (w, h) = (aw.div_ceil(step), ah.div_ceil(step)); + let planes = b.aux.crypto_rank_planes(&b.layer); + let sample = |j: usize| { + planes.iter().filter(|(id, _)| id.get(j).is_some_and(|v| v.to_bits() == hash)).map(|(_, c)| c.get(j).map_or(0.0, |c| c.max(0.0))).sum::().min(1.0) + }; + // Each sample at the centre of its step × step cell; the last cells are cut by the layer. + let at = |x: usize, y: usize| sample((y * step + step / 2).min(ah - 1) * aw + (x * step + step / 2).min(aw - 1)); + let coverage = (0..w * h).map(|i| at(i % w, i / w)).collect(); + let (size, uv) = ([aw as f64 / b.aux.scale, ah as f64 / b.aux.scale], [aw as f64 / (w * step) as f64, ah as f64 / (h * step) as f64]); + Some(CryptoCoverage { width: w, height: h, coverage, size, uv }) +} + +/// An ID's coverage over a layer, for the eyedropper's hover highlight: `width` × `height` +/// samples (row-major, 0..1). The layer, `size` effect-space pixels from its origin, shows the +/// texture from (0, 0) to `uv`. +pub struct CryptoCoverage { + pub width: usize, + pub height: usize, + pub coverage: Vec, + pub size: [f64; 2], + pub uv: [f64; 2], +} + /// What a pick finds at a point. enum Hit { /// `probeOnly` and the probe isn't built yet. diff --git a/crates/engine/src/session/render.rs b/crates/engine/src/session/render.rs index 86127a510..05603edac 100644 --- a/crates/engine/src/session/render.rs +++ b/crates/engine/src/session/render.rs @@ -36,6 +36,13 @@ impl Session { commands::crypto_probe_job(self, p) } + /// The coverage of Cryptomatte ID `hash` (as `effect.cryptomatteAt` reports it, parsed) over + /// the layer of params `p`, at most `max_side` samples on the long side, from the kept probe: + /// `None` until [`Session::crypto_probe_job`] has built it. + pub fn crypto_coverage(&self, p: &Value, hash: u32, max_side: usize) -> Option { + commands::crypto_coverage(self, p, hash, max_side) + } + /// Let go of the kept Cryptomatte probe (and its aux channels), and cancel a build in /// flight: the eyedropper was disarmed. Edits, footage changes and Open / New do it too. pub fn drop_crypto_probe(&self) { diff --git a/crates/engine/src/tests/crypto_pick.rs b/crates/engine/src/tests/crypto_pick.rs index 2e61f57a1..0afcd34fd 100644 --- a/crates/engine/src/tests/crypto_pick.rs +++ b/crates/engine/src/tests/crypto_pick.rs @@ -431,7 +431,13 @@ fn hover_latency() { format!("{:.3}", t0.elapsed().as_secs_f64() * 1e3) }) .collect(); - eprintln!("HOVER {name}: first {} ms, then {} ms", ms[0], ms[1..].join(" / ")); + // The highlight of the hovered ID (built once per ID, at most 1024 samples wide). + let q = json!({"effect": "Cryptomatte", "x": 100.0, "y": 500.0}); + let hash = u32::from_str_radix(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["hash"].as_str().unwrap(), 16).unwrap(); + let t0 = std::time::Instant::now(); + let c = s.crypto_coverage(&q, hash, 1024).unwrap(); + let cov = t0.elapsed().as_secs_f64() * 1e3; + eprintln!("HOVER {name}: first {} ms, then {} ms; highlight {}x{} {cov:.2} ms", ms[0], ms[1..].join(" / "), c.width, c.height); } } @@ -794,3 +800,99 @@ fn undoing_picks_keeps_the_probe_and_the_step_says_pick() { s.execute_checked("edit.undo", json!({})).unwrap(); assert!(!s.crypto_probe_ready(&q).unwrap(), "an undone opacity change needs a new probe"); } + +/// The hover highlight: an ID's coverage from the kept probe, one sample at the centre of each +/// cell of aux pixels, over the layer's extent (the last cells cut by it). +#[test] +fn coverage_of_an_id_from_the_kept_probe() { + let s = session(); + let q = json!({"effect": 1, "x": 0.5, "y": 1.5}); + let left = crypto_hash("left"); + assert!(s.crypto_coverage(&q, left, 64).is_none(), "no coverage before the probe"); + s.crypto_probe_job(&q).unwrap().unwrap()(); + let c = s.crypto_coverage(&q, left, 64).unwrap(); + assert_eq!((c.width, c.height, c.size, c.uv), (16, 8, [8.0, 4.0], [1.0, 1.0]), "the aux pixels over the layer"); + assert!(c.coverage.iter().all(|v| (0.0..=1.0).contains(v)) && c.coverage.iter().any(|v| *v > 0.5) && c.coverage.contains(&0.0), "{:?}", c.coverage); + assert_eq!(c.coverage[0], 0.6, "left's coverage at the first aux pixel"); + assert!(s.crypto_coverage(&q, crypto_hash("nobody"), 64).unwrap().coverage.iter().all(|v| *v == 0.0)); + assert_eq!(s.crypto_coverage(&q, left, 2).map(|c| (c.width, c.height)), Some((2, 1))); + // An odd size: 2 × 2 cells, the last ones half outside the layer; samples at cell centres. + let mut s = cells_session(1919, 1079, false); + let q = json!({"effect": "Cryptomatte", "x": 40.0, "y": 10.0}); + assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["name"], "c1_0"); + let c = s.crypto_coverage(&q, crypto_hash("c1_0"), 1024).unwrap(); + assert_eq!((c.width, c.height, c.size), (960, 540, [1919.0, 1079.0])); + assert_eq!(c.uv, [1919.0 / 1920.0, 1079.0 / 1080.0]); + // Sample 18 covers pixels 36-37: its centre (37) is in c1_0, its corner (36) in c0_0. + assert_eq!((c.coverage[17], c.coverage[18], c.coverage[37]), (0.0, 1.0, 0.0)); +} + +struct Ranked(u32, u32, std::sync::OnceLock>); +impl FootageSource for Ranked { + fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option> { + Some(Arc::new(Image::new(f.width, f.height))) + } + fn aux(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + Some( + self.2 + .get_or_init(|| { + let (w, h) = (self.0 as usize, self.1 as usize); + let mut aux = AuxChannels::new(w as u32, h as u32, 1.0); + for part in 0..3 { + for (k, c) in ["R", "G", "B", "A"].iter().enumerate() { + let rank = part * 2 + k / 2; + let v: Vec = (0..w * h) + .map(|i| if k % 2 == 0 { crypto_float(crypto_hash(&format!("r{rank}_{}", Cells::name(i % w, i / w)))) } else { 1.0 / 6.0 }) + .collect(); + aux.channels.push((format!("CryptoObject{part:02}.{c}"), v)); + } + } + Arc::new(aux) + }) + .clone(), + ) + } +} + +fn rss_mb() -> f64 { + let s = std::fs::read_to_string("/proc/self/status").unwrap_or_default(); + s.lines().find(|l| l.starts_with("VmRSS")).and_then(|l| l.split_whitespace().nth(1)).and_then(|v| v.parse::().ok()).unwrap_or(0.0) / 1024.0 +} + +/// Highlight cost with 6 ranks (Cryptomatte's default 3 parts) at 1080p, 4K and 8K, and the memory the +/// probe pins. Run with `--ignored --nocapture`; it measures, it doesn't assert. +#[test] +#[ignore] +fn highlight_cost_with_six_ranks() { + for (w, h) in [(1920u32, 1080u32), (3840, 2160), (7680, 4320)] { + let r0 = rss_mb(); + let mut s = Session { footage: Arc::new(Ranked(w, h, Default::default())), importer: Some(Arc::new(CellsProbe(w, h))), ..Default::default() }; + let item = s.execute("file.import", json!({"paths": ["/renders/ranked.exr"]})).unwrap()["items"][0].clone(); + s.execute("comp.new", json!({"name": "C", "width": w, "height": h, "frameRate": 24, "duration": 2})).unwrap(); + s.execute("layer.addItem", json!({"item": item})).unwrap(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + let q = json!({"effect": "Cryptomatte", "x": 100.0, "y": 500.0}); + let t0 = std::time::Instant::now(); + let r = s.execute("effect.cryptomatteAt", q.clone()).unwrap(); + let build = t0.elapsed().as_secs_f64() * 1e3; + let hash = u32::from_str_radix(r["hash"].as_str().unwrap(), 16).unwrap(); + let mut ms = vec![]; + for _ in 0..5 { + let t0 = std::time::Instant::now(); + let c = s.crypto_coverage(&q, hash, 1024).unwrap(); + ms.push(t0.elapsed().as_secs_f64() * 1e3); + assert!(c.coverage.iter().any(|v| *v > 0.0)); + } + ms.sort_by(f64::total_cmp); + let aux = s.footage.aux(ItemId(0), &Footage::default(), Tick::ZERO).unwrap(); + let bytes: usize = aux.channels.iter().map(|(_, v)| v.len() * 4).sum(); + eprintln!( + "HIGHLIGHT 6 ranks {w}x{h}: aux {:.0} MB ({} planes), probe build {build:.1} ms, highlight median {:.2} ms (min {:.2}), RSS +{:.0} MB", + bytes as f64 / 1e6, + aux.channels.len(), + ms[2], + ms[0], + rss_mb() - r0 + ); + } +} From a5f0af1f57e0a0e643afa9da297a68369bc69f5a Mon Sep 17 00:00:00 2001 From: heojunfo Date: Tue, 3 Nov 2026 15:33:34 +0900 Subject: [PATCH 48/71] test(export): name the tiled bit in the EXR version check Co-Authored-By: Claude Opus 5.5 --- crates/export/tests/it/roundtrip.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/export/tests/it/roundtrip.rs b/crates/export/tests/it/roundtrip.rs index fbad2ca48..3ad174691 100644 --- a/crates/export/tests/it/roundtrip.rs +++ b/crates/export/tests/it/roundtrip.rs @@ -311,7 +311,8 @@ fn exr_sequence_writes_scan_lines() { let om = OutputModule::for_format(OutputFormat::ExrSequence); run(&p, cid, &NoFootage, &om, &d.join("f_[#####].exr")); let bytes = std::fs::read(d.join("f_00000.exr")).expect("exr"); - // Version field: 2, then the flags byte whose 0x02 bit marks a single-part tiled file. + // Version field (little-endian u32 at bytes 4..8): version 2, then the flags byte. Its 0x02 + // bit is the tiled bit, 0x200 of the field, so a scan-line file has 0 there. assert_eq!(&bytes[4..6], &[2, 0], "{:02x?}", &bytes[..8]); let meta = exr::meta::MetaData::read_from_buffered(std::io::Cursor::new(&bytes), false).expect("exr header"); assert_eq!(meta.headers[0].blocks, exr::meta::BlockDescription::ScanLines); From 6b0f72e4bef415be9626b3094c8ca41250048db3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= Date: Tue, 3 Nov 2026 07:22:46 +0100 Subject: [PATCH 49/71] Cryptomatte eyedropper: the viewer pick (#473) panels::crypto_pick is the eyedropper's viewer side; it is wired in by the next commit. Armed, the hover names the ID under the cursor and highlights its coverage, a click replaces the Selection with it, Shift adds, Alt or Ctrl (Option or Cmd on a Mac) removes. Each pick is one undo step, "Pick Cryptomatte". The probe the hover needs is built off the UI thread, one at a time for the app (EffectcraftApp::crypto_probe_busy, re-arming included), not while playing and not until a scrub has settled for 100 ms; meanwhile the chip says so. Session::crypto_probe_ready asks for readiness without changing anything; a job is asked for only when it starts. Clicks made meanwhile are queued, own the build and are made in order at the end of the pass, after its keys and edits. A click is the frame, point, resolution and project revision its press saw. If any of them changed before it is made (an edit, Ctrl+Z, a nudge, a frame step), it and every click waiting with it are dropped and the status says "Pick not made". A pick's own change of the revision doesn't count as an edit for a click still held. Losing the window focus drops a held press. A click that takes the focus from the Selection field waits for the field to commit and is made on top of it. The overlay takes drags too: a click that drifts picks at its press, and one dragged further than 24 points picks nothing ("Drag cancelled the pick"). Presses and releases are read from the pass's events in order rather than from egui's end-of-pass click state, since a slow pass can hold a whole click. Space, the Hand and the Zoom tool keep their clicks, the middle button pans, and a right-click is passed on under viewer::FORWARDED_SECONDARY for the viewer to open its context menu (the viewer side is in the next commit). Errors show in the chip in red, without the command's prefix, wrapped to the viewer's width. The hovered ID's coverage (Session::crypto_coverage) is drawn as a texture through the layer's effect-space transform, rebuilt only when the hovered ID, frame or revision changes, and exposed to the test harness as viewer.cryptoPick.highlight. Supporting changes: - EffectcraftApp::say sets the status line and restarts its 5 s; each status message now gets its own 5 s, also when the same text is shown again. - Points snap to the centre of the shown frame's pixel at the exact 1/n scale the viewer renders (frames::render_scale; the frame key keeps 0.333 for Third, which named the next row's ID). Cache Frames When Idle renders at that exact scale too, so idle-cached frames are on the grid the pick reads. --- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/frame.rs | 21 +- crates/ui-egui/src/app/prefetch.rs | 5 +- crates/ui-egui/src/frames.rs | 8 + crates/ui-egui/src/frames/tests.rs | 11 + crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels.rs | 1 + crates/ui-egui/src/panels/crypto_pick.rs | 600 +++++++++++++++++++++++ crates/ui-egui/src/panels/viewer.rs | 4 + 9 files changed, 648 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/panels/crypto_pick.rs diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index dced78349..9b0e1583e 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -77,6 +77,7 @@ impl EffectcraftApp { passive_tex: std::collections::HashMap::new(), viewer_native: None, viewer_shown: None, + crypto_probe_busy: Default::default(), viewer_image: None, viewer_readback: Default::default(), wgpu: None, diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index ffbf57257..0af8f929b 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -454,10 +454,22 @@ impl EffectcraftApp { } } + /// Sets the status line and restarts its 5 s, also when the same text is still showing (a + /// message set again would otherwise keep the time it was first shown). + pub(crate) fn say(&mut self, ctx: &egui::Context, msg: String) { + ctx.data_mut(|d| d.remove::<(f64, String)>(egui::Id::unique("status-since"))); + self.ui.status = msg; + } + /// The status message or the latest toast, bottom left (`lift` points higher while the /// Importing card is there). fn draw_toast(&mut self, ui: &mut egui::Ui, full: egui::Rect, lift: f32) { let now = self.now; + // A message cleared before its time (a click on it, Esc, another pick) forgets its time, so + // the same message shown again later gets its own 5 s. + if self.ui.status.is_empty() { + ui.data_mut(|d| d.remove::<(f64, String)>(egui::Id::unique("status-since"))); + } let msg = if !self.ui.status.is_empty() { Some(self.ui.status.clone()) } else { @@ -485,11 +497,14 @@ impl EffectcraftApp { } ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); if !self.ui.status.is_empty() { - let since: f64 = ui.data(|d| d.get_temp(egui::Id::unique("status-since")).unwrap_or(now)); - ui.data_mut(|d| d.insert_temp(egui::Id::unique("status-since"), since)); + // Each message gets its own 5 s (a new one doesn't inherit the old one's time). + let key = egui::Id::unique("status-since"); + let shown = ui.data(|d| d.get_temp::<(f64, String)>(key)).filter(|(_, m)| *m == self.ui.status); + let since = shown.map_or(now, |(t, _)| t); + ui.data_mut(|d| d.insert_temp(key, (since, self.ui.status.clone()))); if now - since > 5.0 { self.ui.status.clear(); - ui.data_mut(|d| d.remove::(egui::Id::unique("status-since"))); + ui.data_mut(|d| d.remove::<(f64, String)>(key)); } } } diff --git a/crates/ui-egui/src/app/prefetch.rs b/crates/ui-egui/src/app/prefetch.rs index d351ca411..54bf07250 100644 --- a/crates/ui-egui/src/app/prefetch.rs +++ b/crates/ui-egui/src/app/prefetch.rs @@ -405,11 +405,12 @@ impl EffectcraftApp { return; } let cur = comp.frame_rate.frame_at(self.session.time()).clamp(wa, wb); - let fit = self.frames_that_fit(comp, series.scale as f64 / 1000.0).min(n); + // The viewer's exact factor (1/3, not the key's 0.333): it shows these frames. + let scale = frames::render_scale(series.scale, comp.resolution.factor()); + let fit = self.frames_that_fit(comp, scale).min(n); // One thread each (`Frames::request_background`), so every frame thread takes one. let slots = self.frames_parallelism().max(1); let mut queued = self.frames.inflight(); - let scale = series.scale as f64 / 1000.0; let mut pending = false; for k in 0..fit { let f = wa + (cur - wa + k).rem_euclid(n); diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index c79b023b2..87cd9a17b 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -1580,5 +1580,13 @@ impl Queue { } } +/// The render scale a viewer frame's key stands for: it keeps thousandths, but the viewer +/// renders every n-th pixel, and 0.333 is not 1/3 (2 pixels off by row 1000 at Third). The +/// comp's own factor decides when two factors share the thousandths (from 1/34). +pub(crate) fn render_scale(thousandths: u32, factor: Option) -> f64 { + let n = factor.map(f64::from).filter(|n| (1000.0 / n).round() == f64::from(thousandths)); + 1.0 / n.unwrap_or_else(|| (1000.0 / f64::from(thousandths.max(1))).round()).max(1.0) +} + #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/frames/tests.rs b/crates/ui-egui/src/frames/tests.rs index d04793f50..692df16f7 100644 --- a/crates/ui-egui/src/frames/tests.rs +++ b/crates/ui-egui/src/frames/tests.rs @@ -1,5 +1,16 @@ use super::*; +#[test] +fn render_scales_are_the_factors_the_thousandths_stand_for() { + assert_eq!(render_scale(333, Some(3)), 1.0 / 3.0); + assert_eq!(render_scale(333, None), 1.0 / 3.0); + assert_eq!(render_scale(167, Some(6)), 1.0 / 6.0); + assert_eq!((render_scale(1000, None), render_scale(500, None), render_scale(250, Some(4))), (1.0, 0.5, 0.25)); + // 1/34 and 1/35 are both 29 thousandths: the comp's factor decides. + assert_eq!((render_scale(29, Some(35)), render_scale(29, Some(34))), (1.0 / 35.0, 1.0 / 34.0)); + assert_eq!(render_scale(29, Some(2)), 1.0 / 34.0); +} + #[test] fn converting_a_frame_with_nan_alpha_keeps_the_viewer_alive() { let image = effectcraft_engine::render::Image::filled(1, 1, [0.5, 0.25, 0.1, f32::NAN]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ca8c1fb01..e237eeaa6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -312,6 +312,8 @@ pub struct EffectcraftApp { pub(crate) viewer_native: Option<(egui::TextureId, FrameKey, Arc)>, /// The texture the viewer draws and its frame key (CPU or GPU frame). pub(crate) viewer_shown: Option<(egui::TextureId, FrameKey)>, + /// A Cryptomatte eyedropper probe is being built (one at a time, re-arming included). + pub(crate) crypto_probe_busy: Arc, /// Last displayed image (Info panel colour, eyedroppers, histograms). GPU frames fill it on /// demand ([`EffectcraftApp::viewer_pixels`]). pub(crate) viewer_image: Option>, diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 933f90c71..c104d96df 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -4,6 +4,7 @@ pub mod anim_tools; pub mod camera_tracker_ui; pub mod comp_settings; pub mod content_fill_panel; +pub mod crypto_pick; pub mod delete_items; pub mod dialogs; pub mod dialogs_3d; diff --git a/crates/ui-egui/src/panels/crypto_pick.rs b/crates/ui-egui/src/panels/crypto_pick.rs new file mode 100644 index 000000000..56479c476 --- /dev/null +++ b/crates/ui-egui/src/panels/crypto_pick.rs @@ -0,0 +1,600 @@ +//! The Cryptomatte eyedropper in the Composition viewer (#473). The button next to the effect's +//! Selection arms it ([`super::effect_controls::toggle_pick`] with [`KIND`]); while it is armed +//! the viewer names the ID under the cursor, and a click picks it into the Selection: a plain +//! click replaces it, Shift adds, Alt or Ctrl (Option or Cmd on a Mac) removes. It stays armed +//! for the next click until Esc or the button again, and highlights the hovered ID's coverage. +//! Arming it whenever the effect is selected (After Effects' behaviour, #1811) would only need +//! to set the same pick. +//! +//! The hover needs the frame's Cryptomatte probe ([`effectcraft_engine::Session::crypto_probe_job`]): +//! it is built off the UI thread, one at a time, not while playing or scrubbing; the chip says so +//! meanwhile, and clicks wait for it (the hover then builds nothing), except while playing, where +//! a click without its probe is not made. Once built, a hover is a few array reads. +//! +//! A click is the frame and the project shown at its press (and the resolution it read). It is +//! made at the end of a pass ([`upkeep`]), checked against the frame and the project right +//! before: if either changed since its press, it and every click waiting with it are dropped, +//! and the status says so. A press dragged further than [`SLOP`] picks nothing, and says so. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use effectcraft_engine::commands::CryptoProbeJob; +use effectcraft_engine::geom::Mat3; +use effectcraft_engine::project::Layer; +use effectcraft_engine::render::EvalCtx; +use egui::{Color32, Rect, Sense, vec2}; +use serde_json::{Value, json}; + +use super::fx_widgets as fw; +use crate::EffectcraftApp; +use crate::i18n::{tr, tr_args}; +use crate::state::{FxPick, Tool}; +use crate::theme::Tokens; + +/// [`FxPick::kind`] of the Cryptomatte eyedropper. +pub const KIND: &str = "crypto"; + +/// How long the time must stay put before a probe is built for it (scrubbing). +const SETTLE: f64 = 0.1; + +/// A press that moves further than this (points) before its release picks nothing. +const SLOP: f32 = 24.0; + +/// The status when waiting clicks are dropped. +const NOT_MADE: &str = "Pick not made: the frame or the project changed before its Cryptomatte IDs were read"; + +/// The status when a click is not made because the preview plays. +const PLAYING: &str = "Pick not made: stop playback to pick"; + +/// egui temp-data id for the eyedropper's [`State`] between frames. +const VIEWER_CRYPTO_PICK: &str = "viewer-crypto-pick"; + +/// egui temp-data id for the project revision and frame at the start of a pass. +const VIEWER_CRYPTO_PICK_START: &str = "viewer-crypto-pick-start"; + +fn pick_id() -> egui::Id { + egui::Id::unique(VIEWER_CRYPTO_PICK) +} + +fn pick_start_id() -> egui::Id { + egui::Id::unique(VIEWER_CRYPTO_PICK_START) +} + +/// The press of a click in progress. +#[derive(Clone)] +struct Press { + pos: egui::Pos2, + /// Its modifiers: letting go of Shift a moment early still adds. + keys: egui::Modifiers, + /// A menu or popup list was open: the click only closes it. + menu: bool, + /// A text field had the focus: the click commits it (the project may change by that). + typing: bool, + /// The pick query at the press (frame, resolution, layer point) and the project revision. + q: Option, + rev: u64, +} + +/// The hovered ID's coverage highlight drawn over the viewer. +#[derive(Clone)] +struct HighlightMask { + key: (u32, Value), + texture: egui::TextureHandle, + size: [f64; 2], + uv: [f32; 2], +} + +/// Hover and click state between frames. +#[derive(Clone, Default)] +struct State { + /// The last hover query (with the project revision) and its answer (or why there is none). + asked: Option<(Value, u64)>, + reply: Option>, + /// Clicks waiting for their probe, in order, and the project revision they were made at. + pending: Vec, + pending_rev: u64, + /// The waiting clicks took the focus from a text field (the Selection being typed): they + /// are made once it has committed, on top of what it committed. + after_typing: bool, + /// A text field or a popup had the keyboard the pass before: its Esc isn't the pick's + /// (egui lets go of the focus and closes popups before the viewer runs). + esc_taken: bool, + /// The press of the click in progress. + press: Option, + /// Probes may be built for the waiting clicks (not while playing or scrubbing). + start: bool, + /// The comp time shown and since when (to wait for a scrub to settle). + shown: Option<(f64, f64)>, + /// The pick (layer, Selection) and the egui pass this state was last used for. + seen: Option<((u64, u64), u64)>, + /// The hovered ID's highlight: its hash and probe query, coverage texture, size and uv. + mask: Option, +} + +/// Clears the busy flag and repaints when a build ends, a panic included. +struct Done(Arc, egui::Context); +impl Drop for Done { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + self.1.request_repaint(); + } +} + +fn run(job: CryptoProbeJob, done: Done) { + let _done = done; + job(); +} + +/// Whether the probe `q` needs is built; if not and `start`, start building it unless one is +/// being built (one at a time for the app). Asking changes nothing: only a build started here +/// supersedes another. +fn ready(app: &EffectcraftApp, ctx: &egui::Context, q: &Value, start: bool) -> Result { + let busy = &app.crypto_probe_busy; + if app.session.crypto_probe_ready(q).map_err(|e| shown_error(&e.to_string()))? { + return Ok(true); + } + // (Only this thread sets the flag, so it stays clear until the store.) + if !start || busy.load(Ordering::Acquire) { + return Ok(false); + } + let Some(job) = app.session.crypto_probe_job(q).map_err(|e| shown_error(&e.to_string()))? else { return Ok(true) }; + busy.store(true, Ordering::Release); + let done = Done(busy.clone(), ctx.clone()); + if cfg!(target_arch = "wasm32") { + run(job, done); + } else if std::thread::Builder::new().name("crypto-probe".into()).spawn(move || run(job, done)).is_err() { + return Err("could not start reading the Cryptomatte IDs".into()); + } + Ok(false) +} + +/// An engine error as the chip and the status line show it: without the command's "invalid +/// parameters for `…`:" prefix, capitalised. +fn shown_error(e: &str) -> String { + let e = e.strip_prefix("invalid parameters for `").and_then(|r| r.split_once("`: ")).map_or(e, |(_, m)| m); + let mut c = e.chars(); + c.next().map_or_else(String::new, |f| f.to_uppercase().chain(c).collect()) +} + +/// The keys that remove an ID (Ctrl removes on a Mac too, but means right-click there). +pub fn remove_keys(os: egui::os::OperatingSystem) -> &'static str { + if os == egui::os::OperatingSystem::Mac { tr("Option or Cmd") } else { tr("Alt or Ctrl") } +} + +/// At the start of every pass, before its keys: the project revision and frame on screen when +/// the pass's input happened. A press in the pass is taken against them, not against what the +/// pass's own keys made of them (a slow pass can hold a press, a PageDown and the release). +pub fn pass_start(app: &EffectcraftApp, ctx: &egui::Context) { + ctx.data_mut(|d| d.insert_temp(pick_start_id(), (app.session.revision, app.session.time().seconds()))); +} + +/// Every frame: a disarmed eyedropper lets go of its state and the session's probe. +pub fn upkeep(app: &mut EffectcraftApp, ctx: &egui::Context) { + let sid = pick_id(); + // Another project (New, Open, Revert) ends a pick: it is kept by layer id and property uid, + // which the next project reuses. + let eid = egui::Id::unique("viewer-fx-pick-project"); + let epoch = app.session.script_ui.project_epoch; + let seen = ctx.data(|d| d.get_temp::(eid)); + if seen != Some(epoch) { + if seen.is_some() && app.ui.fx_pick.is_some() { + app.ui.fx_pick = None; + app.ui.status.clear(); + } + ctx.data_mut(|d| d.insert_temp(eid, epoch)); + } + if app.ui.fx_pick.as_ref().is_none_or(|p| p.kind != KIND) && ctx.data(|d| d.get_temp::(sid)).is_some() { + ctx.data_mut(|d| d.remove::(sid)); + app.session.drop_crypto_probe(); + } + // The clicks waiting in the viewer, after everything else this pass did (its keys, edits and + // frame steps included). + if let Some(mut st) = ctx.data(|d| d.get_temp::(sid)).filter(|s| s.seen.is_some_and(|(_, n)| n == ctx.cumulative_pass_nr())) { + make_waiting(app, ctx, &mut st); + ctx.data_mut(|d| d.insert_temp(sid, st)); + } +} + +/// Drop every waiting click, and say so. +fn drop_waiting(app: &mut EffectcraftApp, ctx: &egui::Context, st: &mut State) { + (st.pending, st.after_typing) = (vec![], false); + app.say(ctx, tr(if app.playback.playing { PLAYING } else { NOT_MADE }).to_string()); +} + +/// Make one click. Our own pick is not "the project changed" for a click still held. +fn make(app: &mut EffectcraftApp, ctx: &egui::Context, st: &mut State, q: Value) -> Result { + let before = app.session.revision; + let r = crate::menus::invoke(app, ctx, "effect.pickCryptomatte", q); + if let Some(p) = st.press.as_mut() + && p.rev == before + { + p.rev = app.session.revision; + } + r +} + +/// Make the waiting clicks whose probe is built, in order. Each is checked against the frame and +/// the project right before it is made, with nothing in between: if either changed since its +/// press, it and the clicks waiting with it are not made. +fn make_waiting(app: &mut EffectcraftApp, ctx: &egui::Context, st: &mut State) { + // A click that took the focus from a text field waits for the field to commit (it may do so + // later in this pass or in the next), then is made on what it committed. + let typing = st.after_typing && ctx.text_edit_focused(); + if st.after_typing && !typing { + (st.after_typing, st.pending_rev) = (false, app.session.revision); + } + while let Some(q) = st.pending.first().cloned().filter(|_| !typing) { + let comp = app.session.active_comp_id().map(|c| c.0); + if st.pending_rev != app.session.revision || q["comp"].as_u64() != comp || q["time"].as_f64() != Some(app.session.time().seconds()) { + return drop_waiting(app, ctx, st); + } + match ready(app, ctx, &q, st.start) { + Ok(true) => match make(app, ctx, st, q) { + Ok(r) if r["pending"] == true => break, + r => { + st.pending.remove(0); + (st.asked, st.pending_rev) = (None, app.session.revision); + let msg = match r { + Ok(r) => tr_args("Cryptomatte Selection: {}", &[&r["selection"].as_str().unwrap_or_default()]), + Err(e) => shown_error(&e), + }; + app.say(ctx, msg); + } + }, + Ok(false) => break, + Err(e) => { + (st.pending, st.after_typing) = (vec![], false); + app.say(ctx, e); + } + } + } +} + +/// Middle-button pan and forwarded right-click for the armed eyedropper overlay. +fn viewer_pan_and_secondary(app: &mut EffectcraftApp, ctx: &egui::Context, resp: &egui::Response) { + // The middle button pans the viewer, as the Hand tool (and as without the eyedropper). + if resp.dragged_by(egui::PointerButton::Middle) { + let pid = egui::Id::unique("viewer-fx-pick-pan"); + if resp.drag_started() { + let start = app.ui.viewer.pan; + ctx.data_mut(|d| d.insert_temp(pid, start)); + } + if let (Some(start), Some(d)) = (ctx.data(|m| m.get_temp::<[f32; 2]>(pid)), resp.total_drag_delta()) { + app.ui.viewer.pan = [start[0] + d.x, start[1] + d.y]; + } + } + // The right button opens the viewer's context menu (next pass). + if resp.secondary_clicked() + && let Some(p) = resp.interact_pointer_pos() + { + ctx.data_mut(|d| d.insert_temp(egui::Id::unique(super::viewer::FORWARDED_SECONDARY), p)); + } +} + +/// Whether Space / Hand / Zoom (`tools`) or middle/right buttons (`nav`) own the pointer. +struct PointerNav { + tools: bool, + nav: bool, +} + +/// Queues Cryptomatte picks from primary presses and releases on the armed eyedropper overlay. +/// +/// A press is taken against the pass-start revision and frame, then queued for [`make_waiting`] +/// (or dropped if the drag, playback, frame or project make it invalid). `nav` suppresses a new +/// press when another pointer button or tool owns the click. +fn viewer_click_to_pick( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + map: &super::viewer::ViewerMap, + ectx: &EvalCtx, + st: &mut State, + pointer: PointerNav, + at: &dyn Fn(egui::Pos2) -> Option, +) { + let ctx = ui.ctx().clone(); + // The click is what the press saw: its frame, resolution, point and project. The pass's + // primary presses and releases are taken in order: a slow pass can hold a whole click, or one + // click's release and the next one's press (egui then reports no press on the overlay). + // `None`: the window lost the focus, so a held press may never see its release. + let events: Vec> = ui.input(|i| { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers } => Some(Some((*pos, *pressed, *modifiers))), + egui::Event::WindowFocused(false) => Some(None), + _ => None, + }) + .collect() + }); + // Space (or the Hand or Zoom tool) while the button is held cancels the press (a pan needs a + // new press). + if pointer.tools { + st.press = None; + } + let (rev0, t0) = ctx.data(|d| d.get_temp::<(u64, f64)>(pick_start_id())).unwrap_or((app.session.revision, ectx.time.seconds())); + for e in events { + let Some((pos, pressed, keys)) = e else { + st.press = None; + continue; + }; + if pressed { + let ours = map.area.contains(pos) && ctx.layer_id_at(pos) == Some(ui.layer_id()); + st.press = (!pointer.nav && ours).then(|| Press { + pos, + keys, + menu: crate::widgets::any_menu_open(&ctx), + typing: ctx.text_edit_focused(), + // The frame on screen at the press: a frame step earlier in this pass drops it. + q: at(pos).map(|mut q| { + if t0 != ectx.time.seconds() { + q["time"] = json!(t0); + } + q + }), + rev: rev0, + }); + continue; + } + // The release ends the press, whatever egui made of it. + let Some(Press { pos: from, keys: k, menu: false, typing, q: Some(mut q), rev }) = st.press.take() else { continue }; + // Ctrl too on a Mac, where `command` is Cmd. + q["mode"] = json!(if k.alt || k.command || k.ctrl { + "remove" + } else if k.shift { + "add" + } else { + "replace" + }); + let same_frame = q["time"].as_f64() == Some(ectx.time.seconds()); + if pos.distance(from) > SLOP { + app.say(&ctx, tr("Drag cancelled the pick").to_string()); + } else if app.playback.playing && !(same_frame && app.session.crypto_probe_ready(&q).unwrap_or(false)) { + // Playing, a frame without its IDs read is gone before they could be: not queued. + app.say(&ctx, tr(PLAYING).to_string()); + } else if !same_frame || !typing && rev != app.session.revision { + // A frame step or an edit came between the press and the release. + drop_waiting(app, &ctx, st); + } else { + if st.pending.is_empty() { + st.pending_rev = rev; + } + st.after_typing |= typing || ctx.text_edit_focused(); + st.pending.push(q); + } + } +} + +/// Drop stale hover/click state when the pick or pass gap means it no longer applies. +fn reset_stale_state(app: &mut EffectcraftApp, ctx: &egui::Context, st: &mut State, pick: &FxPick) { + let pass = ctx.cumulative_pass_nr(); + // Disarmed (or the viewer hidden) since, or another pick: start afresh, so an old click + // waiting for its probe is never made. (A probe still being built keeps the app's flag.) + if st.seen.is_none_or(|(k, n)| k != (pick.layer, pick.prop) || n + 1 < pass) { + if !st.pending.is_empty() { + app.say(ctx, tr(NOT_MADE).to_string()); + } + *st = State::default(); + } + st.seen = Some(((pick.layer, pick.prop), pass)); +} + +/// Track the shown time for scrub settle; returns whether probe builds may start. +fn track_scrub_settle(app: &EffectcraftApp, ctx: &egui::Context, st: &mut State, time_secs: f64) -> bool { + // No builds while playing, nor until a scrub has settled. + let now = ctx.input(|i| i.time); + if st.shown.is_none_or(|s| s.0 != time_secs) { + st.shown = Some((time_secs, if st.shown.is_some() { now } else { f64::MIN })); + } + let settled = st.shown.is_some_and(|s| now - s.1 >= SETTLE); + if !settled { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(SETTLE)); + } + let start = settled && !app.playback.playing; + st.start = start; + start +} + +/// Read the Cryptomatte ID under the cursor; returns whether a probe is still being read. +fn hover_read( + app: &mut EffectcraftApp, + ctx: &egui::Context, + map: &super::viewer::ViewerMap, + st: &mut State, + resp: &egui::Response, + start: bool, + at: &dyn Fn(egui::Pos2) -> Option, +) -> bool { + let mut reading = false; + match resp.hover_pos() { + Some(hp) => { + ctx.set_cursor_icon(egui::CursorIcon::Crosshair); + let c = map.to_comp(hp); + app.pointer_comp = Some([c[0] as f32, c[1] as f32]); + let q = at(hp); + let asked = q.clone().map(|q| (q, app.session.revision)); + if asked != st.asked { + st.reply = None; + // While clicks wait, they own the build: the hover only reads. + match q.as_ref().map(|q| ready(app, ctx, q, start && st.pending.is_empty())) { + Some(Ok(true)) => { + let r = q.map(|q| app.session.execute("effect.cryptomatteAt", q).map_err(|e| shown_error(&e.to_string()))); + if r.as_ref().is_some_and(|r| r.as_ref().is_ok_and(|v| v["pending"] == true)) { + reading = true; + } else { + st.reply = r.filter(|r| !matches!(r, Ok(Value::Null))); + st.asked = asked; + } + } + Some(Ok(false)) => reading = true, + Some(Err(e)) => (st.reply, st.asked) = (Some(Err(e)), asked), + None => st.asked = None, + } + } + } + None => (st.asked, st.reply) = (None, None), + } + reading +} + +/// Rebuilds the texture when the hovered ID, or the layer, effect, scale, frame or project +/// revision it was read at, changes; then draws it. +fn draw_coverage_highlight(app: &mut EffectcraftApp, ctx: &egui::Context, painter: &egui::Painter, map: &super::viewer::ViewerMap, m: &Mat3, st: &mut State) { + let hovered = st.reply.as_ref().and_then(|r| u32::from_str_radix(r.as_ref().ok()?["hash"].as_str()?, 16).ok()); + let hovered = hovered.zip(st.asked.as_ref()).map(|(h, (q, rev))| (h, json!([q["layer"], q["effect"], q["scale"], q["time"], rev]))); + if let (Some(h), Some(q)) = (&hovered, st.asked.as_ref().map(|a| &a.0)) + && st.mask.as_ref().is_none_or(|mask| mask.key != *h) + { + st.mask = app.session.crypto_coverage(q, h.0, 1024).map(|c| { + let a = app.tokens.accent; + let px = c.coverage.iter().map(|v| Color32::from_rgba_unmultiplied(a.r(), a.g(), a.b(), (v * 140.0) as u8)).collect(); + let img = egui::ColorImage::new([c.width, c.height], px); + let size = [c.size[0], c.size[1]]; + HighlightMask { + key: h.clone(), + texture: ctx.load_texture("crypto-pick-highlight", img, egui::TextureOptions::NEAREST), + size, + uv: [c.uv[0] as f32, c.uv[1] as f32], + } + }); + } + if let (Some(h), Some(mask)) = (&hovered, &st.mask) + && h == &mask.key + { + let (tex, size, uv) = (&mask.texture, mask.size, mask.uv); + let corners = [[0.0, 0.0], [size[0], 0.0], size, [0.0, size[1]]].map(|p| map.to_screen(fw::layer_to_comp(m, p))); + let mut mesh = egui::Mesh::with_texture(tex.id()); + for (pos, t) in corners.iter().zip([[0.0, 0.0], [uv[0], 0.0], uv, [0.0, uv[1]]]) { + mesh.vertices.push(egui::epaint::Vertex { pos: *pos, uv: egui::pos2(t[0], t[1]), color: Color32::WHITE }); + } + mesh.indices = vec![0, 1, 2, 0, 2, 3]; + painter.add(mesh); + // Its corners (screen points), for tests and agents. + let label = corners.iter().map(|p| format!("{:.2},{:.2}", p.x, p.y)).collect::>().join(" "); + app.auto.add("viewer.cryptoPick.highlight", Rect::from_points(&corners), &label); + } +} + +/// Draw the status chip (hovered name, reading, or usage hint). +fn draw_status_chip(app: &mut EffectcraftApp, ctx: &egui::Context, painter: &egui::Painter, map: &super::viewer::ViewerMap, st: &State, reading: bool) { + let err = !reading && matches!(st.reply, Some(Err(_))); + let text = match &st.reply { + _ if reading && app.playback.playing => tr("Stop playback to pick").to_string(), + _ if reading => tr("Reading Cryptomatte IDs…").to_string(), + Some(Ok(r)) => { + let name = r["name"].as_str().map_or_else(|| format!("<{}>", r["hash"].as_str().unwrap_or_default()), str::to_string); + if r["selected"] == true { tr_args("{} (selected)", &[&name]) } else { name } + } + Some(Err(e)) => e.clone(), + None => tr_args("Click: replace · Shift: add · {}: remove", &[&remove_keys(ctx.os())]), + }; + // One line, at most the viewer's width (long names end in an ellipsis); an error in red, on + // as many lines as it needs (at least three, more in a narrow viewer), so its hint shows. + let max = (map.area.width() - 44.0).clamp(40.0, 480.0); + let rows = ((map.area.height() - 28.0) / 14.0).max(3.0) as usize; + let colour = if err { app.tokens.danger } else { app.tokens.text }; + let mut job = egui::text::LayoutJob::simple_singleline(text.clone(), Tokens::ui(11.5), colour); + job.wrap = egui::text::TextWrapping { max_width: max, max_rows: if err { rows } else { 1 }, break_anywhere: !err, overflow_character: Some('…') }; + let galley = painter.layout_job(job); + let chip = Rect::from_min_size(map.area.min + vec2(10.0, 10.0), vec2(galley.size().x + 24.0, (galley.size().y + 8.0).max(22.0))); + app.auto.add("viewer.cryptoPick", chip, &text); + if err { + app.auto.add("viewer.cryptoPick.error", chip, if galley.elided { "cut" } else { "whole" }); + painter.rect(chip, 6.0, Color32::from_rgba_unmultiplied(48, 10, 8, 220), egui::Stroke::new(1.0, colour), egui::StrokeKind::Inside); + } else { + painter.rect_filled(chip, 11.0, Color32::from_black_alpha(180)); + } + painter.galley(chip.left_center() + vec2(12.0, -galley.size().y / 2.0), galley, colour); +} + +/// Esc disarms the eyedropper only when no text field or popup had the keyboard this pass or +/// the pass before (egui lets go of the focus and closes popups before the viewer runs). +fn handle_escape(app: &mut EffectcraftApp, ui: &mut egui::Ui, st: &mut State) { + let ctx = ui.ctx(); + // (Esc in a text field, the Selection being typed, only ends the typing; in a menu, it.) + let esc_taken = std::mem::replace(&mut st.esc_taken, ctx.text_edit_focused() || egui::Popup::is_any_open(ctx)); + if ui.input(|i| i.key_pressed(egui::Key::Escape)) && !esc_taken && !st.esc_taken { + app.ui.fx_pick = None; + app.ui.status.clear(); + st.pending.clear(); + } +} + +/// The armed eyedropper's part of the viewer. `m` maps the layer's effect space to the comp. +#[allow(clippy::too_many_arguments)] +pub fn viewer_hook( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + painter: &egui::Painter, + map: &super::viewer::ViewerMap, + ectx: &EvalCtx, + layer: &Layer, + m: &Mat3, + pick: &FxPick, +) { + let ctx = ui.ctx().clone(); + let sid = pick_id(); + let mut st: State = ctx.data(|d| d.get_temp(sid)).unwrap_or_default(); + reset_stale_state(app, &ctx, &mut st, pick); + // The Cryptomatte effect that owns the Selection (gone: the pick ends). + let Some(fx) = layer.effects().and_then(|f| f.groups().find(|g| g.find(pick.prop).is_some())).map(|g| g.uid) else { + app.ui.fx_pick = None; + return; + }; + let start = track_scrub_settle(app, &ctx, &mut st, ectx.time.seconds()); + // The resolution of the frame on screen: the pick reads the pixel it shows. + let scale = app + .viewer_shown + .as_ref() + .filter(|(_, k)| k.comp == ectx.comp_id.0) + .map_or(1.0, |(_, k)| crate::frames::render_scale(k.scale, ectx.comp.resolution.factor())); + // The centre of the shown frame's pixel under the cursor: the layer may be resampled into + // the frame (a proxy, a scaled layer), and that pixel is what the user sees. + let region = super::viewer_tools::shown_region(&ctx, map.comp); + let at = |pos: egui::Pos2| { + let c = map.to_comp(pos); + let snap = |v: f64, o: f64| o + (((v - o) * scale).floor() + 0.5) / scale; + let p = fw::comp_to_layer(m, [snap(c[0], region[0]), snap(c[1], region[1])])?; + let (x, y, time) = (p[0], p[1], ectx.time.seconds()); + Some( + json!({"comp": ectx.comp_id.0, "layer": layer.id.0, "effect": fx, "x": x, "y": y, "scale": scale.clamp(1e-3, 1.0), "time": time, "probeOnly": true}), + ) + }; + // The Hand and Zoom tools (Space held too) and other buttons keep their own clicks and drags. + let tools = super::space_hand(&ctx) || matches!(app.ui.tool, Tool::Hand | Tool::Zoom); + let nav = tools || ctx.input(|i| i.pointer.middle_down() || i.pointer.secondary_down()); + // Drags too: a click that drifts must not move the layer under it. (egui hands a press to + // what sensed it the pass before, so middle and right presses land here: passed on below.) + let resp = ui.interact(map.area, egui::Id::unique("viewer-fx-pick"), if tools { Sense::hover() } else { Sense::click_and_drag() }); + viewer_pan_and_secondary(app, &ctx, &resp); + app.auto.add("viewer.fxPick", map.area, &pick.name); + let mut reading = hover_read(app, &ctx, map, &mut st, &resp, start, &at); + viewer_click_to_pick(app, ui, map, ectx, &mut st, PointerNav { tools, nav }, &at); + // Clicks waiting for their probe ([`make_waiting`], at the end of the pass). + reading |= !st.pending.is_empty(); + draw_coverage_highlight(app, &ctx, painter, map, m, &mut st); + draw_status_chip(app, &ctx, painter, map, &st, reading); + handle_escape(app, ui, &mut st); + ctx.data_mut(|d| d.insert_temp(sid, st)); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn errors_show_without_the_command_prefix() { + let e = "invalid parameters for `effect.cryptomatteAt`: this layer has no pixels"; + assert_eq!(shown_error(e), "This layer has no pixels"); + assert_eq!(shown_error("Reading the Cryptomatte IDs failed: x"), "Reading the Cryptomatte IDs failed: x"); + } + + /// A build that panics still clears the busy flag, so the next hover can build again. + #[test] + fn a_panicking_build_clears_busy() { + let busy = Arc::new(AtomicBool::new(true)); + let done = Done(busy.clone(), egui::Context::default()); + let r = std::thread::spawn(move || run(Box::new(|| panic!("a broken build")), done)).join(); + assert!(r.is_err() && !busy.load(Ordering::Acquire)); + } +} diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index bb7b2d075..9769b879c 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -63,6 +63,10 @@ fn map_id() -> egui::Id { egui::Id::unique("viewer-map") } +/// The egui data key (an `egui::Pos2`) of a right-click on the viewer that an overlay sensed: +/// the viewer opens its context menu there on the next pass. +pub(crate) const FORWARDED_SECONDARY: &str = "viewer-forwarded-secondary"; + pub fn comp_to_screen(ctx: &egui::Context, p: [f32; 2]) -> Option { let m: ViewerMap = ctx.data(|d| d.get_temp(map_id()))?; Some(m.to_screen([p[0] as f64, p[1] as f64])) From 7cea1625f68fa19b380262740dae170df5eeef44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= Date: Tue, 3 Nov 2026 07:22:46 +0100 Subject: [PATCH 50/71] Cryptomatte eyedropper: arm it from the Selection in Effect Controls (#473) An eyedropper next to the Cryptomatte effect's Selection arms the viewer pick, and the status line says how to use it. It stays armed until Esc or the button (Esc in a text field or a menu only ends that); disarming, New, Open and Revert end it and let go of the session's probe. The app runs the pick's bookkeeping at the start and end of each pass, the viewer hands the pointer to the pick while it is armed and opens its context menu for a right-click the pick forwarded, and a Selection cut off by a narrow Effect Controls panel shows whole on hover. UI tests: ui_crypto_pick (arming, hover, clicks, modifiers, drags, waiting clicks, status, highlight) and ui_crypto_pick_undo (one undo step per pick, the probe kept over undo). --- crates/ui-egui/src/app/frame.rs | 2 + .../src/panels/effect_controls/rows.rs | 27 +- .../src/panels/effect_controls/viewer.rs | 16 +- crates/ui-egui/src/panels/viewer.rs | 10 +- crates/ui-egui/tests/it/main.rs | 2 + crates/ui-egui/tests/it/ui_crypto_pick.rs | 1878 +++++++++++++++++ .../ui-egui/tests/it/ui_crypto_pick_undo.rs | 1328 ++++++++++++ 7 files changed, 3252 insertions(+), 11 deletions(-) create mode 100644 crates/ui-egui/tests/it/ui_crypto_pick.rs create mode 100644 crates/ui-egui/tests/it/ui_crypto_pick_undo.rs diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 0af8f929b..f6d590e8c 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -327,7 +327,9 @@ impl EffectcraftApp { ctx.request_repaint(); return; } + panels::crypto_pick::pass_start(self, &ctx); self.frame(ui); + panels::crypto_pick::upkeep(self, &ctx); #[cfg(not(target_arch = "wasm32"))] pointer_wrap::finish_native(&ctx, frame); // wasm32: no frame threads; render queued frames now, between UI frames. diff --git a/crates/ui-egui/src/panels/effect_controls/rows.rs b/crates/ui-egui/src/panels/effect_controls/rows.rs index 665922fcf..5a5a4e712 100644 --- a/crates/ui-egui/src/panels/effect_controls/rows.rs +++ b/crates/ui-egui/src/panels/effect_controls/rows.rs @@ -1,7 +1,7 @@ use super::common::{Actions, DIAL_ROW, PARAM_NAME_X, ROW, TREE_INDENT, gesture_key}; use super::{ - Align2, Color32, EffectcraftApp, EvalCtx, FxPick, Icon, Layer, Node, ParamUi, PropGroup, Property, Rect, Sense, Stroke, Tokens, Value, fw, icons, json, - pos2, vec2, widgets, + Align2, Color32, EffectcraftApp, EvalCtx, FxPick, GroupKind, Icon, Layer, Node, ParamUi, PropGroup, Property, Rect, Sense, Stroke, Tokens, Value, fw, + icons, json, pos2, vec2, widgets, }; fn triangle(p: &egui::Painter, c: egui::Pos2, left: bool, col: Color32) { @@ -65,6 +65,9 @@ pub(crate) fn toggle_pick(app: &mut EffectcraftApp, kind: &str, layer: &Layer, p app.ui.text_color_pick = None; app.ui.status = if kind == "point" { format!("Click in the Composition panel to set {}", prop.name) + } else if kind == crate::panels::crypto_pick::KIND { + let keys = crate::panels::crypto_pick::remove_keys(egui::os::OperatingSystem::from_target_os()); + crate::i18n::tr_args("Click IDs in the Composition panel: Shift adds, {} removes, Esc ends", &[&keys]) } else { format!("Click in the Composition panel to sample {}", prop.name) }; @@ -485,11 +488,27 @@ fn value_text(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: & let Value::Str(s) = value else { return; }; - let PropCx { t, uid, cy, vx, r, layer, prop, .. } = *cx; - let fr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 12.0).clamp(80.0, 320.0), 18.0)); + let PropCx { t, uid, cy, vx, r, layer, group, prop, .. } = *cx; + // Cryptomatte's Selection also has an eyedropper that picks IDs in the viewer (#473). + let crypto = matches!(&group.kind, GroupKind::Effect { effect } if effect == "ec.3d.cryptomatte") && prop.match_id == "selection"; + let fr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - if crypto { 36.0 } else { 12.0 }).clamp(80.0, 320.0), 18.0)); + if crypto { + let er = Rect::from_center_size(pos2(fr.max.x + 14.0, cy), vec2(18.0, 18.0)); + let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == crate::panels::crypto_pick::KIND); + if fw::eyedropper_button(ui, er, active, egui::Id::unique(("ec-eye", uid)), &t) + .on_hover_text(crate::i18n::tr("Eyedropper: click, then click in the Composition panel")) + .clicked() + { + toggle_pick(app, crate::panels::crypto_pick::KIND, layer, prop); + } + app.auto.add(&format!("effectControls.prop.{uid}.eyedropper"), er, &prop.name); + } let bid = egui::Id::unique(("ec-text", uid)); let mut buf: String = ui.data(|d| d.get_temp(bid)).unwrap_or_else(|| s.to_owned()); let resp = widgets::text_field(ui, fr, &mut buf, "", &t); + // Cut off by a narrow panel (a long Selection): the whole text on hover. + let cut = !resp.has_focus() && ui.painter().layout_no_wrap(buf.clone(), egui::TextStyle::Body.resolve(ui.style()), t.text).size().x > fr.width() - 17.0; + let resp = if cut { resp.on_hover_text(buf.clone()) } else { resp }; // The field has no visible hint: give the screen reader the parameter's name. resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, ui.is_enabled(), &prop.name)); app.auto.add(&format!("effectControls.prop.{uid}.value"), fr, &prop.name); diff --git a/crates/ui-egui/src/panels/effect_controls/viewer.rs b/crates/ui-egui/src/panels/effect_controls/viewer.rs index 6f59d8c96..6d4eb7ffd 100644 --- a/crates/ui-egui/src/panels/effect_controls/viewer.rs +++ b/crates/ui-egui/src/panels/effect_controls/viewer.rs @@ -221,9 +221,19 @@ pub fn viewer_hook( let ctx = ui.ctx().clone(); crate::color_picker::note_viewer(&ctx, map.area); let mut actions: Actions = vec![]; - let point_preview = match fx_pick_session(app, ui, painter, map, ectx, l2c, &mut actions) { - ControlFlow::Break(()) => return, - ControlFlow::Continue(p) => p, + let point_preview = if let Some(pick) = app.ui.fx_pick.clone().filter(|p| p.kind == crate::panels::crypto_pick::KIND) { + match ectx.comp.layer(effectcraft_engine::project::LayerId(pick.layer)) { + Some(l) => { + crate::panels::crypto_pick::viewer_hook(app, ui, painter, map, ectx, l, &crate::panels::viewer::from_effect_space(ectx, l, l2c(ectx, l)), &pick) + } + None => app.ui.fx_pick = None, + } + None + } else { + match fx_pick_session(app, ui, painter, map, ectx, l2c, &mut actions) { + ControlFlow::Break(()) => return, + ControlFlow::Continue(p) => p, + } }; text_color_pick(app, ui, painter, map, ectx, &mut actions); popup_eyedropper(app, ui, painter, map); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 9769b879c..20a7856d5 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -830,9 +830,9 @@ fn phase_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: &m // Interaction. let resp = ui.interact(area, egui::Id::unique("viewer-interact"), Sense::click_and_drag()); let menu_hits_id = egui::Id::unique(("viewer-menu-hits", cid.0)); - if resp.secondary_clicked() - && let Some(pos) = resp.interact_pointer_pos() - { + // A right-click an overlay took (the Cryptomatte eyedropper's) opens the menu too. + let forwarded = ui.data_mut(|d| d.remove_temp::(egui::Id::unique(FORWARDED_SECONDARY))); + if let Some(pos) = resp.interact_pointer_pos().filter(|_| resp.secondary_clicked()).or(forwarded) { // Picking an already selected layer preserves the group; an unselected hit // becomes the target, using the same visible/unlocked hit test as left-click. pick(app, ectx, map.to_comp(pos), false); @@ -844,7 +844,9 @@ fn phase_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: &m app.session.state.selected_layers.iter().filter(|id| comp.layer(**id).is_some_and(|l| !l.switches.locked)).map(|id| id.0).collect(); let mut context_actions = Vec::new(); let copy_frame = crate::i18n::label(app, "comp.copyFrame", "Copy Frame to Clipboard"); - crate::widgets::context_menu(&resp, |ui| { + let menu = egui::Popup::context_menu(&resp).style(crate::widgets::menu_style); + let open = forwarded.map(|_| egui::containers::SetOpenCommand::Bool(true)); + if open.is_some() { menu.open_memory(open) } else { menu }.show(|ui| { let hits = ui.data(|d| d.get_temp::>(menu_hits_id)).unwrap_or_default(); if !hits.is_empty() { ui.menu_button(crate::i18n::tr("Select"), |ui| { diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 2de7ebee7..01fe565c3 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -23,6 +23,8 @@ mod ui_color_hex; mod ui_comp_presets; mod ui_contrast; mod ui_control_modifiers; +mod ui_crypto_pick; +mod ui_crypto_pick_undo; mod ui_dialog_preview; mod ui_dock_resize; mod ui_drag_drop; diff --git a/crates/ui-egui/tests/it/ui_crypto_pick.rs b/crates/ui-egui/tests/it/ui_crypto_pick.rs new file mode 100644 index 000000000..f3a01aea3 --- /dev/null +++ b/crates/ui-egui/tests/it/ui_crypto_pick.rs @@ -0,0 +1,1878 @@ +//! The Cryptomatte eyedropper (#473): the Selection's button arms it, the viewer names (and +//! highlights) the ID under the cursor, and clicks pick into the Selection: plain replaces, Shift +//! adds, Alt or Ctrl removes. It stays armed until Esc or the button. Checked over window and +//! panel sizes, display scales, viewer zoom, pan and resolution, proxies, overlapping popups, and +//! the asynchronous probe (hover and clicks before it is built, edits while it builds), playback +//! and scrubbing, navigation tools, errors and disarming. + +use effectcraft_engine::Session; +use effectcraft_engine::keyframe::Value as KV; +use effectcraft_engine::project::{LayerId, Resolution}; +use effectcraft_engine::raster::channels3d::{crypto_float, crypto_hash}; +use effectcraft_ui_egui::dock::{self, PanelKind, SplitSize}; +use effectcraft_ui_egui::panels::viewer::comp_to_screen; +use effectcraft_ui_egui::state::Tool; +use effectcraft_ui_egui::{Dialog, EffectcraftApp}; +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +const READING: &str = "Reading Cryptomatte IDs…"; +const HINT: &str = "Click: replace · Shift: add · Alt or Ctrl: remove"; +const MAC_HINT: &str = "Click: replace · Shift: add · Option or Cmd: remove"; +const PAUSED: &str = "Stop playback to pick"; + +/// The ID of the 37 × 29 px cell at pixel (x, y) of a full-size frame. +fn cell(x: usize, y: usize) -> String { + format!("c{}_{}", x / 37, y / 29) +} + +/// A `w` × `h` EXR (white RGBA) whose CryptoObject has an ID per cell of a `k`-times larger frame +/// (`k` = 2: a half-size proxy), all named in its manifest. +fn crypto_exr(dir: &std::path::Path, name: &str, w: usize, h: usize, k: usize) -> String { + use exr::prelude::*; + let ids: Vec = (0..w * h).map(|i| crypto_float(crypto_hash(&cell(i % w * k, i / w * k)))).collect(); + let ch = |n: &str, v: Vec| AnyChannel::new(n, FlatSamples::F32(v)); + let one = || vec![1.0f32; w * h]; + let channels = AnyChannels::sort( + vec![ch("R", one()), ch("G", one()), ch("B", one()), ch("A", one()), ch("CryptoObject00.R", ids), ch("CryptoObject00.G", one())].into(), + ); + let mut layer = Layer::new((w, h), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels); + let names: serde_json::Map = (0..(w * k).div_ceil(37)) + .flat_map(|x| (0..(h * k).div_ceil(29)).map(move |y| cell(x * 37, y * 29))) + .map(|n| (n.clone(), json!(format!("{:08x}", crypto_hash(&n))))) + .collect(); + for (key, v) in [("name", "CryptoObject".to_string()), ("manifest", serde_json::Value::Object(names).to_string())] { + layer.attributes.other.insert(Text::from(format!("cryptomatte/f834d0a/{key}").as_str()), AttributeValue::Text(Text::from(v.as_str()))); + } + let path = dir.join(name); + Image::from_layer(layer).write().to_file(&path).unwrap(); + path.to_string_lossy().to_string() +} + +struct Ids { + layer: u64, + sel: u64, + footage: u64, + dir: std::path::PathBuf, +} + +/// A `w` × `h` comp with the EXR (and its half-size proxy file) under a Cryptomatte effect (after +/// a heavy blur with `blur`: its probe then takes a while to build). +fn crypto_session(tag: &str, w: usize, h: usize, blur: bool) -> (Session, Ids) { + let dir = std::env::temp_dir().join(format!("ec-ui-crypick-{tag}-{}", std::process::id())); + std::fs::create_dir_all(dir.join("proxy")).unwrap(); + let path = crypto_exr(&dir, "plate.exr", w, h, 1); + crypto_exr(&dir.join("proxy"), "plate_proxy.exr", w.div_ceil(2), h.div_ceil(2), 2); + let mut s = effectcraft_host::session(); + let r = s.execute("file.import", json!({"paths": [path]})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let footage = r["items"][0].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Crypto", "width": w, "height": h, "frameRate": 24, "duration": 2})).unwrap(); + let layer = s.execute("layer.addItem", json!({"item": footage})).unwrap()["layer"].as_u64().unwrap(); + s.state.selected_layers = vec![LayerId(layer)]; + if blur { + s.execute_checked("effect.apply", json!({"effect": "Gaussian Blur"})).unwrap(); + s.execute_checked("prop.set", json!({"path": "effects/#1/blurriness", "value": 40.0})).unwrap(); + } + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + let fx = s.active_comp().unwrap().layers[0].effects().unwrap().groups().last().unwrap(); + let sel = fx.get("selection").unwrap().uid; + (s, Ids { layer, sel, footage, dir }) +} + +/// The app with the Composition viewer and Effect Controls (`ec` points wide) side by side. +fn harness(s: Session, size: egui::Vec2, ppp: f32, ec: f32) -> Harness<'static, EffectcraftApp> { + harness_dt(s, size, ppp, ec, 0.25) +} + +/// [`harness`] whose steps advance the clock by `dt` seconds. +fn harness_dt(s: Session, size: egui::Vec2, ppp: f32, ec: f32, dt: f32) -> Harness<'static, EffectcraftApp> { + let app = app(s, ec); + let mut h = Harness::builder().with_size(size).with_pixels_per_point(ppp).with_step_dt(dt).build_eframe(|_| app); + settle(&mut h); + h +} + +fn app(s: Session, ec: f32) -> EffectcraftApp { + let mut app = EffectcraftApp::new(s); + app.ui.viewer.show_layer_controls = false; + app.ui.dock = dock::docked(dock::vsplit( + SplitSize::FixedSecond(160.0), + dock::hsplit(SplitSize::FixedSecond(ec), dock::tabs(&[PanelKind::Composition], 0), dock::tabs(&[PanelKind::EffectControls], 0)), + dock::tabs(&[PanelKind::Timeline], 0), + )); + app +} + +fn settle(h: &mut Harness<'_, EffectcraftApp>) { + for _ in 0..1500 { + h.step(); + if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().is_ok_and(|v| *v > 0.0) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + h.run_steps(4); +} + +fn chip(h: &Harness<'_, EffectcraftApp>) -> Option { + h.state().auto.find("viewer.cryptoPick").map(|e| e.label.clone()) +} + +/// Step until the chip stops saying the probe is being built (the job runs on its own thread). +fn wait_probe(h: &mut Harness<'_, EffectcraftApp>) { + for _ in 0..3000 { + h.step(); + if chip(h).as_deref() != Some(READING) { + return; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + panic!("the probe never finished"); +} + +fn hover(h: &mut Harness<'_, EffectcraftApp>, pos: Pos2) -> String { + h.event(Event::PointerMoved(pos)); + h.step(); + wait_probe(h); + chip(h).unwrap_or_default() +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, pos: Pos2, modifiers: Modifiers) { + h.event(Event::PointerMoved(pos)); + h.event(Event::ModifiersChanged(modifiers)); + h.step(); + for pressed in [true, false] { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }); + h.step(); + } + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.step(); + wait_probe(h); + h.run_steps(2); +} + +fn at(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")).clone(); + pos2(e.rect[0] + e.rect[2] * 0.5, e.rect[1] + e.rect[3] * 0.5) +} + +fn selection(h: &Harness<'_, EffectcraftApp>, ids: &Ids) -> String { + let s = &h.state().session; + match &s.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().props.find(ids.sel).unwrap().value { + KV::Str(t) => t.clone(), + v => panic!("{v:?}"), + } +} + +fn arm(h: &mut Harness<'_, EffectcraftApp>, ids: &Ids) { + let eye = at(h, &format!("effectControls.prop.{}.eyedropper", ids.sel)); + click(h, eye, Modifiers::NONE); + assert!(h.state().ui.fx_pick.as_ref().is_some_and(|p| p.kind == "crypto" && p.prop == ids.sel), "armed"); +} + +/// The resolution of the frame the viewer shows: every n-th pixel (its key keeps thousandths). +fn shown_scale(h: &Harness<'_, EffectcraftApp>) -> f64 { + let s = &h.state().session; + 1.0 / (1000.0 / h.state().shown_series(s.active_comp_id().unwrap()).scale as f64).round() +} + +/// The comp point the viewer maps screen point `p` to (its own arithmetic). +fn to_comp(h: &Harness<'_, EffectcraftApp>, p: Pos2) -> [f64; 2] { + let (o, k) = (comp_to_screen(&h.ctx, [0.0, 0.0]).unwrap(), comp_to_screen(&h.ctx, [1000.0, 1000.0]).unwrap()); + let z = (k - o) / 1000.0; + [((p.x - o.x) / z.x) as f64, ((p.y - o.y) / z.y) as f64] +} + +/// The rendered matte of ID `name` (as the viewer renders the frame at `scale`) at comp point `p`. +/// A downsampled proxy blends cells at their edges, so an edge pixel can be a mix of IDs. +fn lit(h: &mut Harness<'_, EffectcraftApp>, name: &str, p: [f64; 2], scale: f64) -> f32 { + let s = &mut h.state_mut().session; + s.execute_checked("prop.set", json!({"path": "effects/#1/selection", "value": name})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let opts = effectcraft_engine::render::RenderOpts { roi: None, guides: false, ..h.state().frame_opts(cid, scale) }; + let img = h.state().session.render(cid, effectcraft_engine::time::Tick::ZERO, opts); + img.get((p[0] * scale).floor() as i64, (p[1] * scale).floor() as i64)[0] +} + +/// Whether ID `got` (a cell name) has the largest coverage of the shown pixel at comp point `p` +/// (a downsampled or scaled layer blends the cells around it). +fn largest(h: &mut Harness<'_, EffectcraftApp>, got: &str, p: [f64; 2], scale: f64) -> bool { + let v = lit(h, got, p, scale); + if v >= 0.5 { + return true; + } + let (cx, cy) = ((p[0] / 37.0).floor() as i64, (p[1] / 29.0).floor() as i64); + let others: Vec = + (-2..=2).flat_map(|dx| (-2..=2).map(move |dy| (cx + dx, cy + dy))).filter(|c| c.0 >= 0 && c.1 >= 0).map(|c| format!("c{}_{}", c.0, c.1)).collect(); + v > 0.0 && others.iter().filter(|o| *o != got).all(|o| lit(h, o, p, scale) <= v + 1e-4) +} + +#[test] +fn eyedropper_arms_hovers_picks_with_modifiers_and_stays_armed_until_esc() { + let (s, ids) = crypto_session("flow", 320, 180, false); + let mut h = harness(s, vec2(1400.0, 800.0), 1.0, 420.0); + let steps = h.state().session.history.undo.len(); + arm(&mut h, &ids); + assert!(h.state().ui.status.contains("Shift adds"), "{}", h.state().ui.status); + let hint = comp_to_screen(&h.ctx, [-3.0, 90.0]).unwrap(); + assert_eq!(hover(&mut h, hint), HINT, "off the layer: the hint"); + let a = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + let b = comp_to_screen(&h.ctx, [120.0, 100.0]).unwrap(); + assert_eq!(hover(&mut h, a), "c1_1"); + assert_eq!(hover(&mut h, b), "c3_3"); + click(&mut h, a, Modifiers::NONE); + assert_eq!(selection(&h, &ids), "c1_1"); + assert!(h.state().ui.status.contains("c1_1"), "{}", h.state().ui.status); + assert_eq!(hover(&mut h, a), "c1_1 (selected)"); + click(&mut h, b, Modifiers::SHIFT); + assert_eq!((selection(&h, &ids), pick_steps(&h)), ("c1_1, c3_3".to_string(), 2)); + click(&mut h, a, Modifiers::ALT); + assert_eq!(selection(&h, &ids), "c3_3"); + click(&mut h, b, Modifiers::COMMAND); + assert_eq!(selection(&h, &ids), ""); + // The modifiers of the press count: Shift let go before the button still adds. + click(&mut h, a, Modifiers::NONE); + h.event(Event::PointerMoved(b)); + h.event(Event::ModifiersChanged(Modifiers::SHIFT)); + h.step(); + h.event(Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::SHIFT }); + h.step(); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.step(); + h.event(Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + assert_eq!(selection(&h, &ids), "c1_1, c3_3", "Shift held at the press adds"); + // On a Mac the hint says Option or Cmd, and Ctrl (no Cmd there) removes too. + h.ctx.set_os(egui::os::OperatingSystem::Mac); + assert_eq!(hover(&mut h, hint), MAC_HINT); + click(&mut h, a, Modifiers::CTRL); + assert_eq!(selection(&h, &ids), "c3_3", "Ctrl on a Mac"); + h.ctx.set_os(egui::os::OperatingSystem::Nix); + click(&mut h, b, Modifiers::ALT); + assert_eq!(selection(&h, &ids), ""); + assert_eq!(h.state().session.history.undo.len(), steps + 8, "one undo step per pick"); + // The pointer leaves the viewer: the hint again. + let away = at(&h, &format!("effectControls.prop.{}.eyedropper", ids.sel)) + vec2(0.0, 60.0); + h.event(Event::PointerMoved(away)); + h.run_steps(2); + assert_eq!(chip(&h).as_deref(), Some(HINT), "hover ends with the pointer away"); + // Outside the layer: nothing to pick, no step; still armed. + let off = comp_to_screen(&h.ctx, [-30.0, 40.0]).unwrap(); + if h.state().auto.find("viewer.fxPick").is_some_and(|e| off.x > e.rect[0]) { + click(&mut h, off, Modifiers::NONE); + assert_eq!(h.state().session.history.undo.len(), steps + 8); + } + assert!(h.state().ui.fx_pick.is_some(), "stays armed"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().ui.fx_pick.is_none(), "Esc ends it"); + assert!(chip(&h).is_none()); + // The button toggles it too. + arm(&mut h, &ids); + let eye = at(&h, &format!("effectControls.prop.{}.eyedropper", ids.sel)); + click(&mut h, eye, Modifiers::NONE); + assert!(h.state().ui.fx_pick.is_none()); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// Window and panel sizes × display scales × viewer zoom / pan × Resolution (and a proxy): hover +/// and click name the ID of the pixel the viewer shows, next to cell edges too. Each hover is +/// checked against the rendered matte of the named ID at the point the screen maps back to. +#[test] +fn picks_what_the_viewer_shows_across_sizes_scales_zoom_pan_resolution_and_proxies() { + let mut checked = 0; + let mut failures = vec![]; + let cases = [(vec2(900.0, 640.0), 1.0, 300.0), (vec2(1280.0, 720.0), 1.25, 420.0), (vec2(1600.0, 1000.0), 1.5, 360.0), (vec2(2400.0, 1400.0), 2.0, 600.0)]; + for (i, (size, ppp, ec)) in cases.into_iter().enumerate() { + let (s, ids) = crypto_session(&format!("matrix{i}"), 1919, 1079, false); + let mut h = harness(s, size, ppp, ec); + h.state_mut().session.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": true})).unwrap(); + arm(&mut h, &ids); + for proxy in [false, true] { + if proxy { + let p = ids.dir.join("proxy").join("plate_proxy.exr"); + h.state_mut().session.execute("file.setProxy", json!({"item": ids.footage, "path": p.to_string_lossy()})).unwrap(); + } + for res in [Resolution::Full, Resolution::Half, Resolution::Third, Resolution::Quarter, Resolution::Custom(6)] { + for (zoom, pan) in [(None, [0.0, 0.0]), (Some(0.5), [0.0, 0.0]), (Some(2.0), [-300.0, 120.0])] { + let cid = h.state().session.active_comp_id().unwrap(); + h.state_mut().session.set_resolution(cid, res).unwrap(); + h.state_mut().ui.viewer.zoom = zoom.map(|z: f32| z / ppp); + h.state_mut().ui.viewer.pan = pan; + settle(&mut h); + let area = h.state().auto.find("viewer.fxPick").unwrap().rect; + let scale = shown_scale(&h); + // Points next to cell edges, in the middle and at the far corner. + // (Cell edges far from the origin: 925, 1044 shows the 1/3 vs 0.333 rounding.) + for p in [[36.6, 28.7], [37.4, 29.3], [959.7, 539.2], [1918.4, 1078.6], [74.1, 57.9], [925.4, 1044.4], [1110.6, 1043.6]] { + let Some(sp) = comp_to_screen(&h.ctx, [p[0] as f32, p[1] as f32]) else { continue }; + if sp.x < area[0] + 4.0 || sp.y < area[1] + 40.0 || sp.x > area[0] + area[2] - 4.0 || sp.y > area[1] + area[3] - 4.0 { + continue; + } + let got = hover(&mut h, sp); + let got = got.trim_end_matches(" (selected)").to_string(); + // The comp point under the cursor, as the viewer maps it back. + let q = to_comp(&h, sp); + checked += 1; + // The named ID has the largest coverage of the shown pixel (a blended edge + // pixel of a proxy at Quarter has several). No ID named: the frame shows + // (almost) no matte there either (an odd size's last half-resolution pixel + // samples just outside the layer). + let ok = if got.starts_with('c') { largest(&mut h, &got, q, scale) } else { got == HINT && lit(&mut h, "*", q, scale) < 0.5 }; + if !ok { + failures.push(format!( + "{size:?} ppp {ppp} proxy {proxy} {res:?} zoom {zoom:?} (scale {scale}): at {q:?} hover {got:?} is not lit there" + )); + } + } + } + } + // Frames from Cache Frames When Idle (the first case): rendered on the grid the + // viewer renders and the pick reads (1/3, not the key's 0.333), so frame 20 from the + // idle cache is frame 0 as the viewer rendered it, and hovers there are right too. + if i == 0 && !proxy { + (h.state_mut().ui.viewer.zoom, h.state_mut().ui.viewer.pan) = (None, [0.0, 0.0]); + for res in [Resolution::Third, Resolution::Custom(6)] { + let fails = idle_cached_frames_are_shown_frames(&mut h, res); + checked += 1; + failures.extend(fails); + } + } + // A click picks what the hover named. + (h.state_mut().ui.viewer.zoom, h.state_mut().ui.viewer.pan) = (None, [0.0, 0.0]); + settle(&mut h); + let sp = comp_to_screen(&h.ctx, [959.7, 539.2]).unwrap(); + let named = hover(&mut h, sp); + click(&mut h, sp, Modifiers::NONE); + assert_eq!(selection(&h, &ids), named.trim_end_matches(" (selected)")); + if proxy { + h.state_mut().session.execute("file.setProxy", json!({"item": ids.footage, "path": null})).ok(); + } + } + let _ = std::fs::remove_dir_all(&ids.dir); + } + assert!(failures.is_empty(), "{} of {checked} hovers wrong:\n{}", failures.len(), failures.join("\n")); + assert!(checked > 100, "{checked}"); +} + +fn set_frame(h: &mut Harness<'_, EffectcraftApp>, f: i64) { + h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(f as f64 / 24.0)); +} + +/// The viewer's CPU frame `f` of the series shown. +fn cpu_frame(h: &Harness<'_, EffectcraftApp>, f: i64) -> Option<([usize; 2], Vec)> { + let key = h.state().shown_series(h.state().session.active_comp_id().unwrap()); + match h.state().frames.get_for_viewer(&effectcraft_ui_egui::frames::FrameKey { frame: f, ..key }, None) { + Some((_, effectcraft_ui_egui::frames::FrameImage::Cpu(c))) => Some((c.size, c.pixels.clone())), + _ => None, + } +} + +/// At `res`, with the Colors display: frame 20 cached when idle against frame 0 rendered by the +/// viewer (the footage is a still), then hovers on frame 20. Returns what is wrong. +fn idle_cached_frames_are_shown_frames(h: &mut Harness<'_, EffectcraftApp>, res: Resolution) -> Vec { + let cid = h.state().session.active_comp_id().unwrap(); + let display = h.state().session.active_comp().unwrap().layers[0].effects().unwrap().groups().last().unwrap().get("display").unwrap().value.to_json(); + h.state_mut().session.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": false})).unwrap(); + h.state_mut().session.execute_checked("prop.set", json!({"path": "effects/#1/display", "value": 0})).unwrap(); + h.state_mut().session.set_resolution(cid, res).unwrap(); + set_frame(h, 0); + settle(h); + let shown = cpu_frame(h, 0).expect("frame 0 shown"); + h.state_mut().ui.cache_when_idle = true; + for _ in 0..2000 { + h.step(); + let key = h.state().shown_series(cid); + if h.state().frames.is_cached(&effectcraft_ui_egui::frames::FrameKey { frame: 20, ..key }) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h.state_mut().ui.cache_when_idle = false; + let mut out = vec![]; + match cpu_frame(h, 20) { + None => out.push(format!("{res:?}: frame 20 was never cached when idle")), + Some(c) if c.0 != shown.0 => out.push(format!("{res:?}: the idle cache rendered {:?}, the viewer {:?}", c.0, shown.0)), + Some(c) if c.1 != shown.1 => out.push(format!("{res:?}: the idle-cached frame differs from the viewer's")), + Some(_) => {} + } + h.state_mut().session.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": true})).unwrap(); + h.state_mut().session.execute_checked("prop.set", json!({"path": "effects/#1/display", "value": display})).unwrap(); + set_frame(h, 20); + settle(h); + let scale = shown_scale(h); + for p in [[959.7, 539.2], [925.4, 1044.4], [1110.6, 1043.6]] { + let sp = comp_to_screen(&h.ctx, [p[0] as f32, p[1] as f32]).unwrap(); + let got = hover(h, sp).trim_end_matches(" (selected)").to_string(); + let q = to_comp(h, sp); + if !(got.starts_with('c') && largest(h, &got, q, scale)) { + out.push(format!("{res:?} idle-cached frame 20: at {q:?} hover {got:?} is not lit there")); + } + } + set_frame(h, 0); + settle(h); + out +} + +/// Press and release at `pos` without waiting for the probe. +fn press(h: &mut Harness<'_, EffectcraftApp>, pos: Pos2) { + h.event(Event::PointerMoved(pos)); + h.run_steps(2); + for pressed in [true, false] { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } +} + +/// An edit that drops the probe: the blur before the Cryptomatte effect changes, so the next +/// hover or click has to render it again to build the probe. +/// (Each blurriness `v` once: an earlier one would come from the layer cache.) +fn edit(h: &mut Harness<'_, EffectcraftApp>, v: f64) { + h.state_mut().session.execute_checked("prop.set", json!({"path": "effects/#1/blurriness", "value": v})).unwrap(); +} + +/// Let a probe still being built finish with nothing waiting on it. +fn drain(h: &mut Harness<'_, EffectcraftApp>) { + for _ in 0..100 { + h.step(); + std::thread::sleep(std::time::Duration::from_millis(20)); + } +} + +/// Waiting clicks: the first hover starts the probe; a click made meanwhile waits then picks; +/// an edit, Esc, disarming, or hiding the viewer while it waits drops it. +fn waiting_clicks_are_dropped_by_edits_esc_disarm_and_hiding_the_viewer(h: &mut Harness<'_, EffectcraftApp>, ids: &Ids, a: Pos2, b: Pos2) { + // A click before the probe is built waits for it. + // (egui reports the hover a pass after the pointer moves.) + h.event(Event::PointerMoved(a)); + h.run_steps(2); + assert_eq!(chip(h).as_deref(), Some(READING), "the first hover starts the probe off the UI thread"); + press(h, a); + assert_eq!(selection(h, ids), "", "the click waits"); + wait_probe(h); + h.run_steps(2); + assert_eq!(selection(h, ids), "c27_20", "then picks"); + assert_eq!(hover(h, b), "c50_40"); + let steps = h.state().session.history.undo.len(); + // An edit while a click waits drops the click. + edit(h, 41.0); + press(h, b); + assert_eq!(chip(h).as_deref(), Some(READING)); + edit(h, 42.0); + wait_probe(h); + h.run_steps(2); + assert_eq!(selection(h, ids), "c27_20", "not picked after the edit"); + assert_eq!(h.state().session.history.undo.len(), steps + 2, "only the two edits"); + // Esc while a click waits: no pick, disarmed. + edit(h, 43.0); + press(h, b); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().ui.fx_pick.is_none() && chip(h).is_none()); + drain(h); + assert_eq!(selection(h, ids), "c27_20"); + // Disarmed while a click waits, then armed again: the old click is not made. + edit(h, 44.0); + arm(h, ids); + press(h, b); + assert_eq!(chip(h).as_deref(), Some(READING)); + h.state_mut().ui.fx_pick = None; + h.run_steps(2); + arm(h, ids); + drain(h); + assert_eq!(selection(h, ids), "c27_20", "the old click stays unmade"); + assert_eq!(hover(h, b), "c50_40", "still picks"); + // The viewer hidden while a click waits (Effect Controls maximised), then shown again: the + // pick still on, the old click is not made either. + edit(h, 45.0); + press(h, b); + assert_eq!(chip(h).as_deref(), Some(READING)); + h.state_mut().ui.maximized = Some(PanelKind::EffectControls); + h.run_steps(2); + assert!(h.state().auto.find("viewer.fxPick").is_none(), "the viewer is hidden"); + drain(h); + h.state_mut().ui.maximized = None; + h.run_steps(2); + drain(h); + assert!(h.state().ui.fx_pick.is_some()); + assert_eq!(selection(h, ids), "c27_20", "the click made before the viewer was hidden stays unmade"); +} + +/// Popups and dialogs over the viewer take the pointer. Another selected layer keeps the pick; +/// deleting the layer or switching comps ends it. +fn popups_dialogs_and_comp_switches_end_or_block_them(h: &mut Harness<'_, EffectcraftApp>, ids: &Ids, b: Pos2) { + // A popup over the viewer takes the pointer: no hover, its click is its own. + let area = h.state().auto.find("viewer.fxPick").unwrap().rect; + let inside = |p: Pos2| p.x > area[0] && p.y > area[1] && p.x < area[0] + area[2] && p.y < area[1] + area[3]; + let res = at(h, "viewer.resolution"); + click(h, res, Modifiers::NONE); + let item = at(h, "viewer.resolutionItem.0"); + assert!(inside(item), "the Resolution popup is over the viewer: {item:?} {area:?}"); + assert_eq!(hover(h, item), HINT, "nothing hovered under the popup"); + click(h, item, Modifiers::NONE); + settle(h); + assert_eq!(h.state().viewer_res(), effectcraft_ui_egui::state::Resolution::Full); + assert_eq!(selection(h, ids), "c27_20", "the popup's click picked nothing"); + assert!(h.state().ui.fx_pick.is_some(), "still armed"); + assert_eq!(hover(h, b), "c50_40"); + // A click on the viewer while the popup is open only closes the popup. + click(h, res, Modifiers::NONE); + assert!(h.state().auto.find("viewer.resolutionItem.0").is_some(), "open again"); + click(h, b, Modifiers::SHIFT); + assert!(h.state().auto.find("viewer.resolutionItem.0").is_none(), "closed"); + assert_eq!(selection(h, ids), "c27_20", "the closing click picked nothing"); + // So does a dialog. + h.state_mut().dialog = Some(Dialog::CompSettings); + h.run_steps(3); + let d = at(h, "dialog.comp.name"); + assert!(inside(d), "the dialog is over the viewer: {d:?} {area:?}"); + assert_eq!(hover(h, d), HINT, "nothing hovered under the dialog"); + h.state_mut().dialog = None; + h.run_steps(2); + // Another layer selected: the pick stays with its layer. + let solid = h.state_mut().session.execute("layer.newSolid", json!({"name": "Other", "width": 64, "height": 64})).unwrap()["layer"].as_u64().unwrap(); + h.state_mut().session.state.selected_layers = vec![LayerId(solid)]; + settle(h); + assert!(h.state().ui.fx_pick.as_ref().is_some_and(|p| p.layer == ids.layer)); + click(h, b, Modifiers::SHIFT); + assert_eq!(selection(h, ids), "c27_20, c50_40"); + // The layer deleted: the pick ends; undone, it is not armed again. + h.state_mut().session.state.selected_layers = vec![LayerId(ids.layer)]; + h.state_mut().session.execute_checked("edit.clear", json!({"layers": [ids.layer]})).unwrap(); + h.run_steps(3); + assert!(h.state().ui.fx_pick.is_none() && chip(h).is_none(), "ended with its layer"); + h.state_mut().session.execute_checked("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert!(h.state().ui.fx_pick.is_none()); + // Another comp: the pick ends. + h.state_mut().session.state.selected_layers = vec![LayerId(ids.layer)]; + h.run_steps(3); + arm(h, ids); + h.state_mut().session.execute("comp.new", json!({"name": "Other", "width": 320, "height": 180, "duration": 2})).unwrap(); + h.run_steps(3); + assert!(h.state().ui.fx_pick.is_none() && chip(h).is_none(), "ended with the comp switch"); +} + +/// The asynchronous probe (a 4K plate, so it takes a while): the first hover says it is reading, +/// a click made meanwhile waits and then picks; an edit, Esc or disarming while it waits drops +/// it. Popups and dialogs over the viewer take the pointer. Another selected layer keeps the +/// pick; deleting the layer or switching comps ends it. +#[test] +fn clicks_wait_for_the_probe_and_edits_popups_dialogs_and_comp_switches_end_or_block_them() { + let (s, ids) = crypto_session("async", 3840, 2160, true); + let mut h = harness(s, vec2(1280.0, 800.0), 1.0, 420.0); + arm(&mut h, &ids); + let (a, b) = (comp_to_screen(&h.ctx, [1017.5, 594.5]).unwrap(), comp_to_screen(&h.ctx, [1868.5, 1174.5]).unwrap()); + waiting_clicks_are_dropped_by_edits_esc_disarm_and_hiding_the_viewer(&mut h, &ids, a, b); + popups_dialogs_and_comp_switches_end_or_block_them(&mut h, &ids, b); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// On the GPU compositor (egui_kittest with wgpu on the headless compositor's adapter, as in +/// `ui_gpu.rs`; lavapipe on CI-like boxes): hover and click name what the GPU viewer shows, at +/// each Resolution. Skips without a GPU adapter. +#[test] +fn picks_on_the_gpu_viewer() { + let Some(probe) = effectcraft_gpu::Gpu::headless() else { + eprintln!("no GPU adapter: skipping"); + return; + }; + let adapter = effectcraft_engine::render::Accelerator::name(&probe); + drop(probe); + let mut setup = egui_kittest::wgpu::default_wgpu_setup(); + if let eframe::egui_wgpu::WgpuSetup::CreateNew(c) = &mut setup { + let name = adapter.clone(); + c.native_adapter_selector = Some(std::sync::Arc::new(move |adapters, _| { + adapters + .iter() + .find(|a| format!("{} ({:?})", a.get_info().name, a.get_info().backend) == name) + .or(adapters.first()) + .cloned() + .ok_or_else(|| "no adapter".to_owned()) + })); + } + let (s, ids) = crypto_session("gpu", 1919, 1079, false); + let app = app(s, 420.0); + let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).with_pixels_per_point(1.0).wgpu_setup(setup).build_eframe(|_| app); + settle(&mut h); + if h.state().gpu_adapter().is_none() { + eprintln!("the viewer stayed on the CPU ({adapter}): skipping"); + return; + } + eprintln!("GPU viewer on {:?}", h.state().gpu_adapter()); + h.state_mut().session.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": true})).unwrap(); + arm(&mut h, &ids); + let mut checked = 0; + for res in [Resolution::Full, Resolution::Half, Resolution::Third, Resolution::Quarter] { + let cid = h.state().session.active_comp_id().unwrap(); + h.state_mut().session.set_resolution(cid, res).unwrap(); + settle(&mut h); + let scale = shown_scale(&h); + for p in [[36.6, 28.7], [37.4, 29.3], [959.7, 539.2], [925.4, 1044.4]] { + let sp = comp_to_screen(&h.ctx, [p[0] as f32, p[1] as f32]).unwrap(); + let got = hover(&mut h, sp).trim_end_matches(" (selected)").to_string(); + let q = to_comp(&h, sp); + assert!(got.starts_with('c') && lit(&mut h, &got, q, scale) >= 0.5, "{res:?}: at {q:?} hover {got:?} is not lit there"); + checked += 1; + } + } + let sp = comp_to_screen(&h.ctx, [959.7, 539.2]).unwrap(); + let named = hover(&mut h, sp); + // The highlight is drawn: a pixel of the hovered ID (just up-left of the pointer) is tinted + // while hovered, not with the pointer off the layer (the frame itself unchanged). + settle(&mut h); + let px = |h: &mut Harness<'_, EffectcraftApp>| *h.render().expect("render").get_pixel(sp.x as u32 - 2, sp.y as u32 - 2); + let lit_px = px(&mut h); + let off = comp_to_screen(&h.ctx, [-3.0, 540.0]).unwrap_or(pos2(2.0, 2.0)); + hover(&mut h, off); + let plain = px(&mut h); + assert_eq!(hover(&mut h, sp), named); + assert_eq!(px(&mut h), lit_px, "the same frame"); + assert_ne!(lit_px, plain, "the highlight tints the hovered ID"); + click(&mut h, sp, Modifiers::NONE); + assert_eq!(selection(&h, &ids), named.trim_end_matches(" (selected)")); + assert_eq!(checked, 16); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// How many probes the session built so far, and whether one is kept. +/// Waits until the build thread started after `jobs` jobs has counted itself (it may not have +/// run yet when the pass that spawned it returns). +fn started(h: &Harness<'_, EffectcraftApp>, jobs: u64) { + for _ in 0..2500 { + if h.state().session.crypto_probe_jobs() > jobs { + return; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + panic!("no build started"); +} + +fn builds(h: &Harness<'_, EffectcraftApp>) -> u64 { + h.state().session.crypto_probe_stats().1 +} + +/// Clicks made while the IDs are being read are all made, in order, once they are. +#[test] +fn every_click_made_while_reading_is_made_in_order() { + let (s, ids) = crypto_session("multi", 3840, 2160, true); + let mut h = harness(s, vec2(1280.0, 800.0), 1.0, 420.0); + arm(&mut h, &ids); + let (a, b) = (comp_to_screen(&h.ctx, [1017.5, 594.5]).unwrap(), comp_to_screen(&h.ctx, [1868.5, 1174.5]).unwrap()); + h.event(Event::PointerMoved(a)); + h.run_steps(2); + assert_eq!(chip(&h).as_deref(), Some(READING)); + let steps = h.state().session.history.undo.len(); + for (p, m) in [(a, Modifiers::NONE), (b, Modifiers::SHIFT), (a, Modifiers::ALT), (a, Modifiers::SHIFT)] { + h.event(Event::PointerMoved(p)); + h.event(Event::ModifiersChanged(m)); + h.step(); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: m }); + h.step(); + } + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.step(); + } + assert_eq!(chip(&h).as_deref(), Some(READING), "all four made while reading"); + assert_eq!(selection(&h, &ids), ""); + wait_probe(&mut h); + h.run_steps(3); + assert_eq!(selection(&h, &ids), "c50_40, c27_20", "replace, add, remove, add, in order"); + assert_eq!(h.state().session.history.undo.len(), steps + 4); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// A click that drifts past egui's drag threshold still picks at its press, and the Selection +/// tool under the pick never moves the layer. +#[test] +fn a_drifting_click_picks_and_never_moves_the_layer() { + let (s, ids) = crypto_session("drag", 320, 180, false); + let mut h = harness(s, vec2(1400.0, 800.0), 1.0, 420.0); + arm(&mut h, &ids); + let a = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + assert_eq!(hover(&mut h, a), "c1_1"); + let position = |h: &Harness<'_, EffectcraftApp>| { + format!( + "{:?}", + h.state().session.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().transform().and_then(|t| t.get("position")).map(|p| p.value.clone()) + ) + }; + let (steps, before) = (h.state().session.history.undo.len(), position(&h)); + let drag = |h: &mut Harness<'_, EffectcraftApp>, to: egui::Vec2| { + h.event(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=4 { + h.event(Event::PointerMoved(a + to * k as f32 / 4.0)); + h.step(); + } + h.event(Event::PointerButton { pos: a + to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + }; + drag(&mut h, vec2(12.0, 0.0)); + assert_eq!(selection(&h, &ids), "c1_1", "picked at the press"); + assert_eq!(h.state().session.history.undo.len(), steps + 1, "only the pick"); + // A real drag picks nothing and moves nothing either. + drag(&mut h, vec2(120.0, 40.0)); + assert_eq!((h.state().session.history.undo.len(), position(&h)), (steps + 1, before), "the layer stays put"); + assert_eq!(h.state().ui.status, "Drag cancelled the pick", "not silently"); + assert!(h.state().ui.fx_pick.is_some()); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// Frame steps on a still (behind a blur that isn't animated) reuse the probe: no re-read. +#[test] +fn frame_steps_on_a_still_reuse_the_probe() { + let (s, ids) = crypto_session("time", 3840, 2160, true); + let mut h = harness(s, vec2(1280.0, 800.0), 1.0, 420.0); + arm(&mut h, &ids); + let a = comp_to_screen(&h.ctx, [1017.5, 594.5]).unwrap(); + assert_eq!(hover(&mut h, a), "c27_20"); + let n = builds(&h); + for f in 1..=4 { + h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(f as f64 / 24.0)); + h.run_steps(2); + assert_eq!(chip(&h).as_deref(), Some("c27_20"), "frame {f}"); + } + assert_eq!(builds(&h), n, "no probe built for the frame steps"); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// With an animated blur before the Cryptomatte effect each frame needs its own probe: none is +/// built while playing, and a scrub builds one once it settles, not one per frame. +#[test] +fn no_probe_builds_while_playing_and_one_per_settled_scrub() { + let (mut s, ids) = crypto_session("play", 960, 540, true); + for (t, v) in [(0.0, 2.0), (1.0, 6.0)] { + s.execute_checked("prop.addKey", json!({"path": "effects/#1/blurriness", "time": t, "value": v})).unwrap(); + } + let mut h = harness_dt(s, vec2(1280.0, 800.0), 1.0, 420.0, 0.02); + arm(&mut h, &ids); + let a = comp_to_screen(&h.ctx, [260.0, 150.0]).unwrap(); + assert_eq!(hover(&mut h, a), "c7_5"); + let n = builds(&h); + let set = |h: &mut Harness<'_, EffectcraftApp>, f: i64| h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(f as f64 / 24.0)); + // Scrubbing: a frame per 20 ms step; nothing built until it stays put for a while. + // (The viewer names what it shows: a new frame takes a step or two to arrive.) + let mut read = false; + for f in 1..=6 { + set(&mut h, f); + h.run_steps(2); + read |= chip(&h).as_deref() == Some(READING); + } + assert!(read, "the scrub reached frames without a probe"); + assert_eq!(builds(&h), n, "no build while scrubbing"); + wait_probe(&mut h); + assert_eq!(chip(&h).as_deref(), Some("c7_5")); + assert_eq!(builds(&h), n + 1, "one build for the settled frame"); + // Playing: no builds, the chip says why; stopped, it reads. + h.state_mut().playback.playing = true; + set(&mut h, 9); + for _ in 0..20 { + h.step(); + } + assert_eq!(chip(&h).as_deref(), Some(PAUSED)); + assert_eq!(builds(&h), n + 1, "no build while playing"); + // An audio-only preview plays with the frame held: still no builds. + let now = h.ctx.input(|i| i.time); + let pb = &mut h.state_mut().playback; + (pb.start_wall, pb.start_frame) = (now, 9); + pb.plan = Some(effectcraft_engine::preview::PreviewPlan { + start: 0, + end: 47, + first: 9, + step: 1, + fps: 24.0, + looping: true, + cache_first: false, + video: false, + audio: true, + origin: effectcraft_engine::time::Tick::ZERO, + }); + for _ in 0..20 { + h.step(); + } + assert!(h.state().playback.playing); + assert_eq!(chip(&h).as_deref(), Some(PAUSED)); + assert_eq!(builds(&h), n + 1, "no build while an audio preview holds the frame"); + h.state_mut().playback.plan = None; + h.state_mut().playback.playing = false; + h.run_steps(2); + wait_probe(&mut h); + assert_eq!(chip(&h).as_deref(), Some("c7_5")); + assert_eq!(builds(&h), n + 2); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// Disarming lets go of the probe (and the aux channels it holds): Esc, the button, or the pick +/// ending any other way. +#[test] +fn disarming_releases_the_probe() { + let (s, ids) = crypto_session("release", 320, 180, false); + let mut h = harness(s, vec2(1400.0, 800.0), 1.0, 420.0); + let a = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + let kept = |h: &Harness<'_, EffectcraftApp>| h.state().session.crypto_probe_stats().0; + for how in ["esc", "button", "other"] { + arm(&mut h, &ids); + assert_eq!(hover(&mut h, a), "c1_1"); + assert!(kept(&h), "{how}: kept while armed"); + match how { + "esc" => h.key_press(egui::Key::Escape), + "button" => { + let eye = at(&h, &format!("effectControls.prop.{}.eyedropper", ids.sel)); + click(&mut h, eye, Modifiers::NONE); + } + _ => h.state_mut().ui.fx_pick = None, + } + h.run_steps(2); + assert!(h.state().ui.fx_pick.is_none() && !kept(&h), "{how}: released"); + } + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// Space (the temporary Hand) and the Hand and Zoom tools keep their clicks: no pick. +#[test] +fn space_hand_and_zoom_clicks_do_not_pick() { + let (s, ids) = crypto_session("nav", 320, 180, false); + let mut h = harness(s, vec2(1400.0, 800.0), 1.0, 420.0); + arm(&mut h, &ids); + let a = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + assert_eq!(hover(&mut h, a), "c1_1"); + let space = |pressed| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }; + h.event(space(true)); + h.step(); + click(&mut h, a, Modifiers::NONE); + h.event(space(false)); + h.run_steps(2); + assert_eq!(selection(&h, &ids), "", "Space + click"); + for tool in [Tool::Zoom, Tool::Hand] { + h.state_mut().ui.tool = tool; + h.run_steps(2); + let a = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + click(&mut h, a, Modifiers::NONE); + assert_eq!(selection(&h, &ids), "", "{tool:?} click"); + } + h.state_mut().ui.tool = Tool::Selection; + h.run_steps(2); + // The middle button pans, armed as unarmed; the right button opens the viewer's menu. + let a = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + let pan0 = h.state().ui.viewer.pan; + h.event(Event::PointerMoved(a)); + h.run_steps(2); + h.event(Event::PointerButton { pos: a, button: PointerButton::Middle, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=5 { + h.event(Event::PointerMoved(a + vec2(10.0 * k as f32, 0.0))); + h.step(); + } + h.event(Event::PointerButton { pos: a + vec2(50.0, 0.0), button: PointerButton::Middle, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + let pan = h.state().ui.viewer.pan; + assert!((pan[0] - pan0[0] - 50.0).abs() < 1.0 && (pan[1] - pan0[1]).abs() < 1.0, "a middle drag pans: {pan0:?} -> {pan:?}"); + assert!(h.state().ui.fx_pick.is_some() && selection(&h, &ids).is_empty(), "and picks nothing"); + let a = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + h.event(Event::PointerMoved(a)); + h.run_steps(2); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: a, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + assert!(egui::Popup::is_any_open(&h.ctx), "a right-click opens the viewer's context menu"); + assert!(h.state().ui.fx_pick.is_some() && selection(&h, &ids).is_empty(), "and picks nothing"); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(!egui::Popup::is_any_open(&h.ctx)); + let a = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + click(&mut h, a, Modifiers::NONE); + assert_eq!(selection(&h, &ids), "c1_1", "the Selection tool picks"); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// A layer without Cryptomatte channels says so on hover, in a chip no wider than allowed (the +/// long message ends in an ellipsis; the whole text stays in its automation label). +#[test] +fn a_layer_without_cryptomatte_shows_the_error_in_a_bounded_chip() { + let (mut s, ids) = crypto_session("nocrypto", 320, 180, false); + let solid = s.execute("layer.newSolid", json!({"name": "Plain", "width": 320, "height": 180})).unwrap()["layer"].as_u64().unwrap(); + s.state.selected_layers = vec![LayerId(solid)]; + s.execute_checked("effect.apply", json!({"layers": [solid], "effect": "Cryptomatte"})).unwrap(); + let sel = s.active_comp().unwrap().layer(LayerId(solid)).unwrap().effects().unwrap().groups().next().unwrap().get("selection").unwrap().uid; + let mut h = harness(s, vec2(1400.0, 800.0), 1.0, 420.0); + arm(&mut h, &Ids { layer: solid, sel, footage: ids.footage, dir: ids.dir.clone() }); + let p = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + let got = hover(&mut h, p); + // The message for the user, without the command's prefix, whole (its hint included). + assert_eq!(got, "This layer has no Cryptomatte channels (a multi-layer OpenEXR with Cryptomatte, or a 3D precomp)"); + let w = h.state().auto.find("viewer.cryptoPick").unwrap().rect[2]; + assert!(w <= 480.0 + 24.0, "chip {w} wide"); + let e = h.state().auto.find("viewer.cryptoPick.error").expect("shown as an error"); + assert_eq!(e.label, "whole", "the error isn't cut off"); + // A narrow viewer still shows it whole, on more lines. + h.set_size(vec2(760.0, 800.0)); + h.run_steps(4); + let e = h.state().auto.find("viewer.cryptoPick.error").expect("shown as an error").clone(); + assert!(e.label == "whole" && e.rect[3] > 30.0, "{e:?}"); + // Narrower still (a viewer about 200 px wide): more lines, still whole. + h.set_size(vec2(630.0, 800.0)); + h.run_steps(4); + let q = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + hover(&mut h, q); + let e = h.state().auto.find("viewer.cryptoPick.error").expect("shown as an error").clone(); + assert_eq!(e.label, "whole", "{e:?} in a viewer {} wide", h.state().auto.find("viewer.fxPick").map_or(0.0, |v| v.rect[2])); + // A click there: the same message in the status line. + click(&mut h, q, Modifiers::NONE); + assert!(h.state().ui.status.starts_with("This layer has no Cryptomatte channels"), "{}", h.state().ui.status); + // A hover with an answer isn't an error. + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// Footage whose Cryptomatte reader panics on the probe's thread. +struct Broken(std::sync::Arc); +impl effectcraft_engine::render::FootageSource for Broken { + fn frame( + &self, + i: effectcraft_engine::project::ItemId, + f: &effectcraft_engine::project::Footage, + t: effectcraft_engine::time::Tick, + ) -> Option> { + self.0.frame(i, f, t) + } + fn frame_with( + &self, + i: effectcraft_engine::project::ItemId, + f: &effectcraft_engine::project::Footage, + t: effectcraft_engine::time::Tick, + size: Option<(u32, u32)>, + float: effectcraft_engine::render::FloatCurve, + ) -> Option> { + self.0.frame_with(i, f, t, size, float) + } + fn aux( + &self, + i: effectcraft_engine::project::ItemId, + f: &effectcraft_engine::project::Footage, + t: effectcraft_engine::time::Tick, + ) -> Option> { + assert_ne!(std::thread::current().name(), Some("crypto-probe"), "a broken Cryptomatte reader"); + self.0.aux(i, f, t) + } +} + +/// A panic while the probe is built shows as an error in the chip, and the pick keeps working. +#[test] +fn a_panicking_probe_build_shows_an_error() { + let (mut s, ids) = crypto_session("panic", 320, 180, false); + s.footage = std::sync::Arc::new(Broken(s.footage.clone())); + let mut h = harness(s, vec2(1400.0, 800.0), 1.0, 420.0); + arm(&mut h, &ids); + let p = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + let got = hover(&mut h, p); + assert!(got.contains("Reading the Cryptomatte IDs failed") && got.contains("a broken Cryptomatte reader"), "{got}"); + let p = comp_to_screen(&h.ctx, [120.0, 100.0]).unwrap(); + assert_eq!(hover(&mut h, p), got, "not retried for the frame"); + assert!(h.state().ui.fx_pick.is_some()); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// A long Selection cut off by a narrow Effect Controls panel shows whole on hover. +#[test] +fn a_cut_off_selection_shows_whole_on_hover() { + use egui_kittest::kittest::Queryable; + let (mut s, ids) = crypto_session("tip", 320, 180, false); + let long = (0..8).map(|i| format!("c{i}_{i}")).collect::>().join(", "); + s.execute_checked("prop.set", json!({"path": "effects/#1/selection", "value": long})).unwrap(); + let mut h = harness(s, vec2(1100.0, 800.0), 1.0, 300.0); + let field = at(&h, &format!("effectControls.prop.{}.value", ids.sel)); + h.event(Event::PointerMoved(field)); + h.run_steps(8); + assert!(h.query_by_label(&long).is_some(), "the whole Selection on hover"); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// The highlight's screen corners (its automation label), when one is drawn. +fn highlight(h: &Harness<'_, EffectcraftApp>) -> Option> { + let e = h.state().auto.find("viewer.cryptoPick.highlight")?; + Some(e.label.split(' ').map(|c| c.split_once(',').map(|(x, y)| pos2(x.parse().unwrap(), y.parse().unwrap())).unwrap()).collect()) +} + +/// The hovered ID's highlight covers the layer as the viewer shows it: unscaled, and scaled and +/// rotated; none off the IDs. +#[test] +fn the_highlight_covers_the_layer_as_shown() { + let (s, ids) = crypto_session("quad", 320, 180, false); + let mut h = harness(s, vec2(1400.0, 800.0), 1.0, 420.0); + arm(&mut h, &ids); + let corners = [[0.0, 0.0], [320.0, 0.0], [320.0, 180.0], [0.0, 180.0]]; + for (scale, rot) in [(100.0, 0.0_f64), (50.0, 30.0), (150.0, -10.0)] { + let s = &mut h.state_mut().session; + s.execute_checked("prop.set", json!({"path": "transform/scale", "value": [scale, scale]})).unwrap(); + s.execute_checked("prop.set", json!({"path": "transform/rotation", "value": rot})).unwrap(); + settle(&mut h); + // Layer point → comp: about the anchor (the centre), at the position (the comp's centre). + let (k, (sn, cs)) = (scale / 100.0, rot.to_radians().sin_cos()); + let comp = |p: [f64; 2]| { + let (x, y) = ((p[0] - 160.0) * k, (p[1] - 90.0) * k); + [(160.0 + x * cs - y * sn) as f32, (90.0 + x * sn + y * cs) as f32] + }; + let a = comp_to_screen(&h.ctx, comp([50.0, 40.0])).unwrap(); + assert_eq!(hover(&mut h, a).trim_end_matches(" (selected)"), "c1_1", "scale {scale} rotation {rot}"); + let got = highlight(&h).expect("a highlight"); + for (g, c) in got.iter().zip(corners) { + let want = comp_to_screen(&h.ctx, comp(c)).unwrap(); + assert!(g.distance(want) < 0.6, "scale {scale} rotation {rot}: corner {c:?} at {g:?}, the layer's at {want:?}"); + } + } + let s = &mut h.state_mut().session; + s.execute_checked("prop.set", json!({"path": "transform/scale", "value": [100.0, 100.0]})).unwrap(); + s.execute_checked("prop.set", json!({"path": "transform/rotation", "value": 0.0})).unwrap(); + settle(&mut h); + let off = comp_to_screen(&h.ctx, [-3.0, 90.0]).unwrap(); + assert_eq!(hover(&mut h, off), HINT); + assert!(highlight(&h).is_none(), "no highlight off the IDs"); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// A click waits for its frame's probe (the build has started and is held), then the frame steps +/// with the pointer kept on the viewer (each frame its own probe: an animated blur). The hover +/// and the waiting click never take turns cancelling each other's build: the click, for a frame +/// no longer shown, is dropped with a message, the hover reads the new frame once the held build +/// ends, and a new click picks, all within a few builds and without a thread per pass. +#[test] +fn a_frame_step_while_a_click_waits_settles_within_a_few_builds() { + let (mut s, ids) = crypto_session("livelock", 960, 540, true); + for (t, v) in [(0.0, 2.0), (1.0, 6.0)] { + s.execute_checked("prop.addKey", json!({"path": "effects/#1/blurriness", "time": t, "value": v})).unwrap(); + } + let hold = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + s.footage = std::sync::Arc::new(Held(s.footage.clone(), hold.clone(), Default::default())); + let mut h = harness_dt(s, vec2(1280.0, 800.0), 1.0, 420.0, 0.02); + arm(&mut h, &ids); + let a = comp_to_screen(&h.ctx, [260.0, 150.0]).unwrap(); + set_frame(&mut h, 3); + hold.store(true, std::sync::atomic::Ordering::Release); + let before = h.state().session.crypto_probe_jobs(); + h.event(Event::PointerMoved(a)); + h.run_steps(10); // settled: the build for frame 3 starts (and is held) + assert_eq!(chip(&h).as_deref(), Some(READING)); + started(&h, before); + let b0 = builds(&h); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + let jobs = |h: &Harness<'_, EffectcraftApp>| h.state().session.crypto_probe_jobs(); + let j0 = jobs(&h); + set_frame(&mut h, 4); + let step = |h: &mut Harness<'_, EffectcraftApp>| { + h.step(); + std::thread::sleep(std::time::Duration::from_millis(5)); + }; + // 100 passes with the pointer on the viewer while frame 3's build is held. + for _ in 0..100 { + step(&mut h); + } + assert_eq!(chip(&h).as_deref(), Some(READING)); + assert_eq!((builds(&h), jobs(&h)), (b0, j0), "nothing else started while one build runs"); + // (Asked now: a status message goes after 5 s.) + assert!(h.state().ui.status.starts_with("Pick not made"), "the click for frame 3 says it wasn't made: {:?}", h.state().ui.status); + hold.store(false, std::sync::atomic::Ordering::Release); + for _ in 0..1000 { + step(&mut h); + if chip(&h).as_deref() != Some(READING) { + break; + } + } + assert_eq!(chip(&h).as_deref(), Some("c7_5"), "frame 4's IDs are read"); + assert_eq!(selection(&h, &ids), "", "the click for frame 3 isn't made on frame 4"); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + for _ in 0..500 { + step(&mut h); + if !selection(&h, &ids).is_empty() { + break; + } + } + assert_eq!(selection(&h, &ids), "c7_5", "the click on frame 4 picks"); + // Frame 4's build, and no thread that did nothing. + let (n, j) = (builds(&h) - b0, jobs(&h) - j0); + assert!(n <= 2 && j == n, "{n} builds, {j} jobs (threads) after frame 3's"); + // Then nothing more is started. + for _ in 0..50 { + step(&mut h); + } + assert_eq!((builds(&h) - b0, jobs(&h) - j0), (n, j)); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// Footage whose Cryptomatte read on the probe's thread waits while `hold` is set. It logs the +/// probe threads' reads: (thread, a picture frame rather than the IDs). +struct Held(std::sync::Arc, std::sync::Arc, ReadLog); +type ReadLog = std::sync::Arc>>; +impl Held { + fn log(&self, frame: bool) { + if std::thread::current().name() == Some("crypto-probe") { + self.2.lock().unwrap().push((std::thread::current().id(), frame)); + } + } +} +impl effectcraft_engine::render::FootageSource for Held { + fn frame( + &self, + i: effectcraft_engine::project::ItemId, + f: &effectcraft_engine::project::Footage, + t: effectcraft_engine::time::Tick, + ) -> Option> { + self.log(true); + self.0.frame(i, f, t) + } + fn frame_with( + &self, + i: effectcraft_engine::project::ItemId, + f: &effectcraft_engine::project::Footage, + t: effectcraft_engine::time::Tick, + size: Option<(u32, u32)>, + float: effectcraft_engine::render::FloatCurve, + ) -> Option> { + self.log(true); + self.0.frame_with(i, f, t, size, float) + } + fn aux( + &self, + i: effectcraft_engine::project::ItemId, + f: &effectcraft_engine::project::Footage, + t: effectcraft_engine::time::Tick, + ) -> Option> { + self.log(false); + while std::thread::current().name() == Some("crypto-probe") && self.1.load(std::sync::atomic::Ordering::Acquire) { + std::thread::sleep(std::time::Duration::from_millis(2)); + } + self.0.aux(i, f, t) + } +} + +/// Disarmed and armed again while a probe is built: the new hover waits for that build to end +/// before it starts its own (one build at a time, so one probe's memory). +#[test] +fn rearming_during_a_build_never_runs_two_builds() { + let (mut s, ids) = crypto_session("rearm", 320, 180, false); + let hold = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + s.footage = std::sync::Arc::new(Held(s.footage.clone(), hold.clone(), Default::default())); + let mut h = harness(s, vec2(1400.0, 800.0), 1.0, 420.0); + arm(&mut h, &ids); + let a = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + hold.store(true, std::sync::atomic::Ordering::Release); + let before = h.state().session.crypto_probe_jobs(); + h.event(Event::PointerMoved(a)); + h.run_steps(2); + assert_eq!(chip(&h).as_deref(), Some(READING)); + started(&h, before); + let b1 = builds(&h); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().ui.fx_pick.is_none()); + arm(&mut h, &ids); + h.event(Event::PointerMoved(a)); + for _ in 0..20 { + h.step(); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(chip(&h).as_deref(), Some(READING)); + assert_eq!(builds(&h), b1, "a second build started while the first runs"); + hold.store(false, std::sync::atomic::Ordering::Release); + assert_eq!(hover(&mut h, a), "c1_1"); + assert_eq!(builds(&h), b1 + 1, "then one build for the new pick"); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// Esc while typing in the Selection field ends the typing (the text goes back), not the pick. +#[test] +fn esc_in_the_selection_field_keeps_the_pick() { + let (s, ids) = crypto_session("escfield", 320, 180, false); + let mut h = harness(s, vec2(1400.0, 800.0), 1.0, 420.0); + arm(&mut h, &ids); + let field = at(&h, &format!("effectControls.prop.{}.value", ids.sel)); + click(&mut h, field, Modifiers::NONE); + h.event(Event::Text("c9_9".into())); + h.run_steps(2); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().ui.fx_pick.is_some(), "still armed"); + assert_eq!(selection(&h, &ids), "", "the typing was dropped"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().ui.fx_pick.is_none(), "Esc outside the field ends the pick"); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// A build cancelled while it reads the IDs (the pick turned off, or another project opened) +/// stops there: it doesn't go on to render the effect's input (a 4K plate behind a blur), so +/// the next pick's build, waiting for it (one at a time), starts at once. +#[test] +fn a_cancelled_build_does_not_keep_the_next_pick_waiting() { + for how in ["disarm", "open"] { + let (mut s, ids) = crypto_session(&format!("cancel-{how}"), 3840, 2160, true); + let (hold, log) = (std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), ReadLog::default()); + s.footage = std::sync::Arc::new(Held(s.footage.clone(), hold.clone(), log.clone())); + let mut h = harness(s, vec2(1280.0, 800.0), 1.0, 420.0); + arm(&mut h, &ids); + let a = comp_to_screen(&h.ctx, [1017.5, 594.5]).unwrap(); + hold.store(true, std::sync::atomic::Ordering::Release); + let before = h.state().session.crypto_probe_jobs(); + h.event(Event::PointerMoved(a)); + h.run_steps(2); + started(&h, before); + while log.lock().unwrap().is_empty() { + std::thread::sleep(std::time::Duration::from_millis(2)); + } + let held = log.lock().unwrap()[0].0; + match how { + "disarm" => h.key_press(egui::Key::Escape), + _ => { + let p = (*h.state().session.project).clone(); + h.state_mut().session.replace_project(p, None); + h.state_mut().session.state.selected_layers = vec![LayerId(ids.layer)]; + } + } + h.run_steps(2); + assert!(h.state().ui.fx_pick.is_none(), "{how}: the pick ended"); + arm(&mut h, &ids); + h.event(Event::PointerMoved(a)); + h.run_steps(2); + assert_eq!(chip(&h).as_deref(), Some(READING), "{how}: waits for the cancelled build"); + let jobs = h.state().session.crypto_probe_jobs(); + hold.store(false, std::sync::atomic::Ordering::Release); + assert_eq!(hover(&mut h, a), "c27_20", "{how}"); + assert_eq!(h.state().session.crypto_probe_jobs(), jobs + 1, "{how}: one more build"); + let frames = log.lock().unwrap().iter().filter(|(t, f)| *t == held && *f).count(); + assert_eq!(frames, 0, "{how}: the cancelled build rendered its input"); + let _ = std::fs::remove_dir_all(&ids.dir); + } +} + +/// While playing, a frame whose IDs aren't read yet can't be picked: the chip says to stop, and +/// a click there says it wasn't made, at once, instead of waiting for a frame that is gone. A +/// frame whose IDs are read (here the one shown before playing) still picks while playing. +#[test] +fn a_click_while_playing_says_to_stop_playback() { + let (mut s, ids) = crypto_session("playclick", 960, 540, true); + for (t, v) in [(0.0, 2.0), (1.0, 6.0)] { + s.execute_checked("prop.addKey", json!({"path": "effects/#1/blurriness", "time": t, "value": v})).unwrap(); + } + let mut h = harness_dt(s, vec2(1280.0, 800.0), 1.0, 420.0, 0.02); + arm(&mut h, &ids); + let a = comp_to_screen(&h.ctx, [260.0, 150.0]).unwrap(); + assert_eq!(hover(&mut h, a), "c7_5"); + let (steps, n) = (h.state().session.history.undo.len(), builds(&h)); + // Playing with the frame held at 9 (an audio-only preview), so a queued click could be made. + let play = |h: &mut Harness<'_, EffectcraftApp>| { + set_frame(h, 9); + let now = h.ctx.input(|i| i.time); + let pb = &mut h.state_mut().playback; + (pb.playing, pb.start_wall, pb.start_frame) = (true, now, 9); + let origin = effectcraft_engine::time::Tick::ZERO; + let plan = effectcraft_engine::preview::PreviewPlan { + start: 0, + end: 47, + first: 9, + step: 1, + fps: 24.0, + looping: true, + cache_first: false, + video: false, + audio: true, + origin, + }; + pb.plan = Some(plan); + }; + let stop = |h: &mut Harness<'_, EffectcraftApp>| (h.state_mut().playback.plan, h.state_mut().playback.playing) = (None, false); + play(&mut h); + for _ in 0..500 { + h.step(); + if chip(&h).as_deref() == Some(PAUSED) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + assert_eq!(chip(&h).as_deref(), Some(PAUSED)); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + assert_eq!(h.state().ui.status, "Pick not made: stop playback to pick", "said at the click"); + // Not queued: stopped on the same frame, its IDs are read and nothing is picked. + stop(&mut h); + h.run_steps(2); + wait_probe(&mut h); + h.run_steps(4); + assert_eq!(chip(&h).as_deref(), Some("c7_5")); + assert_eq!((selection(&h, &ids), h.state().session.history.undo.len(), builds(&h)), (String::new(), steps, n + 1)); + // Playing over a frame whose IDs are read: the click picks. + play(&mut h); + h.run_steps(4); + assert_eq!(chip(&h).as_deref(), Some("c7_5")); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(2); + assert_eq!((selection(&h, &ids), h.state().session.history.undo.len()), ("c7_5".to_string(), steps + 1)); + stop(&mut h); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// A 320 × 180 still whose probe builds are held (`hold`), the eyedropper armed and reading. +fn held_session(tag: &str) -> (Harness<'static, EffectcraftApp>, Ids, std::sync::Arc) { + let (mut s, ids) = crypto_session(tag, 320, 180, false); + let hold = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + s.footage = std::sync::Arc::new(Held(s.footage.clone(), hold.clone(), Default::default())); + let mut h = harness(s, vec2(1400.0, 800.0), 1.0, 420.0); + arm(&mut h, &ids); + hold.store(true, std::sync::atomic::Ordering::Release); + let before = h.state().session.crypto_probe_jobs(); + h.event(Event::PointerMoved(comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap())); + h.run_steps(2); + started(&h, before); + (h, ids, hold) +} + +/// Lets the held builds go and runs passes for a second, then returns whether one said a pick +/// wasn't made, and the undo labels. +fn let_go(h: &mut Harness<'static, EffectcraftApp>, hold: &std::sync::atomic::AtomicBool) -> (bool, Vec) { + hold.store(false, std::sync::atomic::Ordering::Release); + let mut said = false; + for _ in 0..200 { + h.step(); + said |= h.state().ui.status.starts_with("Pick not made"); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + (said, h.state().session.history.undo.iter().map(|(l, _)| l.clone()).collect()) +} + +fn button(h: &mut Harness<'static, EffectcraftApp>, pos: Pos2, pressed: bool, modifiers: Modifiers) { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }); +} + +/// A click is the frame and the project shown at its press: a frame step or an edit before its +/// release (an arrow key or PageDown that the window delivers before the release, Windows +/// round 3) drops it and says so, and it isn't made afterwards. Keys with the release, after +/// the click, drop it too. Without a change it is made (the control). +#[test] +fn a_click_is_dropped_when_the_frame_or_project_changes_before_its_release_or_ids() { + type Change = Box)>; + let exec = |c: &'static str, p: serde_json::Value| -> Change { Box::new(move |h| drop(h.state_mut().session.execute_checked(c, p.clone()).unwrap())) }; + let key = |key: egui::Key| -> Change { + Box::new(move |h| { + for pressed in [true, false] { + h.event(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); + } + }) + }; + let cases: Vec<(&str, Option, bool)> = vec![ + ("control", None, false), + ("time.nextFrame before the release", Some(exec("time.nextFrame", json!({}))), false), + ("layer.nudge before the release", Some(exec("layer.nudge", json!({"x": 2}))), false), + ("PageDown with the release", Some(key(egui::Key::PageDown)), true), + ("Right with the release", Some(key(egui::Key::ArrowRight)), true), + ]; + // And with the key in the pass whose end makes the click (its IDs stored just before). + for k in [egui::Key::PageDown, egui::Key::ArrowRight] { + let (mut h, ids, hold) = held_session(&format!("atomic-arrive-{k:?}")); + let a = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + for pressed in [true, false] { + button(&mut h, a, pressed, Modifiers::NONE); + h.step(); + } + hold.store(false, std::sync::atomic::Ordering::Release); + for _ in 0..2500 { + if h.state().session.crypto_probe_stats().0 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + assert!(h.state().session.crypto_probe_stats().0, "{k:?}: the probe is stored, no pass since"); + key(k)(&mut h); + h.step(); + let said = h.state().ui.status.starts_with("Pick not made"); + let (_, labels) = let_go(&mut h, &hold); + assert_eq!(selection(&h, &ids), "", "{k:?} as the IDs arrive: made ({labels:?})"); + assert!(said && !labels.iter().any(|l| l == "Pick Cryptomatte"), "{k:?}: {labels:?} said {said}"); + let _ = std::fs::remove_dir_all(&ids.dir); + } + for (case, change, with_release) in cases { + let (mut h, ids, hold) = held_session(&format!("atomic-{}", case.len())); + let a = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + let (t0, rev0, steps) = (h.state().session.time().seconds(), h.state().session.revision, h.state().session.history.undo.len()); + button(&mut h, a, true, Modifiers::NONE); + h.step(); + if let Some(c) = change.as_ref().filter(|_| !with_release) { + c(&mut h); + } + button(&mut h, a, false, Modifiers::NONE); + if let Some(c) = change.as_ref().filter(|_| with_release) { + c(&mut h); + } + h.step(); + let (said, labels) = let_go(&mut h, &hold); + if change.is_none() { + assert_eq!((selection(&h, &ids), labels.len(), said), ("c1_1".to_string(), steps + 1, false), "{case}: the click is made"); + } else { + assert!(h.state().session.time().seconds() != t0 || h.state().session.revision != rev0, "{case}: changed nothing"); + assert_eq!(selection(&h, &ids), "", "{case}: the stale click was made ({labels:?})"); + assert!(!labels.iter().any(|l| l == "Pick Cryptomatte"), "{case}: {labels:?}"); + assert!(said, "{case}: not said"); + } + let _ = std::fs::remove_dir_all(&ids.dir); + } +} + +/// A waiting click keeps the resolution it was made at: when the viewer then shows the frame at +/// another (a zoom with Auto, or a frame cached at another resolution arriving), it is still +/// made, before the next click (Windows round 3: a plain click lost, 1 run in 51). +#[test] +fn a_waiting_click_is_made_when_the_shown_resolution_changes() { + let (mut h, ids, hold) = held_session("rescale"); + let a = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + let (before, steps) = (shown_scale(&h), h.state().session.history.undo.len()); + for pressed in [true, false] { + button(&mut h, a, pressed, Modifiers::NONE); + h.step(); + } + h.state_mut().ui.viewer.zoom = Some(0.3); + for _ in 0..400 { + h.step(); + if shown_scale(&h) != before { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_ne!(shown_scale(&h), before, "the shown resolution changes"); + let b = comp_to_screen(&h.ctx, [120.0, 100.0]).unwrap(); + h.event(Event::PointerMoved(b)); + h.event(Event::ModifiersChanged(Modifiers::SHIFT)); + h.step(); + for pressed in [true, false] { + button(&mut h, b, pressed, Modifiers::SHIFT); + h.step(); + } + h.event(Event::ModifiersChanged(Modifiers::NONE)); + let (said, labels) = let_go(&mut h, &hold); + assert_eq!((selection(&h, &ids), labels.len() - steps, said), ("c1_1, c3_3".to_string(), 2, false), "{labels:?}"); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// A Shift+click still held when the click before it is made (its build ends while the button +/// is down) is kept: the eyedropper's own pick is not "the project changed". +/// It is made after the first, in order. +#[test] +fn a_click_held_while_the_one_before_is_made_is_kept() { + let (mut h, ids, hold) = held_session("held-second"); + let (a, b) = (comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(), comp_to_screen(&h.ctx, [120.0, 100.0]).unwrap()); + let steps = h.state().session.history.undo.len(); + for pressed in [true, false] { + button(&mut h, a, pressed, Modifiers::NONE); + h.step(); + } + h.event(Event::PointerMoved(b)); + h.event(Event::ModifiersChanged(Modifiers::SHIFT)); + h.step(); + button(&mut h, b, true, Modifiers::SHIFT); + h.step(); + hold.store(false, std::sync::atomic::Ordering::Release); + for _ in 0..400 { + if selection(&h, &ids) == "c1_1" { + break; + } + h.step(); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(selection(&h, &ids), "c1_1", "the first click is made while the second is held"); + button(&mut h, b, false, Modifiers::SHIFT); + h.step(); + let at_release = h.state().ui.status.clone(); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + let (said, labels) = let_go(&mut h, &hold); + assert_eq!((selection(&h, &ids), labels.len() - steps, said), ("c1_1, c3_3".to_string(), 2, false), "status at the release: {at_release:?}"); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// A drop clears every waiting click at once: a click made right after it starts a queue of its +/// own (on the new revision) and is made. +#[test] +fn a_drop_clears_the_whole_queue() { + let (mut h, ids, hold) = held_session("drop-all"); + let (a, b) = (comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(), comp_to_screen(&h.ctx, [120.0, 100.0]).unwrap()); + let steps = h.state().session.history.undo.len(); + for p in [a, b] { + for pressed in [true, false] { + button(&mut h, p, pressed, Modifiers::NONE); + h.step(); + } + } + // The edit drops both; a press in the pass that drops them and its release in the next are + // the next click's, made on its own (not dropped with a click left over from the queue). + h.state_mut().session.execute_checked("layer.nudge", json!({"x": 2})).unwrap(); + let c = comp_to_screen(&h.ctx, [120.0, 100.0]).unwrap(); + let ev = &mut h.input_mut().events; + ev.push(Event::PointerMoved(c)); + ev.push(Event::PointerButton { pos: c, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + assert!(h.state().ui.status.starts_with("Pick not made"), "{:?}", h.state().ui.status); + button(&mut h, c, false, Modifiers::NONE); + h.step(); + let (_, labels) = let_go(&mut h, &hold); + assert_eq!(selection(&h, &ids), "c3_3", "the click after the drop is made: {labels:?}"); + assert_eq!(labels.iter().filter(|l| *l == "Pick Cryptomatte").count(), 1, "{labels:?} (from {steps})"); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// The drag distance is the press to the release: the pointer moving on after the release, in +/// the same pass, doesn't cancel the click (Windows round 4, B). +#[test] +fn a_click_measures_its_drag_to_the_release_not_the_pointer_after() { + let (mut h, ids, hold) = held_session("release-pos"); + let a = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + button(&mut h, a, true, Modifiers::NONE); + h.step(); + // In one pass (`Harness::event` would give each event its own pass). + let ev = &mut h.input_mut().events; + ev.push(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + ev.push(Event::PointerMoved(a + vec2(80.0, 0.0))); + h.step(); + let at_release = h.state().ui.status.clone(); + let (said, _) = let_go(&mut h, &hold); + assert_eq!((selection(&h, &ids), said), ("c1_1".to_string(), false), "status at the release: {at_release:?}"); + assert_ne!(at_release, "Drag cancelled the pick"); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// Playing, a click whose frame moved on between its press and release says "stop playback" +/// (not that the frame changed), and the message stays its 5 s even though another message +/// (the eyedropper's hint) showed for longer before it (Windows round 4, C). +#[test] +fn a_click_while_playing_says_stop_playback_and_the_message_stays() { + let (s, _ids) = crypto_session("playmove", 960, 540, true); + let mut h = harness_dt(s, vec2(1280.0, 800.0), 1.0, 420.0, 0.02); + arm(&mut h, &_ids); + let a = comp_to_screen(&h.ctx, [260.0, 150.0]).unwrap(); + h.event(Event::PointerMoved(a)); + let hint = h.state().ui.status.clone(); + assert!(!hint.is_empty(), "the eyedropper's hint shows"); + // The hint has shown for 4.6 s when the click comes. + let t0 = h.ctx.input(|i| i.time); + while h.ctx.input(|i| i.time) - t0 < 4.6 { + h.step(); + } + assert_eq!(h.state().ui.status, hint); + play_at(&mut h, 9); + h.step(); + button(&mut h, a, true, Modifiers::NONE); + h.step(); + play_at(&mut h, 10); + button(&mut h, a, false, Modifiers::NONE); + h.step(); + assert!(h.state().playback.playing); + assert_eq!(h.state().ui.status, "Pick not made: stop playback to pick", "said at the release"); + let t1 = h.ctx.input(|i| i.time); + while h.ctx.input(|i| i.time) - t1 < 1.5 { + h.step(); + } + assert_eq!(h.state().ui.status, "Pick not made: stop playback to pick", "still shown 1.5 s later"); + let _ = std::fs::remove_dir_all(&_ids.dir); +} + +/// A press held still past egui's click time (0.8 s) and let go before any pass sees it held +/// that long is neither a click nor a drag to egui (an idle app doesn't repaint while a mouse +/// button is held still): it is still a click here, and is made. +#[test] +fn a_long_still_press_with_no_pass_in_between_is_a_click() { + let (mut h, ids, hold) = held_session("long-press"); + let a = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + button(&mut h, a, true, Modifiers::NONE); + h.step(); + // Passes 0.25 s apart: the last one held is at 0.75 s, the release at 1.0 s. + let t0 = h.ctx.input(|i| i.time); + while h.ctx.input(|i| i.time) - t0 + 0.25 < 0.8 { + h.step(); + } + button(&mut h, a, false, Modifiers::NONE); + h.step(); + let at_release = h.state().ui.status.clone(); + let (said, labels) = let_go(&mut h, &hold); + assert_eq!((selection(&h, &ids), said), ("c1_1".to_string(), false), "status at the release: {at_release:?}, {labels:?}"); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +// Several primary-button events in one pass (Windows round 5, c). +/// An armed eyedropper with the IDs read and hovered at two points (c1_1 and c3_3). +fn ready_at_two_ids(tag: &str) -> (Harness<'static, EffectcraftApp>, Ids, Pos2, Pos2) { + let (s, ids) = crypto_session(tag, 320, 180, false); + let mut h = harness(s, vec2(1400.0, 800.0), 1.0, 420.0); + arm(&mut h, &ids); + let (a, b) = (comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(), comp_to_screen(&h.ctx, [120.0, 100.0]).unwrap()); + assert_eq!(hover(&mut h, a), "c1_1"); + assert_eq!(hover(&mut h, b), "c3_3"); + (h, ids, a, b) +} + +fn pick_steps(h: &Harness<'_, EffectcraftApp>) -> usize { + h.state().session.history.undo.iter().filter(|(l, _)| l == "Pick Cryptomatte").count() +} + +/// One click's release and the next one's Shift-press in one pass (a loaded UI thread, Windows +/// round 5 c): both are made, in order, the second as an add. +#[test] +fn a_release_and_the_next_press_in_one_pass_are_both_made() { + let (mut h, ids, a, b) = ready_at_two_ids("batch1"); + h.event(Event::PointerMoved(a)); + h.step(); + h.event(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + let ev = &mut h.input_mut().events; + ev.push(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + ev.push(Event::PointerMoved(b)); + ev.push(Event::ModifiersChanged(Modifiers::SHIFT)); + ev.push(Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::SHIFT }); + h.step(); + let mid = (selection(&h, &ids), h.state().ui.status.clone()); + h.event(Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::SHIFT }); + h.step(); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(4); + assert_eq!(selection(&h, &ids), "c1_1, c3_3", "mid {mid:?}"); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// Two whole clicks in one pass: both are made, two steps. +#[test] +fn two_whole_clicks_in_one_pass_are_both_made() { + let (mut h, ids, a, b) = ready_at_two_ids("batch2"); + h.event(Event::PointerMoved(a)); + h.step(); + let ev = &mut h.input_mut().events; + ev.push(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + ev.push(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + ev.push(Event::PointerMoved(b)); + ev.push(Event::ModifiersChanged(Modifiers::SHIFT)); + ev.push(Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::SHIFT }); + ev.push(Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::SHIFT }); + h.step(); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(4); + assert_eq!((selection(&h, &ids), pick_steps(&h)), ("c1_1, c3_3".to_string(), 2)); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// Arm, Esc (which clears the status early), re-arm 6 s later: the hint shows again for its own +/// time instead of inheriting the old one's. +#[test] +fn the_hint_shows_again_after_esc_and_rearm() { + let (s, ids) = crypto_session("hint", 320, 180, false); + let mut h = harness(s, vec2(1400.0, 800.0), 1.0, 420.0); + arm(&mut h, &ids); + let hint = h.state().ui.status.clone(); + assert!(!hint.is_empty()); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().ui.fx_pick.is_none() && h.state().ui.status.is_empty(), "Esc ends it: {:?}", h.state().ui.status); + h.run_steps(24); + arm(&mut h, &ids); + let after: Vec = (0..4) + .map(|_| { + h.step(); + h.state().ui.status.clone() + }) + .collect(); + assert_eq!(after[3], hint, "the hint after re-arming"); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// One whole click (press and release) in one pass, the IDs read: it is made. +#[test] +fn a_whole_click_in_one_pass_is_made() { + let (mut h, ids, a, _) = ready_at_two_ids("onepass"); + h.event(Event::PointerMoved(a)); + h.step(); + let ev = &mut h.input_mut().events; + ev.push(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + ev.push(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.step(); + h.run_steps(4); + assert_eq!((selection(&h, &ids), pick_steps(&h)), ("c1_1".to_string(), 1)); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// The same while the IDs are being read: the click waits and is made. +#[test] +fn a_whole_click_in_one_pass_waits_while_reading_and_is_made() { + let (mut h, ids, hold) = held_session("s6-onepass-held"); + let a = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + let ev = &mut h.input_mut().events; + ev.push(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + ev.push(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.step(); + let (said, labels) = let_go(&mut h, &hold); + assert_eq!((selection(&h, &ids), said), ("c1_1".to_string(), false), "{labels:?}"); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// Playback from `frame` (an audio-only preview, so the frame is the one set here). +fn play_at(h: &mut Harness<'_, EffectcraftApp>, frame: i64) { + set_frame(h, frame); + let now = h.ctx.input(|i| i.time); + let pb = &mut h.state_mut().playback; + (pb.playing, pb.start_wall, pb.start_frame) = (true, now, frame); + let plan = effectcraft_engine::preview::PreviewPlan { + start: 0, + end: 47, + first: frame, + step: 1, + fps: 24.0, + looping: true, + cache_first: false, + video: false, + audio: true, + origin: effectcraft_engine::time::Tick::ZERO, + }; + pb.plan = Some(plan); +} + +/// Clicks waiting for their IDs when playback starts are dropped, and the status says to stop +/// playback (not that the frame changed). +#[test] +fn waiting_clicks_dropped_by_playback_say_stop_playback() { + let (mut s, ids) = crypto_session("play-drop", 320, 180, true); + let hold = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + s.footage = std::sync::Arc::new(Held(s.footage.clone(), hold.clone(), Default::default())); + let mut h = harness(s, vec2(1400.0, 800.0), 1.0, 420.0); + arm(&mut h, &ids); + hold.store(true, std::sync::atomic::Ordering::Release); + let before = h.state().session.crypto_probe_jobs(); + let a = comp_to_screen(&h.ctx, [50.0, 40.0]).unwrap(); + h.event(Event::PointerMoved(a)); + h.run_steps(2); + started(&h, before); + for pressed in [true, false] { + button(&mut h, a, pressed, Modifiers::NONE); + h.step(); + } + assert!(!h.state().ui.status.starts_with("Pick not made"), "{:?}", h.state().ui.status); + play_at(&mut h, 9); + h.step(); + assert_eq!(h.state().ui.status, "Pick not made: stop playback to pick"); + h.state_mut().playback.playing = false; + let (_, labels) = let_go(&mut h, &hold); + assert_eq!(selection(&h, &ids), "", "{labels:?}"); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// Space pressed while the button is held (to pan) cancels the press: nothing is picked at the +/// release. +#[test] +fn space_while_the_button_is_held_cancels_the_click() { + let (mut h, ids, a, _) = ready_at_two_ids("space-mid"); + h.event(Event::PointerMoved(a)); + h.step(); + button(&mut h, a, true, Modifiers::NONE); + h.step(); + let space = |pressed| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }; + h.event(space(true)); + h.step(); + button(&mut h, a, false, Modifiers::NONE); + h.step(); + h.event(space(false)); + h.run_steps(4); + assert_eq!(selection(&h, &ids), ""); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// A Shift-click whose Shift is let go in the same pass is still an add: the modifiers are the +/// press's own, not the pass's end state. +#[test] +fn a_shift_click_in_one_pass_with_shift_let_go_adds() { + let (mut h, ids, a, b) = ready_at_two_ids("shift"); + for pressed in [true, false] { + button(&mut h, a, pressed, Modifiers::NONE); + h.step(); + } + h.run_steps(4); + assert_eq!(selection(&h, &ids), "c1_1"); + h.event(Event::PointerMoved(b)); + h.step(); + let ev = &mut h.input_mut().events; + ev.push(Event::ModifiersChanged(Modifiers::SHIFT)); + ev.push(Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::SHIFT }); + ev.push(Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::SHIFT }); + ev.push(Event::ModifiersChanged(Modifiers::NONE)); + h.step(); + h.run_steps(4); + assert_eq!((selection(&h, &ids), pick_steps(&h)), ("c1_1, c3_3".to_string(), 2)); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// The same message set again while it still shows gets its own 5 s: pick, undo 2.5 s later, +/// pick the same ID again (Windows round 6, A1). +#[test] +fn the_same_status_set_again_while_shown_gets_its_own_5_s() { + let (mut h, ids, a, _) = ready_at_two_ids("status-again"); + for pressed in [true, false] { + button(&mut h, a, pressed, Modifiers::NONE); + h.step(); + } + h.run_steps(2); + let msg = h.state().ui.status.clone(); + assert_eq!(msg, "Cryptomatte Selection: c1_1"); + h.run_steps(10); + let _ = h.state_mut().session.execute("edit.undo", json!({})); + assert_eq!((selection(&h, &ids), h.state().ui.status.clone()), (String::new(), msg.clone())); + for pressed in [true, false] { + button(&mut h, a, pressed, Modifiers::NONE); + h.step(); + } + h.run_steps(12); + assert_eq!((selection(&h, &ids), h.state().ui.status.clone()), ("c1_1".to_string(), msg), "3 s after the second pick"); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// A press, a PageDown and the release in one slow pass: the click is the frame on screen at the +/// press, so it is dropped and says so, not made on the next frame (Windows round 6, R-i pd8). +#[test] +fn a_frame_step_between_press_and_release_in_one_pass_drops_the_click() { + let (mut h, ids, a, _) = ready_at_two_ids("key-one-pass"); + let key = |pressed| Event::Key { key: egui::Key::PageDown, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }; + let ev = &mut h.input_mut().events; + ev.push(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + ev.push(key(true)); + ev.push(key(false)); + ev.push(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.step(); + assert!(h.state().session.time().seconds() > 0.0, "the PageDown stepped"); + assert!(h.state().ui.status.starts_with("Pick not made"), "{:?}", h.state().ui.status); + for _ in 0..40 { + h.step(); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!((selection(&h, &ids), pick_steps(&h)), (String::new(), 0)); + let _ = std::fs::remove_dir_all(&ids.dir); +} + +/// The window loses the focus while the button is held (the release may go to another window): +/// the press is dropped, so a later release alone picks nothing. +#[test] +fn losing_the_focus_drops_a_held_press() { + let (mut h, ids, a, _) = ready_at_two_ids("focus-lost"); + button(&mut h, a, true, Modifiers::NONE); + h.step(); + h.event(Event::WindowFocused(false)); + h.step(); + h.event(Event::WindowFocused(true)); + h.step(); + button(&mut h, a, false, Modifiers::NONE); + h.run_steps(4); + assert_eq!((selection(&h, &ids), pick_steps(&h)), (String::new(), 0)); + let _ = std::fs::remove_dir_all(&ids.dir); +} diff --git a/crates/ui-egui/tests/it/ui_crypto_pick_undo.rs b/crates/ui-egui/tests/it/ui_crypto_pick_undo.rs new file mode 100644 index 000000000..32635084a --- /dev/null +++ b/crates/ui-egui/tests/it/ui_crypto_pick_undo.rs @@ -0,0 +1,1328 @@ +//! Global undo / redo with the Cryptomatte eyedropper (#473): every pick is one undo step that +//! never merges with a neighbouring edit, and Ctrl+Z / Ctrl+Shift+Z (the app's bindings; Ctrl+Y +//! is New Solid, as in After Effects) walk a model of the expected states over long sequences of +//! picks mixed with ordinary edits, at Full and Third, in two window sizes. After every step the +//! Selection, the other properties, the Edit menu's Undo / Redo entries, the History panel, the +//! viewer's chip and highlight, and the eyedropper's armed state are checked. Undo while the IDs +//! are being read and with clicks waiting, across Open / Revert / New, and after Esc. + +use effectcraft_engine::Session; +use effectcraft_engine::project::{LayerId, Resolution}; +use effectcraft_engine::raster::channels3d::{crypto_float, crypto_hash}; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::{self, PanelKind, SplitSize}; +use effectcraft_ui_egui::panels::viewer::comp_to_screen; +use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::{Value, json}; + +const READING: &str = "Reading Cryptomatte IDs…"; +const HINT: &str = "Click: replace · Shift: add · Alt or Ctrl: remove"; +const CRYPTO: &str = "ec.3d.cryptomatte"; +/// Settings ▸ General ▸ Undo Levels in these tests (the most allowed). +const LEVELS: u32 = 99; +/// The app's Cmd (Ctrl+Z / Ctrl+Shift+Z) as winit reports it: Ctrl off macOS (`command` is Ctrl +/// there), Cmd on macOS. +const MAC: bool = cfg!(target_os = "macos"); +const CTRL: Modifiers = Modifiers { alt: false, ctrl: !MAC, shift: false, mac_cmd: MAC, command: true }; +const CTRL_SHIFT: Modifiers = Modifiers { alt: false, ctrl: !MAC, shift: true, mac_cmd: MAC, command: true }; + +// --------------------------------------------------------------------------------------------- +// Fixture (as in ui_crypto_pick.rs): an EXR whose CryptoObject has an ID per 37 × 29 px cell. + +fn cell(x: usize, y: usize) -> String { + format!("c{}_{}", x / 37, y / 29) +} + +fn crypto_exr(dir: &std::path::Path, name: &str, w: usize, h: usize) -> String { + use exr::prelude::*; + let ids: Vec = (0..w * h).map(|i| crypto_float(crypto_hash(&cell(i % w, i / w)))).collect(); + let ch = |n: &str, v: Vec| AnyChannel::new(n, FlatSamples::F32(v)); + let one = || vec![1.0f32; w * h]; + let channels = AnyChannels::sort( + vec![ch("R", one()), ch("G", one()), ch("B", one()), ch("A", one()), ch("CryptoObject00.R", ids), ch("CryptoObject00.G", one())].into(), + ); + let mut layer = Layer::new((w, h), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels); + let names: serde_json::Map = (0..w.div_ceil(37)) + .flat_map(|x| (0..h.div_ceil(29)).map(move |y| cell(x * 37, y * 29))) + .map(|n| (n.clone(), json!(format!("{:08x}", crypto_hash(&n))))) + .collect(); + for (key, v) in [("name", "CryptoObject".to_string()), ("manifest", Value::Object(names).to_string())] { + layer.attributes.other.insert(Text::from(format!("cryptomatte/f834d0a/{key}").as_str()), AttributeValue::Text(Text::from(v.as_str()))); + } + let path = dir.join(name); + Image::from_layer(layer).write().to_file(&path).unwrap(); + path.to_string_lossy().to_string() +} + +struct Fix { + comp: u64, + /// The footage layer (Cryptomatte EXR) and the precomp layer of a 3D comp, each with a + /// Cryptomatte effect, and a plain solid. + plate: u64, + pre: u64, + other: u64, + inner: u64, + dir: std::path::PathBuf, +} + +/// A `w` × `h` comp: the plate (optionally behind a heavy blur, so its probe takes a while), +/// with a precomp of two 3D solids (`PA` full size, `PB` a 60 px strip at the left) on top, and a +/// solid. Each Cryptomatte layer has the effect. The history starts empty. +fn session(tag: &str, w: usize, h: usize, blur: bool, precomp: bool) -> (Session, Fix) { + let dir = std::env::temp_dir().join(format!("ec-ui-crypick-undo-{tag}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = crypto_exr(&dir, "plate.exr", w, h); + let mut s = effectcraft_host::session(); + let f = populate(&mut s, &path, w, h, blur, precomp, dir); + (s, f) +} + +/// [`session`]'s project, built in `s`'s current project from the EXR at `path`. +fn populate(s: &mut Session, path: &str, w: usize, h: usize, blur: bool, precomp: bool, dir: std::path::PathBuf) -> Fix { + let r = s.execute("file.import", json!({"paths": [path]})).unwrap(); + let footage = r["items"][0].as_u64().unwrap(); + let comp = s.execute("comp.new", json!({"name": "Crypto", "width": w, "height": h, "frameRate": 24, "duration": 2})).unwrap(); + let comp = comp.get("comp").and_then(Value::as_u64).unwrap_or_else(|| s.active_comp_id().unwrap().0); + let plate = s.execute("layer.addItem", json!({"item": footage})).unwrap()["layer"].as_u64().unwrap(); + s.state.selected_layers = vec![LayerId(plate)]; + if blur { + s.execute_checked("effect.apply", json!({"effect": "Gaussian Blur"})).unwrap(); + s.execute_checked("prop.set", json!({"path": "effects/#1/blurriness", "value": 40.0})).unwrap(); + } + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + let (mut pre, mut inner) = (0, 0); + if precomp { + let a = s.execute("layer.newSolid", json!({"name": "PA", "width": w, "height": h})).unwrap()["layer"].clone(); + let b = s.execute("layer.newSolid", json!({"name": "PB", "width": 60, "height": h})).unwrap()["layer"].clone(); + s.execute_checked("prop.set", json!({"layer": b, "path": "transform/position", "value": [30.0, h as f64 / 2.0, -10.0]})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [a, b], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.precompose", json!({"layers": [a, b], "name": "Inner"})).unwrap(); + inner = s.project.items.iter().find(|(_, i)| i.name == "Inner").unwrap().0.0; + pre = s.active_comp().unwrap().layers.iter().find(|l| l.name == "Inner").unwrap().id.0; + s.execute_checked("effect.apply", json!({"layers": [pre], "effect": "Cryptomatte"})).unwrap(); + } + let other = s.execute("layer.newSolid", json!({"name": "Other", "width": 64, "height": 64})).unwrap()["layer"].as_u64().unwrap(); + s.state.selected_layers = vec![LayerId(plate)]; + s.history = Default::default(); + s.prefs.general.undo_levels = LEVELS; + Fix { comp, plate, pre, other, inner, dir } +} + +/// Composition viewer and Effect Controls side by side, the Timeline and the History panel below. +fn harness(s: Session, size: egui::Vec2) -> Harness<'static, EffectcraftApp> { + let mut app = EffectcraftApp::new(s); + app.ui.viewer.show_layer_controls = false; + app.ui.dock = dock::docked(dock::vsplit( + SplitSize::FixedSecond(200.0), + dock::hsplit(SplitSize::FixedSecond(420.0), dock::tabs(&[PanelKind::Composition], 0), dock::tabs(&[PanelKind::EffectControls], 0)), + dock::hsplit(SplitSize::FixedSecond(300.0), dock::tabs(&[PanelKind::Timeline], 0), dock::tabs(&[PanelKind::History], 0)), + )); + let mut h = Harness::builder().with_size(size).with_pixels_per_point(1.0).with_step_dt(0.25).build_eframe(|_| app); + settle(&mut h); + h +} + +fn settle(h: &mut Harness<'_, EffectcraftApp>) { + for _ in 0..1500 { + h.step(); + if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().is_ok_and(|v| *v > 0.0) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + h.run_steps(4); +} + +fn chip(h: &Harness<'_, EffectcraftApp>) -> Option { + h.state().auto.find("viewer.cryptoPick").map(|e| e.label.clone()) +} + +fn highlight(h: &Harness<'_, EffectcraftApp>) -> bool { + h.state().auto.find("viewer.cryptoPick.highlight").is_some() +} + +fn wait_probe(h: &mut Harness<'_, EffectcraftApp>) { + for _ in 0..3000 { + h.step(); + if chip(h).as_deref() != Some(READING) { + return; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + panic!("the probe never finished"); +} + +fn hover(h: &mut Harness<'_, EffectcraftApp>, pos: Pos2) -> String { + h.event(Event::PointerMoved(pos)); + h.step(); + wait_probe(h); + h.step(); + chip(h).unwrap_or_default() +} + +/// Press and release at `pos` with `modifiers`, without waiting for the probe. +fn press(h: &mut Harness<'_, EffectcraftApp>, pos: Pos2, modifiers: Modifiers) { + h.event(Event::PointerMoved(pos)); + h.event(Event::ModifiersChanged(modifiers)); + h.step(); + for pressed in [true, false] { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }); + h.step(); + } + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.step(); +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, pos: Pos2, modifiers: Modifiers) { + press(h, pos, modifiers); + wait_probe(h); + h.run_steps(2); +} + +fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) { + h.event(Event::ModifiersChanged(modifiers)); + h.event(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }); + h.event(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); +} + +fn at(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")).clone(); + pos2(e.rect[0] + e.rect[2] * 0.5, e.rect[1] + e.rect[3] * 0.5) +} + +/// The Selection property uid of the layer's (first) Cryptomatte effect. +fn sel_uid(s: &Session, comp: u64, layer: u64) -> Option { + let l = s.project.comp(effectcraft_engine::project::ItemId(comp))?.layer(LayerId(layer))?; + l.effects()?.groups().find(|g| g.match_id == CRYPTO)?.get("selection").map(|p| p.uid) +} + +/// Select `layer` (Effect Controls shows it) and arm its Cryptomatte eyedropper. +fn arm(h: &mut Harness<'_, EffectcraftApp>, f: &Fix, layer: u64) -> u64 { + h.state_mut().session.state.selected_layers = vec![LayerId(layer)]; + h.run_steps(3); + let uid = sel_uid(&h.state().session, f.comp, layer).expect("a Cryptomatte effect"); + let eye = at(h, &format!("effectControls.prop.{uid}.eyedropper")); + click(h, eye, Modifiers::NONE); + assert!(h.state().ui.fx_pick.as_ref().is_some_and(|p| p.kind == "crypto" && p.prop == uid && p.layer == layer), "armed on {layer}"); + uid +} + +/// Type `text` into the Selection field of `layer` (shown in Effect Controls), committed with Enter. +fn type_selection(h: &mut Harness<'_, EffectcraftApp>, f: &Fix, layer: u64, text: &str, commit: bool) { + h.state_mut().session.state.selected_layers = vec![LayerId(layer)]; + h.run_steps(3); + let uid = sel_uid(&h.state().session, f.comp, layer).unwrap(); + let field = at(h, &format!("effectControls.prop.{uid}.value")); + click(h, field, Modifiers::NONE); + h.key_press_modifiers(CTRL, Key::A); + h.step(); + h.event(Event::Text(text.into())); + h.step(); + if commit { + key(h, Key::Enter, Modifiers::NONE); + } +} + +// --------------------------------------------------------------------------------------------- +// Observation and model. + +#[derive(Clone, Debug, PartialEq)] +struct LayerObs { + id: u64, + name: String, + opacity: Value, + opacity_keys: Vec<(f64, Value)>, + position: Value, + /// (match id, Selection uid and value for Cryptomatte) per effect. + effects: Vec<(String, Option<(u64, String)>)>, +} + +impl LayerObs { + fn crypto(&self) -> Option<&(u64, String)> { + self.effects.iter().find_map(|e| e.1.as_ref()) + } + fn crypto_mut(&mut self) -> &mut (u64, String) { + self.effects.iter_mut().find_map(|e| e.1.as_mut()).expect("a Cryptomatte effect") + } +} + +type Obs = Vec; + +fn observe(s: &Session, comp: u64) -> Obs { + let c = s.project.comp(effectcraft_engine::project::ItemId(comp)).unwrap(); + c.layers + .iter() + .map(|l| { + let tr = l.transform().unwrap(); + let op = tr.get("opacity").unwrap(); + let effects = l + .effects() + .map(|fx| { + fx.groups() + .map(|g| { + let sel = (g.match_id == CRYPTO).then(|| { + let p = g.get("selection").unwrap(); + (p.uid, p.value.to_json().as_str().unwrap_or_default().to_string()) + }); + (g.match_id.clone(), sel) + }) + .collect() + }) + .unwrap_or_default(); + LayerObs { + id: l.id.0, + name: l.name.clone(), + opacity: op.value.to_json(), + opacity_keys: op.keys.iter().map(|k| (k.time.seconds(), k.value.to_json())).collect(), + position: tr.get("position").unwrap().value.to_json(), + effects, + } + }) + .collect() +} + +fn sel_of(o: &Obs, id: u64) -> String { + o.iter().find(|l| l.id == id).and_then(|l| l.crypto()).map(|c| c.1.clone()).unwrap_or_default() +} + +fn layer_mut(o: &mut Obs, id: u64) -> &mut LayerObs { + o.iter_mut().find(|l| l.id == id).unwrap_or_else(|| panic!("no layer {id}")) +} + +/// The Selection after a pick of token `t` (as `edit_selection` writes it: `, ` separated). +fn model_pick(sel: &str, t: &str, mode: &str) -> String { + let mut v: Vec = sel.split(", ").filter(|s| !s.is_empty()).map(str::to_string).collect(); + match mode { + "replace" => v = vec![t.to_string()], + "add" => { + if !v.iter().any(|x| x == t) { + v.push(t.to_string()) + } + } + _ => v.retain(|x| x != t), + } + v.join(", ") +} + +fn mode_of(m: Modifiers) -> &'static str { + if m.alt || m.ctrl || m.command { + "remove" + } else if m.shift { + "add" + } else { + "replace" + } +} + +struct Model { + comp: u64, + /// The working line: (label of the step that made it, expected state); `cur` is current. + line: Vec<(String, Obs)>, + cur: usize, + time: f64, + /// (layer, Selection uid) the eyedropper is armed on. + armed: Option<(u64, u64)>, + branches: usize, + /// States per branch (the history bounds them by the undo levels). + branch_states: Vec, + /// Every distinct undo label seen, with the step that made it. + labels: Vec<(String, String)>, + /// Undo / redo steps after which the probe had to be built again, of those checked. + rebuilds: (usize, usize), + checks: usize, +} + +impl Model { + fn new(h: &Harness<'_, EffectcraftApp>, comp: u64) -> Model { + let s = &h.state().session; + Model { + comp, + line: vec![("Original".into(), observe(s, comp))], + cur: 0, + time: s.time_of(effectcraft_engine::project::ItemId(comp)).seconds(), + armed: None, + branches: 0, + branch_states: vec![], + labels: vec![], + rebuilds: (0, 0), + checks: 0, + } + } + fn state(&self) -> &Obs { + &self.line[self.cur].1 + } + /// A step that records: the redo line becomes a branch. + fn record(&mut self, label: &str, o: Obs, what: &str) { + if self.cur + 1 < self.line.len() { + self.branches += 1; + self.branch_states.push(self.line.len() - 1 - self.cur); + // The oldest branches go beyond the undo levels (no branch here grows from another). + while self.branch_states.iter().sum::() > LEVELS as usize { + self.branch_states.remove(0); + self.branches -= 1; + } + } + self.line.truncate(self.cur + 1); + self.line.push((label.to_string(), o)); + self.cur += 1; + assert!(self.cur <= LEVELS as usize, "the model doesn't trim the working line"); + if !self.labels.iter().any(|(l, _)| l == label) { + self.labels.push((label.to_string(), what.to_string())); + } + } + /// Undo or redo moved to another state: the pick ends when its effect is gone there. + fn moved(&mut self) { + if let Some((l, uid)) = self.armed + && !self.state().iter().any(|x| x.id == l && x.crypto().is_some_and(|c| c.0 == uid)) + { + self.armed = None; + } + } +} + +/// The comp point of layer point `p` (no scale, rotation or anchor change in these tests). +fn comp_point(o: &Obs, layer: u64, p: [f64; 2], size: [f64; 2]) -> [f32; 2] { + let l = o.iter().find(|l| l.id == layer).unwrap(); + let pos = &l.position; + let (dx, dy) = (pos[0].as_f64().unwrap() - size[0] / 2.0, pos[1].as_f64().unwrap() - size[1] / 2.0); + [(p[0] + dx) as f32, (p[1] + dy) as f32] +} + +/// Where the checks hover on each Cryptomatte layer (layer point) and the ID they must name. +fn probe_point(f: &Fix, layer: u64) -> ([f64; 2], &'static str) { + if layer == f.plate { ([55.5, 43.5], "c1_1") } else { ([20.0, 90.0], "PB") } +} + +/// Every check after a step: state, Edit menu, History panel, time, armed state, chip, highlight. +fn verify(h: &mut Harness<'_, EffectcraftApp>, f: &Fix, m: &mut Model, size: [f64; 2], what: &str) { + m.checks += 1; + // (Two passes: the panels draw what the last edit left, and their rows are current.) + h.run_steps(2); + let got = observe(&h.state().session, m.comp); + assert_eq!(got, *m.state(), "{what}: project state"); + // Edit ▸ Undo / Redo: labels and availability. + let items = effectcraft_ui_egui::menus::menu_items(h.state()); + let item = |id: &str| items.iter().find(|i| i.id == id).map(|i| (i.label.clone(), i.enabled)).unwrap(); + let want_undo = if m.cur > 0 { (format!("Undo {}", m.line[m.cur].0), true) } else { ("Can't Undo".to_string(), false) }; + let want_redo = if m.cur + 1 < m.line.len() { (format!("Redo {}", m.line[m.cur + 1].0), true) } else { ("Can't Redo".to_string(), false) }; + assert_eq!((item("edit.undo"), item("edit.redo")), (want_undo, want_redo), "{what}: Edit menu"); + let s = &h.state().session; + assert_eq!((s.history.undo.len(), s.history.redo.len()), (m.cur, m.line.len() - 1 - m.cur), "{what}: history depth"); + // The History panel: the working line's labels, the current state highlighted and in view. + let tree = s.history_tree(); + let line: Vec<&str> = tree.iter().filter(|n| n.line).map(|n| n.label.as_str()).collect(); + let want: Vec<&str> = m.line.iter().map(|l| l.0.as_str()).collect(); + assert_eq!(line, want, "{what}: History panel line"); + let cur = tree.iter().find(|n| n.current).unwrap(); + assert_eq!(cur.label, m.line[m.cur].0, "{what}: History current"); + assert_eq!(s.history.branches.len(), m.branches, "{what}: History branches"); + let row = h.state().auto.find(&format!("history.state.{}", cur.index)).map(|e| e.label.clone()); + assert_eq!(row.as_deref(), Some(m.line[m.cur].0.as_str()), "{what}: History panel row of the current state"); + // The time is not part of the history. + let t = s.time_of(effectcraft_engine::project::ItemId(m.comp)).seconds(); + assert!((t - m.time).abs() < 1e-9, "{what}: time {t} != {}", m.time); + // The eyedropper: armed where the model says, the chip names what is under the pointer + // (selected or not as the Selection says), and the hovered ID is highlighted. + let pick = h.state().ui.fx_pick.as_ref().map(|p| (p.layer, p.prop)); + assert_eq!(pick, m.armed, "{what}: armed"); + match m.armed { + Some((layer, _)) => { + let (lp, name) = probe_point(f, layer); + let p = comp_point(m.state(), layer, lp, size); + let sp = comp_to_screen(&h.ctx, p).unwrap(); + let sel = m.state().iter().find(|l| l.id == layer).unwrap().crypto().unwrap().1.clone(); + let selected = sel.split(", ").any(|t| t == name); + let want = if selected { format!("{name} (selected)") } else { name.to_string() }; + assert_eq!(hover(h, sp), want, "{what}: chip"); + assert!(highlight(h), "{what}: the hovered ID is highlighted"); + } + None => { + h.step(); + assert_eq!(chip(h), None, "{what}: no chip when disarmed"); + assert!(!highlight(h), "{what}: no highlight when disarmed"); + } + } +} + +/// Undo (`redo` false) or redo with the keyboard, then check against the model. Counts whether +/// the probe was built again for the step. +fn step_history(h: &mut Harness<'_, EffectcraftApp>, f: &Fix, m: &mut Model, size: [f64; 2], redo: bool, what: &str) { + let can = if redo { m.cur + 1 < m.line.len() } else { m.cur > 0 }; + let builds = h.state().session.crypto_probe_stats().1; + // A step that changed only Selections (a pick or a typed Selection). + let only_sel = can && { + let other = if redo { m.cur + 1 } else { m.cur - 1 }; + let strip = |o: &Obs| { + o.iter() + .map(|l| LayerObs { effects: l.effects.iter().map(|e| (e.0.clone(), e.1.as_ref().map(|c| (c.0, String::new())))).collect(), ..l.clone() }) + .collect::>() + }; + strip(&m.line[other].1) == strip(m.state()) + }; + if redo { + key(h, Key::Z, CTRL_SHIFT); + } else { + key(h, Key::Z, CTRL); + } + if can { + if redo { + m.cur += 1; + } else { + m.cur -= 1; + } + } + m.moved(); + let armed = m.armed.is_some(); + verify(h, f, m, size, what); + if armed && only_sel { + m.rebuilds.1 += 1; + if h.state().session.crypto_probe_stats().1 > builds { + m.rebuilds.0 += 1; + } + } +} + +/// One forward step of the long sequence. +enum Step { + /// Click at a layer point of the armed layer: the token it names and whether it is an edit. + Pick([f64; 2], Modifiers, &'static str), + /// Click where the armed layer has no ID: no step. + PickEmpty, + Arm(u64), + Esc, + Opacity(u64, f64, Option<&'static str>), + Position(u64, [f64; 2]), + Type(u64, &'static str), + OpacityKey(u64, f64, f64), + Time(f64), + RemoveCrypto(u64), + ApplyCrypto(u64), + DeleteLayer(u64), + AddPrecompLayer, + /// The plate's blur (before the Cryptomatte effect): drops the probe, slow to rebuild. + Blur(u64, f64), +} + +fn run_step(h: &mut Harness<'_, EffectcraftApp>, f: &Fix, m: &mut Model, size: [f64; 2], step: &Step, n: usize) { + let mut o = m.state().clone(); + let before = h.state().session.history.undo.len(); + let (what, records): (String, bool) = match step { + Step::Pick(lp, mods, token) => { + let (layer, _) = m.armed.expect("armed"); + let p = comp_point(&o, layer, *lp, size); + click(h, comp_to_screen(&h.ctx, p).unwrap(), *mods); + let c = layer_mut(&mut o, layer).crypto_mut(); + let next = model_pick(&c.1, token, mode_of(*mods)); + let changed = next != c.1; + c.1 = next; + (format!("#{n} pick {token} {}", mode_of(*mods)), changed) + } + Step::PickEmpty => { + let (layer, _) = m.armed.expect("armed"); + // Just off the layer's left or right edge, inside the viewer. + let area = h.state().auto.find("viewer.fxPick").unwrap().rect; + let inside = |p: Pos2| p.x > area[0] + 2.0 && p.x < area[0] + area[2] - 2.0 && p.y > area[1] + 40.0 && p.y < area[1] + area[3] - 2.0; + let sp = [[-6.0, 120.0], [size[0] + 6.0, 120.0], [100.0, size[1] + 6.0]] + .into_iter() + .map(|lp| comp_to_screen(&h.ctx, comp_point(&o, layer, lp, size)).unwrap()) + .find(|p| inside(*p)) + .expect("an empty point in the viewer"); + assert_eq!(hover(h, sp), HINT); + click(h, sp, Modifiers::NONE); + (format!("#{n} click off the IDs"), false) + } + Step::Arm(layer) => { + let uid = arm(h, f, *layer); + m.armed = Some((*layer, uid)); + (format!("#{n} arm {layer}"), false) + } + Step::Esc => { + key(h, Key::Escape, Modifiers::NONE); + m.armed = None; + (format!("#{n} Esc"), false) + } + Step::Opacity(layer, v, merge) => { + let mut q = json!({"comp": f.comp, "layer": layer, "path": "transform/opacity", "value": v}); + if let Some(k) = merge { + q["merge"] = json!(k); + } + h.state_mut().session.execute_checked("prop.set", q).unwrap(); + layer_mut(&mut o, *layer).opacity = json!(v); + (format!("#{n} opacity {layer} {v} merge {merge:?}"), true) + } + Step::Position(layer, v) => { + let l = layer_mut(&mut o, *layer); + let mut p = l.position.clone(); + (p[0], p[1]) = (json!(v[0]), json!(v[1])); + h.state_mut().session.execute_checked("prop.set", json!({"comp": f.comp, "layer": layer, "path": "transform/position", "value": p})).unwrap(); + l.position = p; + (format!("#{n} position {layer} {v:?}"), true) + } + Step::Type(layer, text) => { + type_selection(h, f, *layer, text, true); + layer_mut(&mut o, *layer).crypto_mut().1 = text.to_string(); + // Typing selects the layer; the pick (if any) stays where it was. + (format!("#{n} type {text:?}"), true) + } + Step::OpacityKey(layer, t, v) => { + h.state_mut() + .session + .execute_checked("prop.addKey", json!({"comp": f.comp, "layer": layer, "path": "transform/opacity", "time": t, "value": v})) + .unwrap(); + layer_mut(&mut o, *layer).opacity_keys.push((*t, json!(v))); + (format!("#{n} opacity key {layer} {t}s {v}"), true) + } + Step::Time(t) => { + h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(*t)); + m.time = *t; + settle(h); + (format!("#{n} time {t}"), false) + } + Step::RemoveCrypto(layer) => { + let uid = o.iter().find(|l| l.id == *layer).unwrap().effects.iter().position(|e| e.0 == CRYPTO).unwrap(); + h.state_mut().session.execute_checked("effect.remove", json!({"comp": f.comp, "layer": layer, "effect": uid + 1})).unwrap(); + layer_mut(&mut o, *layer).effects.retain(|e| e.0 != CRYPTO); + if m.armed.is_some_and(|a| a.0 == *layer) { + m.armed = None; + } + (format!("#{n} remove Cryptomatte from {layer}"), true) + } + Step::ApplyCrypto(layer) => { + h.state_mut().session.execute_checked("effect.apply", json!({"comp": f.comp, "layers": [layer], "effect": "Cryptomatte"})).unwrap(); + let uid = sel_uid(&h.state().session, f.comp, *layer).unwrap(); + layer_mut(&mut o, *layer).effects.push((CRYPTO.into(), Some((uid, String::new())))); + (format!("#{n} apply Cryptomatte to {layer}"), true) + } + Step::DeleteLayer(layer) => { + h.state_mut().session.execute_checked("edit.clear", json!({"comp": f.comp, "layers": [layer]})).unwrap(); + o.retain(|l| l.id != *layer); + (format!("#{n} delete layer {layer}"), true) + } + Step::Blur(layer, v) => { + h.state_mut().session.execute_checked("prop.set", json!({"comp": f.comp, "layer": layer, "path": "effects/#1/blurriness", "value": v})).unwrap(); + (format!("#{n} blur {v}"), true) + } + Step::AddPrecompLayer => { + let r = h.state_mut().session.execute_checked("layer.addItem", json!({"comp": f.comp, "item": f.inner})).unwrap(); + let id = r["layer"].as_u64().unwrap(); + let now = observe(&h.state().session, f.comp); + let new = now.iter().find(|l| l.id == id).unwrap().clone(); + assert!(new.name.starts_with("Inner") && new.opacity == json!(100.0) && new.effects.is_empty(), "a fresh precomp layer: {new:?}"); + let at = now.iter().position(|l| l.id == id).unwrap(); + o.insert(at, new); + (format!("#{n} add precomp layer {id}"), true) + } + }; + h.run_steps(2); + let after = h.state().session.history.undo.len(); + if records { + assert_eq!(after, before + 1, "{what}: exactly one undo step"); + let label = h.state().session.history.undo.last().unwrap().0.clone(); + m.record(&label, o, &what); + } else { + assert_eq!(after, before, "{what}: no undo step"); + assert_eq!(m.state(), &o, "{what}: no change"); + } + verify(h, f, m, size, &what); +} + +fn long_sequence(tag: &str, size: egui::Vec2, res: Resolution) { + let (w, hh) = (320usize, 180usize); + let dims = [w as f64, hh as f64]; + let (mut s, f) = session(tag, w, hh, false, true); + let cid = effectcraft_engine::project::ItemId(f.comp); + s.set_resolution(cid, res).unwrap(); + let mut h = harness(s, size); + let mut m = Model::new(&h, f.comp); + verify(&mut h, &f, &mut m, dims, "start"); + let ctr = |cx: usize, cy: usize| [cx as f64 * 37.0 + 18.5, cy as f64 * 29.0 + 14.5]; + let (pl, pre, other) = (f.plate, f.pre, f.other); + use Step::*; + let steps = vec![ + Arm(pl), + Pick(ctr(1, 1), Modifiers::NONE, "c1_1"), + Opacity(pl, 80.0, Some("scrub-a")), + Pick(ctr(3, 3), Modifiers::SHIFT, "c3_3"), + Opacity(pl, 70.0, Some("scrub-a")), + Position(pl, [170.0, 95.0]), + Pick(ctr(1, 1), Modifiers::ALT, "c1_1"), + Opacity(other, 50.0, None), + Pick(ctr(3, 3), Modifiers::COMMAND, "c3_3"), + PickEmpty, + Pick(ctr(5, 2), Modifiers::NONE, "c5_2"), + Pick(ctr(5, 2), Modifiers::SHIFT, "c5_2"), + Pick(ctr(0, 0), Modifiers::ALT, "c0_0"), + Type(pl, "c2_2, c5_2"), + Pick(ctr(4, 4), Modifiers::SHIFT, "c4_4"), + Time(1.0), + OpacityKey(pl, 1.0, 60.0), + Pick(ctr(6, 1), Modifiers::SHIFT, "c6_1"), + Time(0.5), + RemoveCrypto(pl), + Opacity(other, 40.0, None), + ApplyCrypto(pl), + Arm(pl), + Pick(ctr(7, 3), Modifiers::NONE, "c7_3"), + Arm(pre), + Pick([20.0, 90.0], Modifiers::NONE, "PB"), + Pick([200.0, 90.0], Modifiers::SHIFT, "PA"), + Opacity(pre, 30.0, None), + Pick([20.0, 90.0], Modifiers::ALT, "PB"), + DeleteLayer(other), + Pick([20.0, 90.0], Modifiers::NONE, "PB"), + AddPrecompLayer, + Arm(pl), + Pick(ctr(8, 4), Modifiers::SHIFT, "c8_4"), + ]; + for (n, st) in steps.iter().enumerate() { + run_step(&mut h, &f, &mut m, dims, st, n); + } + let total = m.cur; + assert!(total >= 20, "{total} undo steps"); + // Undo all the way with Ctrl+Z, and once more (nothing left). + for k in 0..=total { + step_history(&mut h, &f, &mut m, dims, false, &format!("undo {k}")); + } + assert_eq!(m.cur, 0); + // Redo all the way with Ctrl+Shift+Z, and once more. + for k in 0..=total { + step_history(&mut h, &f, &mut m, dims, true, &format!("redo {k}")); + } + assert_eq!(m.cur, total); + // Ctrl+Y is New Solid (After Effects' binding), not Redo: it opens the Solid Settings dialog + // and changes nothing; Esc closes the dialog. + for _ in 0..3 { + step_history(&mut h, &f, &mut m, dims, false, "undo before Ctrl+Y"); + } + let armed = m.armed; + key(&mut h, Key::Y, CTRL); + assert!(h.state().dialog.is_some(), "Ctrl+Y opens the New Solid dialog"); + assert_eq!(h.state().session.history.redo.len(), 3, "Ctrl+Y doesn't redo"); + key(&mut h, Key::Escape, Modifiers::NONE); + assert!(h.state().dialog.is_none()); + if armed.is_some() && h.state().ui.fx_pick.is_none() { + eprintln!("FINDING {tag}: Esc that closed the New Solid dialog also disarmed the eyedropper"); + m.armed = None; + } + verify(&mut h, &f, &mut m, dims, "after Ctrl+Y and Esc"); + // Undo partway, then a new pick: no redo left, the undone steps kept as a branch. + for k in 0..4 { + step_history(&mut h, &f, &mut m, dims, false, &format!("partway {k}")); + } + if m.armed.is_none() { + let l = if m.state().iter().find(|l| l.id == pl).and_then(|l| l.crypto()).is_some() { pl } else { pre }; + run_step(&mut h, &f, &mut m, dims, &Arm(l), 100); + } + let (layer, _) = m.armed.unwrap(); + let (lp, name) = probe_point(&f, layer); + let sel = m.state().iter().find(|l| l.id == layer).unwrap().crypto().unwrap().1.clone(); + let mods = if sel.split(", ").any(|t| t == name) { Modifiers::ALT } else { Modifiers::SHIFT }; + let branches = m.branches; + run_step(&mut h, &f, &mut m, dims, &Pick(lp, mods, name), 101); + assert_eq!((m.branches, m.cur + 1 == m.line.len()), (branches + 1, true), "the redo line became a branch"); + // Random undo / redo (fixed seed), with an occasional new pick or re-arm. + let mut seed: u64 = 0x5eed_c0ff_ee12_3457; + let mut rnd = || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + for i in 0..400 { + let r = rnd() % 20; + if r < 9 { + step_history(&mut h, &f, &mut m, dims, false, &format!("random {i} undo")); + } else if r < 18 { + step_history(&mut h, &f, &mut m, dims, true, &format!("random {i} redo")); + } else { + match m.armed { + None => { + let l = [pl, pre].into_iter().find(|l| m.state().iter().find(|x| x.id == *l).is_some_and(|x| x.crypto().is_some())); + if let Some(l) = l { + run_step(&mut h, &f, &mut m, dims, &Arm(l), 200 + i); + } + } + Some((layer, _)) => { + let (lp, name) = probe_point(&f, layer); + let sel = m.state().iter().find(|l| l.id == layer).unwrap().crypto().unwrap().1.clone(); + let mods = if sel.split(", ").any(|t| t == name) { Modifiers::ALT } else { Modifiers::SHIFT }; + run_step(&mut h, &f, &mut m, dims, &Pick(lp, mods, name), 200 + i); + } + } + } + } + eprintln!( + "{tag}: {} checks; {} history states on the line, {} branches; probe rebuilt after {} of {} armed undo/redo steps that changed only a Selection", + m.checks, + m.line.len(), + m.branches, + m.rebuilds.0, + m.rebuilds.1 + ); + for (l, w) in &m.labels { + eprintln!("{tag}: LABEL {l:?} (first from {w})"); + } + let _ = std::fs::remove_dir_all(&f.dir); +} + +/// Scenario 2 at Full and Third, in a large and a small window. +#[test] +fn long_sequence_full_large_window() { + long_sequence("full-l", vec2(1400.0, 860.0), Resolution::Full); +} + +#[test] +fn long_sequence_third_large_window() { + long_sequence("third-l", vec2(1400.0, 860.0), Resolution::Third); +} + +#[test] +fn long_sequence_full_small_window() { + long_sequence("full-s", vec2(1024.0, 700.0), Resolution::Full); +} + +#[test] +fn long_sequence_third_small_window() { + long_sequence("third-s", vec2(1024.0, 700.0), Resolution::Third); +} + +// --------------------------------------------------------------------------------------------- +// Scenario 1: each pick kind is one step; clicks off the IDs and picks that change nothing are +// none; clicks queued while reading are one step each, undone in order. + +#[test] +fn each_pick_is_one_step_and_clicks_that_change_nothing_are_none() { + let (s, f) = session("kinds", 320, 180, false, false); + let dims = [320.0, 180.0]; + let mut h = harness(s, vec2(1400.0, 860.0)); + let mut m = Model::new(&h, f.comp); + let ctr = |cx: usize, cy: usize| [cx as f64 * 37.0 + 18.5, cy as f64 * 29.0 + 14.5]; + let pl = f.plate; + let ctrl_only = Modifiers { ctrl: true, ..Modifiers::NONE }; + use Step::*; + let steps = [ + Arm(pl), + Pick(ctr(1, 1), Modifiers::NONE, "c1_1"), // replace + Pick(ctr(3, 3), Modifiers::SHIFT, "c3_3"), // add + Pick(ctr(3, 3), Modifiers::SHIFT, "c3_3"), // add, already selected: no step + Pick(ctr(1, 1), Modifiers::ALT, "c1_1"), // remove + Pick(ctr(1, 1), Modifiers::ALT, "c1_1"), // remove, not selected: no step + Pick(ctr(4, 2), Modifiers::SHIFT, "c4_2"), // add + Pick(ctr(4, 2), Modifiers::COMMAND, "c4_2"), // Ctrl (as `command`) removes + Pick(ctr(5, 5), Modifiers::SHIFT, "c5_5"), // add + Pick(ctr(5, 5), ctrl_only, "c5_5"), // Ctrl alone removes too + Pick(ctr(3, 3), Modifiers::NONE, "c3_3"), // replace with what it already is: no step + PickEmpty, // off the layer: no step + Pick(ctr(2, 1), Modifiers::NONE, "c2_1"), // replace + ]; + for (n, st) in steps.iter().enumerate() { + run_step(&mut h, &f, &mut m, dims, st, n); + } + assert_eq!(m.cur, 8, "eight picks changed the Selection"); + for k in 0..9 { + step_history(&mut h, &f, &mut m, dims, false, &format!("undo {k}")); + } + for k in 0..9 { + step_history(&mut h, &f, &mut m, dims, true, &format!("redo {k}")); + } + let _ = std::fs::remove_dir_all(&f.dir); +} + +/// Clicks made while the IDs are read (a 4K plate behind a blur) are one undo step each, in the +/// order clicked; Ctrl+Z takes them back one at a time and Ctrl+Shift+Z makes them again. +#[test] +fn clicks_queued_while_reading_are_one_step_each_and_undo_in_order() { + let (s, f) = session("queue", 3840, 2160, true, false); + let dims = [3840.0, 2160.0]; + let mut h = harness(s, vec2(1280.0, 860.0)); + let mut m = Model::new(&h, f.comp); + let pl = f.plate; + run_step(&mut h, &f, &mut m, dims, &Step::Arm(pl), 0); + run_step(&mut h, &f, &mut m, dims, &Step::Blur(pl, 41.0), 1); + let (a, b) = ([1017.5, 594.5], [1868.5, 1174.5]); + let sp = |h: &Harness<'_, EffectcraftApp>, m: &Model, p: [f64; 2]| comp_to_screen(&h.ctx, comp_point(m.state(), pl, p, dims)).unwrap(); + // The verify above built the probe; drop it again. + run_step(&mut h, &f, &mut m, dims, &Step::Blur(pl, 42.0), 2); + let pa = sp(&h, &m, a); + h.state_mut().session.execute_checked("prop.set", json!({"comp": f.comp, "layer": pl, "path": "effects/#1/blurriness", "value": 43.0})).unwrap(); + let label = h.state().session.history.undo.last().unwrap().0.clone(); + let o = m.state().clone(); + m.record(&label, o, "blur 43"); + h.event(Event::PointerMoved(pa)); + h.run_steps(2); + assert_eq!(chip(&h).as_deref(), Some(READING)); + let before = h.state().session.history.undo.len(); + let clicks = [(a, Modifiers::NONE, "c27_20"), (b, Modifiers::SHIFT, "c50_40"), (a, Modifiers::ALT, "c27_20"), (a, Modifiers::SHIFT, "c27_20")]; + for (p, mods, _) in clicks { + let q = sp(&h, &m, p); + press(&mut h, q, mods); + } + assert_eq!(chip(&h).as_deref(), Some(READING), "all four made while reading"); + assert_eq!(h.state().session.history.undo.len(), before, "none made yet"); + wait_probe(&mut h); + h.run_steps(3); + assert_eq!(h.state().session.history.undo.len(), before + 4, "one step per click"); + // The model: each click on the state the previous one left. + for (k, (_, mods, t)) in clicks.iter().enumerate() { + let mut o = m.state().clone(); + let c = layer_mut(&mut o, pl).crypto_mut(); + c.1 = model_pick(&c.1, t, mode_of(*mods)); + let label = h.state().session.history.undo[before + k].0.clone(); + m.record(&label, o, &format!("queued click {k}")); + } + verify(&mut h, &f, &mut m, dims, "after the queued clicks"); + for k in 0..5 { + step_history(&mut h, &f, &mut m, dims, false, &format!("undo {k}")); + } + for k in 0..5 { + step_history(&mut h, &f, &mut m, dims, true, &format!("redo {k}")); + } + let _ = std::fs::remove_dir_all(&f.dir); +} + +// --------------------------------------------------------------------------------------------- +// Scenario 3: undo / redo while armed, while reading, and with clicks waiting. + +/// Ctrl+Z or Ctrl+Shift+Z without waiting for anything; the model follows. +fn undo_now(h: &mut Harness<'_, EffectcraftApp>, m: &mut Model, redo: bool) { + if redo { + key(h, Key::Z, CTRL_SHIFT); + m.cur += 1; + } else { + key(h, Key::Z, CTRL); + m.cur -= 1; + } + m.moved(); +} + +fn blur_value(h: &Harness<'_, EffectcraftApp>, f: &Fix) -> Value { + let s = &h.state().session; + let l = s.project.comp(effectcraft_engine::project::ItemId(f.comp)).unwrap().layer(LayerId(f.plate)).unwrap(); + l.effects().unwrap().groups().next().unwrap().get("blurriness").unwrap().value.to_json() +} + +#[test] +fn undo_and_redo_while_reading_and_with_clicks_waiting() { + let (s, f) = session("reading", 3840, 2160, true, false); + let dims = [3840.0, 2160.0]; + let mut h = harness(s, vec2(1280.0, 860.0)); + let mut m = Model::new(&h, f.comp); + let pl = f.plate; + let (a, b) = ([1017.5, 594.5], [1868.5, 1174.5]); + let sp = |h: &Harness<'_, EffectcraftApp>, m: &Model, p: [f64; 2]| comp_to_screen(&h.ctx, comp_point(m.state(), pl, p, dims)).unwrap(); + use Step::*; + for (n, st) in [Arm(pl), Pick(a, Modifiers::NONE, "c27_20"), Pick(b, Modifiers::SHIFT, "c50_40")].iter().enumerate() { + run_step(&mut h, &f, &mut m, dims, st, n); + } + let blur = |h: &mut Harness<'_, EffectcraftApp>, m: &mut Model, v: f64| { + h.state_mut().session.execute_checked("prop.set", json!({"comp": f.comp, "layer": pl, "path": "effects/#1/blurriness", "value": v})).unwrap(); + let label = h.state().session.history.undo.last().unwrap().0.clone(); + let o = m.state().clone(); + m.record(&label, o, &format!("blur {v}")); + }; + + // (a) Undo a pick while armed: the probe goes with the edit, the chip says it is reading, + // then names the right IDs with the right "(selected)" for the state undone to. + let pb = sp(&h, &m, b); + assert_eq!(hover(&mut h, pb), "c50_40 (selected)"); + let builds = h.state().session.crypto_probe_stats().1; + undo_now(&mut h, &mut m, false); + let right_after = chip(&h); + eprintln!("chip right after undoing a pick: {right_after:?}"); + assert_eq!(hover(&mut h, pb), "c50_40", "after undo: not selected any more"); + let pa = sp(&h, &m, a); + assert_eq!(hover(&mut h, pa), "c27_20 (selected)"); + assert!(highlight(&h)); + let rebuilt = h.state().session.crypto_probe_stats().1 - builds; + eprintln!("probes built for undoing one pick: {rebuilt}"); + verify(&mut h, &f, &mut m, dims, "(a) undo a pick while armed"); + + // (b) Ctrl+Z while the IDs are being read (a build in flight for the state undone). + blur(&mut h, &mut m, 41.0); + h.event(Event::PointerMoved(pa)); + h.run_steps(2); + assert_eq!(chip(&h).as_deref(), Some(READING)); + undo_now(&mut h, &mut m, false); + assert_eq!(blur_value(&h, &f), json!(40.0)); + verify(&mut h, &f, &mut m, dims, "(b) undo while reading"); + assert_eq!(hover(&mut h, pb), "c50_40", "the probe answers for the state undone to"); + // ... and Ctrl+Shift+Z while reading. + h.state_mut().session.drop_crypto_probe(); + h.event(Event::PointerMoved(pa)); + h.run_steps(2); + undo_now(&mut h, &mut m, true); + assert_eq!(blur_value(&h, &f), json!(41.0)); + verify(&mut h, &f, &mut m, dims, "(b) redo while reading"); + + // (c) A click waiting for the probe, then Ctrl+Z: the undo goes first and the waiting click + // is not made on the state undone to (it was aimed at the state before the undo). + blur(&mut h, &mut m, 42.0); + h.event(Event::PointerMoved(pb)); + h.run_steps(2); + assert_eq!(chip(&h).as_deref(), Some(READING)); + press(&mut h, pb, Modifiers::SHIFT); + assert_eq!(chip(&h).as_deref(), Some(READING), "the click waits"); + h.state_mut().ui.status.clear(); + undo_now(&mut h, &mut m, false); + // (Asked at once: a status message goes after 5 s, which is 20 steps here.) + assert!(h.state().ui.status.starts_with("Pick not made"), "the dropped click says so: {:?}", h.state().ui.status); + wait_probe(&mut h); + h.run_steps(3); + verify(&mut h, &f, &mut m, dims, "(c) waiting click, then Ctrl+Z: the click is not made"); + + // (d) Ctrl+Z, then a click while the probe is rebuilt: the click is made on the state undone + // to, as one step, and the redo line becomes a branch. + undo_now(&mut h, &mut m, false); // the blur 41 + undo_now(&mut h, &mut m, false); // the pick of c27_20: the Selection is now empty + assert_eq!(sel_of(m.state(), pl), "", "{:?}", m.state()); + let pb = sp(&h, &m, b); + h.event(Event::PointerMoved(pb)); + h.run_steps(2); + // (The blur's render is in the layer cache: the rebuild can be done already.) + press(&mut h, pb, Modifiers::SHIFT); + wait_probe(&mut h); + h.run_steps(3); + let mut o = m.state().clone(); + layer_mut(&mut o, pl).crypto_mut().1 = "c50_40".into(); + let label = h.state().session.history.undo.last().unwrap().0.clone(); + m.record(&label, o, "(d) click after undo"); + verify(&mut h, &f, &mut m, dims, "(d) Ctrl+Z, then a click while reading"); + + // (e) Ctrl+Z, a click waiting, then Ctrl+Shift+Z: the click is not made. + blur(&mut h, &mut m, 43.0); + verify(&mut h, &f, &mut m, dims, "(e) blur 43"); + // (Undo goes back to a render the layer cache keeps: forget it, so the probe takes a while.) + h.state().session.layer_cache.clear(); + undo_now(&mut h, &mut m, false); + let pa = sp(&h, &m, a); + h.event(Event::PointerMoved(pa)); + h.run_steps(2); + assert_eq!(chip(&h).as_deref(), Some(READING)); + press(&mut h, pa, Modifiers::SHIFT); + undo_now(&mut h, &mut m, true); + wait_probe(&mut h); + h.run_steps(3); + verify(&mut h, &f, &mut m, dims, "(e) waiting click, then Ctrl+Shift+Z: the click is not made"); + + // (f) A click waiting, Ctrl+Z, another click: only the second is made, on the undone state. + blur(&mut h, &mut m, 44.0); + h.event(Event::PointerMoved(pa)); + h.run_steps(2); + assert_eq!(chip(&h).as_deref(), Some(READING)); + press(&mut h, pa, Modifiers::ALT); + undo_now(&mut h, &mut m, false); + press(&mut h, pa, Modifiers::SHIFT); + wait_probe(&mut h); + h.run_steps(3); + let mut o = m.state().clone(); + let c = layer_mut(&mut o, pl).crypto_mut(); + c.1 = model_pick(&c.1, "c27_20", "add"); + let label = h.state().session.history.undo.last().unwrap().0.clone(); + m.record(&label, o, "(f) second click"); + verify(&mut h, &f, &mut m, dims, "(f) click, Ctrl+Z, click: only the second is made"); + for k in 0..m.cur { + step_history(&mut h, &f, &mut m, dims, false, &format!("undo all {k}")); + } + let _ = std::fs::remove_dir_all(&f.dir); +} + +// --------------------------------------------------------------------------------------------- +// Scenario 4: Esc mid-sequence; undo across Revert, Open and New. + +#[test] +fn esc_mid_sequence_and_undo_across_revert_open_and_new() { + let (s, f) = session("reload", 320, 180, false, false); + let dims = [320.0, 180.0]; + let mut h = harness(s, vec2(1400.0, 860.0)); + let mut m = Model::new(&h, f.comp); + let ctr = |cx: usize, cy: usize| [cx as f64 * 37.0 + 18.5, cy as f64 * 29.0 + 14.5]; + let pl = f.plate; + use Step::*; + let steps = [Arm(pl), Pick(ctr(1, 1), Modifiers::NONE, "c1_1"), Opacity(f.other, 50.0, None), Pick(ctr(3, 3), Modifiers::SHIFT, "c3_3"), Esc]; + for (n, st) in steps.iter().enumerate() { + run_step(&mut h, &f, &mut m, dims, st, n); + } + // Disarmed: undo and redo still walk the picks, and don't arm it again. + for k in 0..3 { + step_history(&mut h, &f, &mut m, dims, false, &format!("undo after Esc {k}")); + } + for k in 0..3 { + step_history(&mut h, &f, &mut m, dims, true, &format!("redo after Esc {k}")); + } + // Esc while armed with a pick just made: one Esc, no step, no undo. + run_step(&mut h, &f, &mut m, dims, &Arm(pl), 10); + run_step(&mut h, &f, &mut m, dims, &Pick(ctr(2, 2), Modifiers::SHIFT, "c2_2"), 11); + run_step(&mut h, &f, &mut m, dims, &Esc, 12); + // Saved, then one more pick, then Revert: the saved state, no history, still a sane pick. + let path = f.dir.join("saved.ecproj"); + h.state_mut().session.execute("file.save", json!({"path": path.to_string_lossy()})).unwrap(); + let saved = m.state().clone(); + run_step(&mut h, &f, &mut m, dims, &Arm(pl), 13); + run_step(&mut h, &f, &mut m, dims, &Pick(ctr(4, 4), Modifiers::SHIFT, "c4_4"), 14); + let pick = h.state().ui.fx_pick.clone(); + h.state_mut().session.execute("file.revert", json!({})).unwrap(); + h.run_steps(3); + let armed_after_revert = h.state().ui.fx_pick.clone(); + eprintln!("armed before Revert {pick:?}, after {armed_after_revert:?}; chip {:?}", chip(&h)); + m.line = vec![("Original".into(), saved.clone())]; + (m.cur, m.branches) = (0, 0); + m.branch_states.clear(); + m.armed = armed_after_revert.as_ref().map(|p| (p.layer, p.prop)); + verify(&mut h, &f, &mut m, dims, "after Revert"); + step_history(&mut h, &f, &mut m, dims, false, "Ctrl+Z after Revert: nothing"); + if m.armed.is_none() { + run_step(&mut h, &f, &mut m, dims, &Arm(pl), 15); + } + run_step(&mut h, &f, &mut m, dims, &Pick(ctr(5, 1), Modifiers::SHIFT, "c5_1"), 16); + step_history(&mut h, &f, &mut m, dims, false, "undo the pick after Revert"); + step_history(&mut h, &f, &mut m, dims, true, "redo the pick after Revert"); + // New Project: the pick ends, nothing to undo. + h.state_mut().session.execute("file.newProject", json!({})).unwrap(); + h.run_steps(3); + eprintln!("after New Project: armed {:?}, chip {:?}, status {:?}", h.state().ui.fx_pick, chip(&h), h.state().ui.status); + assert!(chip(&h).is_none(), "no viewer, no chip"); + let items = effectcraft_ui_egui::menus::menu_items(h.state()); + let undo = items.iter().find(|i| i.id == "edit.undo").unwrap(); + assert_eq!((undo.label.as_str(), undo.enabled), ("Can't Undo", false)); + // Open the saved project: its state, no history; arm and pick as usual. + h.state_mut().session.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + settle(&mut h); + m.line = vec![("Original".into(), saved)]; + (m.cur, m.branches, m.armed) = (0, 0, None); + // (Still armed from before New, on the same ids: see `opening_a_project_ends_the_pick`.) + if let Some(p) = h.state().ui.fx_pick.as_ref() { + eprintln!("after Open: still armed {p:?}, chip {:?}", chip(&h)); + m.armed = Some((p.layer, p.prop)); + } + m.branch_states.clear(); + m.time = h.state().session.time_of(effectcraft_engine::project::ItemId(f.comp)).seconds(); + verify(&mut h, &f, &mut m, dims, "after Open"); + if m.armed.is_none() { + run_step(&mut h, &f, &mut m, dims, &Arm(pl), 20); + } + run_step(&mut h, &f, &mut m, dims, &Pick(ctr(6, 2), Modifiers::NONE, "c6_2"), 21); + step_history(&mut h, &f, &mut m, dims, false, "undo after Open"); + step_history(&mut h, &f, &mut m, dims, false, "undo past Open: nothing"); + step_history(&mut h, &f, &mut m, dims, true, "redo after Open"); + let _ = std::fs::remove_dir_all(&f.dir); +} + +// --------------------------------------------------------------------------------------------- +// Scenario 5: grouping and labels. + +#[test] +fn picks_never_merge_with_neighbouring_edits() { + let (s, f) = session("group", 320, 180, false, false); + let dims = [320.0, 180.0]; + let mut h = harness(s, vec2(1024.0, 700.0)); + let mut m = Model::new(&h, f.comp); + let ctr = |cx: usize, cy: usize| [cx as f64 * 37.0 + 18.5, cy as f64 * 29.0 + 14.5]; + let pl = f.plate; + use Step::*; + run_step(&mut h, &f, &mut m, dims, &Arm(pl), 0); + // A scrub of the plate's opacity (one merged step), a pick, the same scrub going on: three. + run_step(&mut h, &f, &mut m, dims, &Opacity(pl, 90.0, Some("scrub-1")), 1); + let n = h.state().session.history.undo.len(); + h.state_mut() + .session + .execute_checked("prop.set", json!({"comp": f.comp, "layer": pl, "path": "transform/opacity", "value": 85.0, "merge": "scrub-1"})) + .unwrap(); + assert_eq!(h.state().session.history.undo.len(), n, "a scrub merges"); + layer_mut(&mut m.line.last_mut().unwrap().1, pl).opacity = json!(85.0); + run_step(&mut h, &f, &mut m, dims, &Pick(ctr(1, 1), Modifiers::NONE, "c1_1"), 2); + run_step(&mut h, &f, &mut m, dims, &Opacity(pl, 80.0, Some("scrub-1")), 3); + // A pick next to a typed Selection. + run_step(&mut h, &f, &mut m, dims, &Type(pl, "c2_2"), 4); + run_step(&mut h, &f, &mut m, dims, &Pick(ctr(3, 3), Modifiers::SHIFT, "c3_3"), 5); + run_step(&mut h, &f, &mut m, dims, &Type(pl, "c2_2, c3_3, c4_4"), 6); + // Two picks in a row. + run_step(&mut h, &f, &mut m, dims, &Pick(ctr(4, 4), Modifiers::ALT, "c4_4"), 7); + run_step(&mut h, &f, &mut m, dims, &Pick(ctr(5, 5), Modifiers::SHIFT, "c5_5"), 8); + for k in 0..m.cur { + step_history(&mut h, &f, &mut m, dims, false, &format!("undo {k}")); + } + for (l, w) in &m.labels { + eprintln!("LABEL {l:?} (first from {w})"); + } + let _ = std::fs::remove_dir_all(&f.dir); +} + +/// FINDING 4. Typing in the Selection field (not committed yet) and then clicking an ID in the +/// viewer: the pick is made first, on the Selection before the typing, and then the field loses +/// the focus and commits the typed text over it. The pick is lost, yet it left an undo step (to a +/// state never shown). Expected: the typed text, then the pick on top, one step each. +#[test] +fn a_click_while_the_selection_field_is_being_edited() { + let (s, f) = session("typing", 320, 180, false, false); + let mut h = harness(s, vec2(1400.0, 860.0)); + let pl = f.plate; + arm(&mut h, &f, pl); + let a = comp_to_screen(&h.ctx, [37.0 + 18.5, 29.0 + 14.5]).unwrap(); + assert_eq!(hover(&mut h, a), "c1_1"); + let n = h.state().session.history.undo.len(); + type_selection(&mut h, &f, pl, "c2_2", false); + assert!(h.ctx.memory(|m| m.focused().is_some()), "the field is being edited"); + // Ctrl+Z in the field is the field's own: no global undo. + key(&mut h, Key::Z, CTRL); + assert_eq!(h.state().session.history.undo.len(), n, "Ctrl+Z while typing doesn't undo the project"); + type_selection(&mut h, &f, pl, "c2_2", false); + click(&mut h, a, Modifiers::SHIFT); + h.run_steps(3); + let sel = sel_of(&observe(&h.state().session, f.comp), pl); + let steps: Vec = h.state().session.history.undo[n..].iter().map(|u| u.0.clone()).collect(); + // The state between the two steps (what one Ctrl+Z goes back to). + let mid = h.state().session.history.undo.last().map(|u| { + let c = u.1.comp(effectcraft_engine::project::ItemId(f.comp)).unwrap().layer(LayerId(pl)).unwrap(); + c.effects().unwrap().groups().next().unwrap().get("selection").unwrap().value.to_json() + }); + eprintln!("the state one Ctrl+Z goes back to: Selection {mid:?}"); + eprintln!("typed c2_2 (uncommitted) + Shift-click c1_1: Selection {sel:?}, steps {steps:?}"); + assert_eq!((sel.as_str(), steps.len()), ("c2_2, c1_1", 2), "the typed text, then the pick on top, one step each"); + key(&mut h, Key::Z, CTRL); + assert_eq!(sel_of(&observe(&h.state().session, f.comp), pl), "c2_2", "undo takes back the pick"); + key(&mut h, Key::Z, CTRL); + assert_eq!(sel_of(&observe(&h.state().session, f.comp), pl), "", "then the typed text"); + let _ = std::fs::remove_dir_all(&f.dir); +} + +/// On an animated Selection a pick sets (or adds) the key at the current time: one step, and +/// undo takes the key back. +#[test] +fn a_pick_on_an_animated_selection_is_one_step() { + let (s, f) = session("anim", 320, 180, false, false); + let mut h = harness(s, vec2(1400.0, 860.0)); + let pl = f.plate; + let uid = arm(&mut h, &f, pl); + let keys = |h: &Harness<'_, EffectcraftApp>| { + let s = &h.state().session; + let l = s.project.comp(effectcraft_engine::project::ItemId(f.comp)).unwrap().layer(LayerId(pl)).unwrap(); + l.props.find(uid).unwrap().keys.iter().map(|k| (k.time.seconds(), k.value.to_json())).collect::>() + }; + h.state_mut().session.execute_checked("prop.toggleAnimation", json!({"comp": f.comp, "layer": pl, "prop": uid, "value": true})).unwrap(); + h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(1.0)); + settle(&mut h); + let n = h.state().session.history.undo.len(); + let before = keys(&h); + let a = comp_to_screen(&h.ctx, [37.0 + 18.5, 29.0 + 14.5]).unwrap(); + assert_eq!(hover(&mut h, a), "c1_1"); + click(&mut h, a, Modifiers::NONE); + assert_eq!(h.state().session.history.undo.len(), n + 1); + assert_eq!(keys(&h), [before.clone(), vec![(1.0, json!("c1_1"))]].concat(), "a key at 1 s"); + key(&mut h, Key::Z, CTRL); + assert_eq!(keys(&h), before, "undo takes the key back"); + assert_eq!(hover(&mut h, a), "c1_1"); + key(&mut h, Key::Z, CTRL_SHIFT); + assert_eq!(hover(&mut h, a), "c1_1 (selected)"); + let _ = std::fs::remove_dir_all(&f.dir); +} + +// --------------------------------------------------------------------------------------------- +// Findings: minimal failing tests (fixes proposed in research/crypick-undo-fixes.patch). + +/// FINDING 1. Undoing (or redoing) a pick changes only the Selection, which the probe doesn't +/// read, yet the probe is dropped and built again: on a 3D precomp that is ~0.6 s of "Reading +/// Cryptomatte IDs…" after every Ctrl+Z of a pick (the pick itself keeps it). +#[test] +fn undoing_or_redoing_a_pick_keeps_the_probe() { + let (mut s, f) = session("keep", 320, 180, false, false); + let q = json!({"comp": f.comp, "layer": f.plate, "effect": 1, "x": 55.5, "y": 43.5, "probeOnly": true}); + s.crypto_probe_job(&q).unwrap().expect("nothing built yet")(); + let pick = json!({"comp": f.comp, "layer": f.plate, "effect": 1, "x": 55.5, "y": 43.5}); + s.execute_checked("effect.pickCryptomatte", pick).unwrap(); + assert!(s.crypto_probe_job(&q).unwrap().is_none(), "the pick keeps the probe"); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert!(s.crypto_probe_job(&q).unwrap().is_none(), "so does its undo"); + assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["selected"], false); + s.execute_checked("edit.redo", json!({})).unwrap(); + assert!(s.crypto_probe_job(&q).unwrap().is_none(), "and its redo"); + assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["selected"], true); + // Any other edit still drops it. + s.execute_checked("prop.set", json!({"comp": f.comp, "layer": f.plate, "path": "transform/opacity", "value": 50})).unwrap(); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert!(s.crypto_probe_job(&q).unwrap().is_some(), "an undone opacity change is a new probe"); + let _ = std::fs::remove_dir_all(&f.dir); +} + +/// FINDING 2. The eyedropper stays armed through New Project, Open and Revert: the pick is kept +/// by layer id and property uid only, and a new project reuses them. Rebuilt the same way, the +/// new project's Cryptomatte is picked into by a plain viewer click the user never armed. +#[test] +fn a_new_or_opened_project_ends_the_pick() { + let (s, f) = session("stale", 320, 180, false, false); + let mut h = harness(s, vec2(1400.0, 860.0)); + let path = f.dir.join("plate.exr").to_string_lossy().to_string(); + let saved = f.dir.join("saved.ecproj").to_string_lossy().to_string(); + h.state_mut().session.execute("file.save", json!({"path": saved})).unwrap(); + // Open (the same file, as Revert does). + arm(&mut h, &f, f.plate); + h.state_mut().session.execute("file.open", json!({"path": saved})).unwrap(); + settle(&mut h); + assert!(h.state().ui.fx_pick.is_none(), "Open ends the pick: {:?}", h.state().ui.fx_pick); + // New Project, then the same setup built in it. + h.state_mut().ui.fx_pick = None; + h.run_steps(2); + arm(&mut h, &f, f.plate); + h.state_mut().session.execute("file.newProject", json!({})).unwrap(); + h.run_steps(3); + let g = populate(&mut h.state_mut().session, &path, 320, 180, false, false, f.dir.clone()); + settle(&mut h); + let stale = h.state().ui.fx_pick.clone(); + let a = comp_to_screen(&h.ctx, [55.5, 43.5]).unwrap(); + click(&mut h, a, Modifiers::NONE); + let sel = sel_of(&observe(&h.state().session, g.comp), g.plate); + assert!(stale.is_none() && sel.is_empty(), "New ends the pick: armed {stale:?}; a plain click picked {sel:?} into the new project"); + let _ = std::fs::remove_dir_all(&f.dir); +} + +/// FINDING 3 (wording). A pick's undo step is "Change Property", the same as an opacity change +/// or a typed Selection: Edit ▸ Undo and the History panel can't tell them apart. The command +/// is "Pick Cryptomatte"; its step could say so. +#[test] +fn a_pick_is_labelled_as_a_pick() { + let (s, f) = session("label", 320, 180, false, false); + let mut h = harness(s, vec2(1400.0, 860.0)); + arm(&mut h, &f, f.plate); + let a = comp_to_screen(&h.ctx, [55.5, 43.5]).unwrap(); + click(&mut h, a, Modifiers::NONE); + let items = effectcraft_ui_egui::menus::menu_items(h.state()); + let undo = items.iter().find(|i| i.id == "edit.undo").unwrap(); + assert_eq!(undo.label, "Undo Pick Cryptomatte"); + let _ = std::fs::remove_dir_all(&f.dir); +} + +/// FINDING 5 (from review). A middle-button drag pans the viewer, armed or not: egui hands the +/// press to the eyedropper's overlay (it sensed drags the pass before), so armed it didn't pan. +#[test] +fn a_middle_drag_pans_while_armed() { + let (s, f) = session("pan", 320, 180, false, false); + let mut h = harness(s, vec2(1400.0, 860.0)); + let a = comp_to_screen(&h.ctx, [160.0, 90.0]).unwrap(); + let drag = |h: &mut Harness<'_, EffectcraftApp>| { + let before = h.state().ui.viewer.pan; + h.event(Event::PointerMoved(a)); + h.step(); + h.event(Event::PointerButton { pos: a, button: PointerButton::Middle, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=5 { + h.event(Event::PointerMoved(a + vec2(10.0 * k as f32, 0.0))); + h.step(); + } + h.event(Event::PointerButton { pos: a + vec2(50.0, 0.0), button: PointerButton::Middle, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + let after = h.state().ui.viewer.pan; + h.state_mut().ui.viewer.pan = before; + h.run_steps(2); + after[0] - before[0] + }; + let unarmed = drag(&mut h); + let n = h.state().session.history.undo.len(); + arm(&mut h, &f, f.plate); + let armed = drag(&mut h); + assert_eq!(h.state().session.history.undo.len(), n, "no pick"); + assert!(h.state().ui.fx_pick.is_some(), "still armed"); + assert!((unarmed - 50.0).abs() < 0.01 && (armed - 50.0).abs() < 0.01, "a 50 px middle drag pans 50 px: unarmed {unarmed}, armed {armed}"); + let _ = std::fs::remove_dir_all(&f.dir); +} From 0d1c5c08bfad007ce04d1577769240cb56776877 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= Date: Tue, 3 Nov 2026 07:22:46 +0100 Subject: [PATCH 51/71] Docs: the Cryptomatte Selection is picked in the viewer (#473) effects-parity.md and gaps.md: the Selection is picked in the viewer. control-protocol.md: the viewer.cryptoPick element ids. footage.md: a paragraph on the eyedropper. --- docs/control-protocol.md | 4 +++- docs/effects-parity.md | 2 +- docs/footage.md | 4 ++++ docs/gaps.md | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 60328099e..93bf2979a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -143,7 +143,9 @@ revolutions are `timeline.prop..revolutions`, `effectControls.prop..re `viewer.showSnapshot`, `viewer.fastPreviews` (their popup entries are `viewer.Item.`), `viewer.ruler.top|left|origin`, `viewer.mask..vertex.`, `viewer.shapePath..vertex.`, `viewer.motionPath..[.in|.out]`, `viewer.puppetPin.[.rotate|.scale]`, -`viewer.freeTransform.handle.` and +`viewer.freeTransform.handle.`, `viewer.cryptoPick` (the Cryptomatte eyedropper's hover chip; its +label is the chip's text), `viewer.cryptoPick.error` (the chip when it shows an error; label `whole`, or +`cut` when the message is cut off), `viewer.cryptoPick.highlight` (the hovered ID's highlighted area) and `viewer.regionOfInterest`; the Graph Editor adds `timeline.graph.transformBox[.]`, `timeline.graph.snap` and `timeline.graph.reference`. Use `ui.elements` to see what is on screen. diff --git a/docs/effects-parity.md b/docs/effects-parity.md index bd81a09df..5e76663e5 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -26,7 +26,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Category | Effects | On the GPU | Known differences or bugs | |---|---:|---:|---| -| 3D Channel | 8 | 8 | Cryptomatte selection can't be set in the UI and the GPU path is slow (#473; quoted names with commas fixed); multi-layer EXR decode shared (#474 open: f16 storage and cache sizing remain) | +| 3D Channel | 8 | 8 | Cryptomatte's GPU path is slow (#473; the Selection is picked in the viewer with an eyedropper, quoted names with commas fixed); multi-layer EXR decode shared (#474 open: f16 storage and cache sizing remain) | | Audio | 13 | 0 | CPU by design (applied in the mixdown) | | Blur & Sharpen | 16 | 15 | Radial Blur keeps the layer edges as After Effects 2026 (713 now pixel-identical); Gaussian Blur interaction and output to be measured first ([fidelity/ae-2026-acceptance.md](fidelity/ae-2026-acceptance.md)) | | Channel | 13 | 13 | — | diff --git a/docs/footage.md b/docs/footage.md index f7f1df21b..d1729fe08 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -120,6 +120,10 @@ Nuke's `beauty`) show the beauty pass: the layer named Combined, Beauty, Image, Color, else the first colour layer that isn't a data pass. Cryptomatte, depth, mist, normals, vectors, positions and IDs are never the picture. EXtractoR reads every other layer and channel. +The Cryptomatte effect keys objects by their Cryptomatte ID passes, with the names from the +file's manifest. Pick them with the eyedropper next to its Selection, then click in the +Composition viewer (Shift adds, Alt or Ctrl removes). + ## Replacing and reloading Importing a layered file as a composition refreshes the decoded sources used by its layers, diff --git a/docs/gaps.md b/docs/gaps.md index 016aeff22..b39464dc3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -65,7 +65,7 @@ are grouped by area; the issue tracker has the rest. | 6 | **Linux and Windows interaction bugs** | Open: #488 rename arrows (hardening landed, reporter asked for details), #492 anchor drag, #268 file drops on Wayland (winit), #613 Direct3D 12 compositor. Now closed: #491, #493, #467, #320, #302, #596 | Many reports come from Linux; some Windows GPUs fail to start the compositor | 25–45 h | G2 · [ui-parity.md](ui-parity.md) | | 7 | **Animation presets: 76 against 621** | After Effects ships 621 `.ffx` presets (Behaviors, Backgrounds, Shapes, Synthetics, Text, Transitions…); we ship 76 original presets (8 text-animator; 23 Transitions: wipes, clock and fan reveals, blinds, dissolves, card flips, CC wipes, fades, #757; 23 Backgrounds: drifting gradients, fog, cloud and smoke, plasma and magma, cells, hexagons, scrolling grids and checks, pulse rings, bokeh, light sweep, #1096, driven by expressions so they keep moving, not seamless loops; 22 Behaviors: wiggle, shake, float, orbit, bounce, drift, pendulum, spin, pulse, heartbeat, blink, strobe, flicker on transform and opacity, #1510) and read only our `.ecpreset`. Missing: Shapes, Synthetics, Image and more Text | Presets are how many designers start; tutorials assume them | 15–35 h (original presets; `.ffx` reading is out of clean-room scope) | G8 · [effects-parity.md](effects-parity.md) | | 8 | **Mask and shape bugs** | Open: #1004 mask vertex multi-select, #999 auto-pan, #1042 / #1033 vertex nodes under the bounding box, #959 / #960 shape group toggles and operators. Now closed: #510, #513, #527, #509, #303, #553, #932 (Convert To Bezier Path on a shape layer's parametric path) | Masks are core compositing | 10–20 h | G2 · [target-app-parity.md](target-app-parity.md) | -| 9 | **Effect behaviour bugs** | #580, #463 Curl Noise, #369 Motion Tile output size, #943 Turbulent Displace Evolution, #473 Cryptomatte selection (quoted names with commas fixed, 3b7f1ace; UI and GPU time open), #474 multi-layer EXR (decode shared, faffad3a; f16 storage and cache sizing open). Closed: #526, #514, #533–#536 | Each one breaks a look someone built | 10–20 h | G1/G2 · [effects-parity.md](effects-parity.md) | +| 9 | **Effect behaviour bugs** | #580, #463 Curl Noise, #369 Motion Tile output size, #943 Turbulent Displace Evolution, #473 Cryptomatte selection (quoted names with commas fixed, 3b7f1ace; the viewer eyedropper added; GPU time open), #474 multi-layer EXR (decode shared, faffad3a; f16 storage and cache sizing open). Closed: #526, #514, #533–#536 | Each one breaks a look someone built | 10–20 h | G1/G2 · [effects-parity.md](effects-parity.md) | | 10 | **Colour pipeline differences** | #340 ProRes 4444 tagged bt709 but sRGB-encoded (open); ACES 2.0 / OCIO 2.5 configs (26.5) missing; now closed: #494 32-bpc blend modes, #495 and #750 Working Gamma 2.4, #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: the Han fallback now reaches the installed Chinese faces; open: #316 paragraph alignment inverted (maintainers' tests show Left/Right correct; asked for a project), #417 soft at Fit (mostly fixed by #444), #550 toolbar font options. Closed: #483, #583 | 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); camera aperture (#260); reflections (#261); extruded surface materials beyond Front / Bevel / Side / Back Color (fb5a5e9a; #315 closed); #307 reopened (the viewer's renderer button now shows and switches the renderer, #645); Z position (#489) closed | Advanced 3D is After Effects' growth area since 2023 | 20–35 h | G2 · [ui-parity.md](ui-parity.md) | From 940a57e8656c76f00dd0e3662c5583b00567c9b4 Mon Sep 17 00:00:00 2001 From: JonRC Date: Tue, 3 Nov 2026 04:08:39 -0300 Subject: [PATCH 52/71] 3D Camera Tracker: Effect Controls say how many frames a partial solve solved (#2959) A solve with unsolved frames drew the same block as a full one, so it looked complete. It now gets one more line in the warning colour, "Partial solve: N of M frames solved", under the status line, and the editor grows by that line only then. The line is an automation element, so agents can read it too. --- .../ui-egui/src/panels/camera_tracker_ui.rs | 35 ++++++++++++++++--- .../src/panels/effect_controls/editors.rs | 2 +- crates/ui-egui/tests/it/ui_camera_tracker.rs | 27 ++++++++++++++ 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/panels/camera_tracker_ui.rs b/crates/ui-egui/src/panels/camera_tracker_ui.rs index 80932f490..780a48981 100644 --- a/crates/ui-egui/src/panels/camera_tracker_ui.rs +++ b/crates/ui-egui/src/panels/camera_tracker_ui.rs @@ -9,7 +9,7 @@ //! and Camera, Create Multiple …, Delete Selected Points; Delete removes the selection; //! - the "Analyzing in background (step 1 of 2)" / "Solving camera" banner; //! - the Effect Controls block with Analyze / Cancel / Create Camera, Method Used and Average -//! Error. +//! Error, and how many frames a partial solve solved. //! //! Everything is dispatched to the `camera.*` commands. @@ -425,12 +425,14 @@ pub fn editor( } let params = effectcraft_engine::camera_track::static_params(g); let solve = ct::solve(¶ms); + let partial = solve.as_deref().and_then(partial_frames); + let dy = if partial.is_some() { PARTIAL_LINE } else { 0.0 }; if solve.is_some() { if widgets::text_button(ui, b3, crate::i18n::tr("Create Camera"), false, &t, egui::Id::unique(("ct-camera", g.uid))).clicked() { actions.push(("camera.create".into(), me.clone())); } app.auto.add(&format!("effectControls.cameraTracker.{}.createCamera", g.uid), b3, "Create Camera"); - let export = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 70.0), vec2(218.0, 22.0)); + let export = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 70.0 + dy), vec2(218.0, 22.0)); if widgets::text_button(ui, export, tr("Export Scene to Blender..."), false, &t, egui::Id::unique(("ct-blender", g.uid))).clicked() && !busy { actions.push(("camera.exportBlender".into(), me.clone())); } @@ -449,10 +451,19 @@ pub fn editor( }, }; p.text(pos2(r.min.x + 24.0, r.min.y + 38.0), Align2::LEFT_CENTER, status, Tokens::ui(11.5), t.text_dim); + if let Some((solved, frames)) = partial { + let text = tr_args("Partial solve: {} of {} frames solved", &[&solved, &frames]); + p.text(pos2(r.min.x + 24.0, r.min.y + 56.0), Align2::LEFT_CENTER, &text, Tokens::ui(11.5), t.warning); + app.auto.add( + &format!("effectControls.cameraTracker.{}.partialSolve", g.uid), + Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 48.0), vec2(r.width() - 24.0, 16.0)), + &text, + ); + } if let Some(sv) = &solve { let f = sv.focal(); p.text( - pos2(r.min.x + 24.0, r.min.y + 56.0), + pos2(r.min.x + 24.0, r.min.y + 56.0 + dy), Align2::LEFT_CENTER, format!("{} solved points · Horizontal Angle of View {:.1}°", sv.points.len(), sv.hfov(f)), Tokens::ui(11.5), @@ -461,5 +472,19 @@ pub fn editor( } } -/// Height of [`editor`]. -pub const EDITOR_HEIGHT: f32 = 100.0; +/// Height of [`editor`] without the partial-solve line. +const EDITOR_HEIGHT: f32 = 100.0; +const PARTIAL_LINE: f32 = 18.0; + +/// Height of [`editor`] for tracker `g`. +pub fn editor_height(g: &PropGroup) -> f32 { + let partial = ct::solve(&effectcraft_engine::camera_track::static_params(g)).is_some_and(|sv| partial_frames(&sv).is_some()); + if partial { EDITOR_HEIGHT + PARTIAL_LINE } else { EDITOR_HEIGHT } +} + +/// `(solved, all)` frames of a solve with unsolved frames, where the solver interpolates the +/// camera; `None` when every frame is solved. +fn partial_frames(sv: &CameraSolve) -> Option<(usize, usize)> { + let solved = sv.frames.iter().filter(|c| c.solved).count(); + (solved < sv.frames.len()).then_some((solved, sv.frames.len())) +} diff --git a/crates/ui-egui/src/panels/effect_controls/editors.rs b/crates/ui-egui/src/panels/effect_controls/editors.rs index bd566cb7f..bcc0dfd88 100644 --- a/crates/ui-egui/src/panels/effect_controls/editors.rs +++ b/crates/ui-egui/src/panels/effect_controls/editors.rs @@ -114,7 +114,7 @@ pub(super) 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 => crate::panels::camera_tracker_ui::EDITOR_HEIGHT, + effectcraft_engine::effects::camera_tracker::ID => crate::panels::camera_tracker_ui::editor_height(g), 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, diff --git a/crates/ui-egui/tests/it/ui_camera_tracker.rs b/crates/ui-egui/tests/it/ui_camera_tracker.rs index 619d7c7c5..579e92d07 100644 --- a/crates/ui-egui/tests/it/ui_camera_tracker.rs +++ b/crates/ui-egui/tests/it/ui_camera_tracker.rs @@ -202,6 +202,33 @@ fn blender_export_button_opens_python_dialog_and_writes_scene() { assert_eq!(h.state().session.active_comp().unwrap().layers.len(), 1, "export does not create a camera in the source project"); } +/// A solve with unsolved (interpolated) frames says so in Effect Controls, with the count, on a +/// line of its own that pushes the Export button down. +#[test] +fn effect_controls_show_how_many_frames_a_partial_solve_solved() { + let (a, plate, uid) = app(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| a); + h.state_mut().show_panel(PanelKind::EffectControls); + h.run_steps(4); + let line = format!("effectControls.cameraTracker.{uid}.partialSolve"); + let export = format!("effectControls.cameraTracker.{uid}.exportBlender"); + assert!(!ids(&h).contains(&line), "a full solve has no partial-solve line"); + let before = rect(&h, &export); + + let g = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(plate)).unwrap().props.find_group(uid).unwrap(); + let mut solve = ct::solve(&effectcraft_engine::camera_track::static_params(g)).unwrap().as_ref().clone(); + for f in solve.frames.iter_mut().skip(24) { + f.solved = false; + } + let s = &mut h.state_mut().session; + s.execute("prop.set", json!({"layer": plate, "path": "effects/#1/solve", "value": solve.to_json()})).unwrap(); + s.camera_pending.clear(); + h.run_steps(4); + let e = h.state().auto.find(&line).expect("the partial-solve line is drawn"); + assert_eq!(e.label, "Partial solve: 24 of 60 frames solved"); + assert!((rect(&h, &export).min.y - before.min.y - 18.0).abs() < 0.5, "the Export button moves down one line"); +} + /// Drag with `button` from `from` to `to` in steps, then release. fn drag_with(h: &mut Harness<'_, EffectcraftApp>, button: egui::PointerButton, from: egui::Pos2, to: egui::Pos2) { h.input_mut().events.push(egui::Event::PointerMoved(from)); From f8463fcf7e0e85ebaacaedbbcd587c49793a6273 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= Date: Tue, 3 Nov 2026 08:47:46 +0100 Subject: [PATCH 53/71] Cryptomatte eyedropper: Esc that closes a dialog keeps it armed (#473) The Esc that closed New Solid or Composition Settings also disarmed the eyedropper. An open dialog now takes Esc like a focused text field or an open popup, so that Esc only closes the dialog and the next one disarms. long_sequence asserts both instead of logging a finding. --- crates/ui-egui/src/panels/crypto_pick.rs | 14 ++++++++------ crates/ui-egui/tests/it/ui_crypto_pick_undo.rs | 13 ++++++++----- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/panels/crypto_pick.rs b/crates/ui-egui/src/panels/crypto_pick.rs index 56479c476..2310ba93b 100644 --- a/crates/ui-egui/src/panels/crypto_pick.rs +++ b/crates/ui-egui/src/panels/crypto_pick.rs @@ -97,8 +97,9 @@ struct State { /// The waiting clicks took the focus from a text field (the Selection being typed): they /// are made once it has committed, on top of what it committed. after_typing: bool, - /// A text field or a popup had the keyboard the pass before: its Esc isn't the pick's - /// (egui lets go of the focus and closes popups before the viewer runs). + /// A text field, a popup or a dialog had the keyboard the pass before: its Esc isn't the + /// pick's (egui lets go of the focus and closes popups, and Esc closes the dialog, before + /// the viewer runs). esc_taken: bool, /// The press of the click in progress. press: Option, @@ -507,12 +508,13 @@ fn draw_status_chip(app: &mut EffectcraftApp, ctx: &egui::Context, painter: &egu painter.galley(chip.left_center() + vec2(12.0, -galley.size().y / 2.0), galley, colour); } -/// Esc disarms the eyedropper only when no text field or popup had the keyboard this pass or -/// the pass before (egui lets go of the focus and closes popups before the viewer runs). +/// Esc disarms the eyedropper only when no text field, popup, or dialog had the keyboard this +/// pass or the pass before (egui lets go of the focus and closes popups, and Esc closes the +/// dialog, before the viewer runs). fn handle_escape(app: &mut EffectcraftApp, ui: &mut egui::Ui, st: &mut State) { let ctx = ui.ctx(); - // (Esc in a text field, the Selection being typed, only ends the typing; in a menu, it.) - let esc_taken = std::mem::replace(&mut st.esc_taken, ctx.text_edit_focused() || egui::Popup::is_any_open(ctx)); + // (Esc in a text field, the Selection being typed, only ends the typing; in a menu or dialog, it.) + let esc_taken = std::mem::replace(&mut st.esc_taken, ctx.text_edit_focused() || egui::Popup::is_any_open(ctx) || app.dialog.is_some()); if ui.input(|i| i.key_pressed(egui::Key::Escape)) && !esc_taken && !st.esc_taken { app.ui.fx_pick = None; app.ui.status.clear(); diff --git a/crates/ui-egui/tests/it/ui_crypto_pick_undo.rs b/crates/ui-egui/tests/it/ui_crypto_pick_undo.rs index 32635084a..973cd58da 100644 --- a/crates/ui-egui/tests/it/ui_crypto_pick_undo.rs +++ b/crates/ui-egui/tests/it/ui_crypto_pick_undo.rs @@ -697,16 +697,19 @@ fn long_sequence(tag: &str, size: egui::Vec2, res: Resolution) { for _ in 0..3 { step_history(&mut h, &f, &mut m, dims, false, "undo before Ctrl+Y"); } - let armed = m.armed; + if m.armed.is_none() { + let l = if m.state().iter().find(|l| l.id == pl).and_then(|l| l.crypto()).is_some() { pl } else { pre }; + run_step(&mut h, &f, &mut m, dims, &Arm(l), 99); + } key(&mut h, Key::Y, CTRL); assert!(h.state().dialog.is_some(), "Ctrl+Y opens the New Solid dialog"); assert_eq!(h.state().session.history.redo.len(), 3, "Ctrl+Y doesn't redo"); key(&mut h, Key::Escape, Modifiers::NONE); assert!(h.state().dialog.is_none()); - if armed.is_some() && h.state().ui.fx_pick.is_none() { - eprintln!("FINDING {tag}: Esc that closed the New Solid dialog also disarmed the eyedropper"); - m.armed = None; - } + assert!(h.state().ui.fx_pick.is_some(), "Esc that closes the New Solid dialog keeps the eyedropper armed"); + key(&mut h, Key::Escape, Modifiers::NONE); + assert!(h.state().ui.fx_pick.is_none(), "a second Esc disarms"); + m.armed = None; verify(&mut h, &f, &mut m, dims, "after Ctrl+Y and Esc"); // Undo partway, then a new pick: no redo left, the undone steps kept as a branch. for k in 0..4 { From 310c6da745cded6d0806b53706b34ab2b1bfca88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= Date: Tue, 3 Nov 2026 09:36:23 +0100 Subject: [PATCH 54/71] Cryptomatte eyedropper: hold the probe in the queued-clicks undo test (#473) clicks_queued_while_reading_are_one_step_each_and_undo_in_order relied on a blurred 4K plate being slow to read, and failed when the IDs were read while the clicks were still being pressed. The probe's ID read is now held (the Held footage from ui_crypto_pick) until the clicks are checked, and the test asserts that the build reached that read and that nothing was made while it was held. --- crates/ui-egui/tests/it/ui_crypto_pick.rs | 8 +++-- .../ui-egui/tests/it/ui_crypto_pick_undo.rs | 31 +++++++++++++++++-- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/tests/it/ui_crypto_pick.rs b/crates/ui-egui/tests/it/ui_crypto_pick.rs index f3a01aea3..f75f0c8c6 100644 --- a/crates/ui-egui/tests/it/ui_crypto_pick.rs +++ b/crates/ui-egui/tests/it/ui_crypto_pick.rs @@ -1111,8 +1111,12 @@ fn a_frame_step_while_a_click_waits_settles_within_a_few_builds() { /// Footage whose Cryptomatte read on the probe's thread waits while `hold` is set. It logs the /// probe threads' reads: (thread, a picture frame rather than the IDs). -struct Held(std::sync::Arc, std::sync::Arc, ReadLog); -type ReadLog = std::sync::Arc>>; +pub(crate) struct Held( + pub(crate) std::sync::Arc, + pub(crate) std::sync::Arc, + pub(crate) ReadLog, +); +pub(crate) type ReadLog = std::sync::Arc>>; impl Held { fn log(&self, frame: bool) { if std::thread::current().name() == Some("crypto-probe") { diff --git a/crates/ui-egui/tests/it/ui_crypto_pick_undo.rs b/crates/ui-egui/tests/it/ui_crypto_pick_undo.rs index 973cd58da..a83ba29e6 100644 --- a/crates/ui-egui/tests/it/ui_crypto_pick_undo.rs +++ b/crates/ui-egui/tests/it/ui_crypto_pick_undo.rs @@ -6,6 +6,7 @@ //! viewer's chip and highlight, and the eyedropper's armed state are checked. Undo while the IDs //! are being read and with clicks waiting, across Open / Revert / New, and after Esc. +use crate::ui_crypto_pick::{Held, ReadLog}; use effectcraft_engine::Session; use effectcraft_engine::project::{LayerId, Resolution}; use effectcraft_engine::raster::channels3d::{crypto_float, crypto_hash}; @@ -15,6 +16,7 @@ use effectcraft_ui_egui::panels::viewer::comp_to_screen; use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2, vec2}; use egui_kittest::Harness; use serde_json::{Value, json}; +use std::sync::atomic::Ordering; const READING: &str = "Reading Cryptomatte IDs…"; const HINT: &str = "Click: replace · Shift: add · Alt or Ctrl: remove"; @@ -834,12 +836,16 @@ fn each_pick_is_one_step_and_clicks_that_change_nothing_are_none() { let _ = std::fs::remove_dir_all(&f.dir); } -/// Clicks made while the IDs are read (a 4K plate behind a blur) are one undo step each, in the -/// order clicked; Ctrl+Z takes them back one at a time and Ctrl+Shift+Z makes them again. +/// Clicks made while the IDs are read (a 4K plate behind a blur; the build is held until the +/// clicks are checked) are one undo step each, in the order clicked; Ctrl+Z takes them back one +/// at a time and Ctrl+Shift+Z makes them again. #[test] fn clicks_queued_while_reading_are_one_step_each_and_undo_in_order() { - let (s, f) = session("queue", 3840, 2160, true, false); + let (mut s, f) = session("queue", 3840, 2160, true, false); let dims = [3840.0, 2160.0]; + let hold = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let log = ReadLog::default(); + s.footage = std::sync::Arc::new(Held(s.footage.clone(), hold.clone(), log.clone())); let mut h = harness(s, vec2(1280.0, 860.0)); let mut m = Model::new(&h, f.comp); let pl = f.plate; @@ -850,6 +856,8 @@ fn clicks_queued_while_reading_are_one_step_each_and_undo_in_order() { // The verify above built the probe; drop it again. run_step(&mut h, &f, &mut m, dims, &Step::Blur(pl, 42.0), 2); let pa = sp(&h, &m, a); + hold.store(true, Ordering::Release); + log.lock().unwrap().clear(); h.state_mut().session.execute_checked("prop.set", json!({"comp": f.comp, "layer": pl, "path": "effects/#1/blurriness", "value": 43.0})).unwrap(); let label = h.state().session.history.undo.last().unwrap().0.clone(); let o = m.state().clone(); @@ -865,6 +873,23 @@ fn clicks_queued_while_reading_are_one_step_each_and_undo_in_order() { } assert_eq!(chip(&h).as_deref(), Some(READING), "all four made while reading"); assert_eq!(h.state().session.history.undo.len(), before, "none made yet"); + for _ in 0..3000 { + h.step(); + if log.lock().unwrap().iter().any(|(_, frame)| !*frame) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + if !log.lock().unwrap().iter().any(|(_, frame)| !*frame) { + panic!("the held build never reached the ID read"); + } + for _ in 0..10 { + h.step(); + std::thread::sleep(std::time::Duration::from_millis(2)); + } + assert_eq!(chip(&h).as_deref(), Some(READING), "still reading while the build is held"); + assert_eq!(h.state().session.history.undo.len(), before, "none made yet while held"); + hold.store(false, Ordering::Release); wait_probe(&mut h); h.run_steps(3); assert_eq!(h.state().session.history.undo.len(), before + 4, "one step per click"); From 220d85f4fcc36bb90d715084f75bd715b4eb8c05 Mon Sep 17 00:00:00 2001 From: Bar Borer Date: Tue, 3 Nov 2026 11:53:55 +0200 Subject: [PATCH 55/71] Effect Controls: Shift-click selects a range of effects Co-Authored-By: Claude Opus 5.5 --- .../src/panels/effect_controls/panel.rs | 12 +++++-- crates/ui-egui/tests/it/ui_effect_controls.rs | 31 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/effect_controls/panel.rs b/crates/ui-egui/src/panels/effect_controls/panel.rs index 122a6abf7..c17038fdd 100644 --- a/crates/ui-egui/src/panels/effect_controls/panel.rs +++ b/crates/ui-egui/src/panels/effect_controls/panel.rs @@ -268,8 +268,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { hresp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, &shown)); app.auto.add(&format!("effectControls.effect.{}", g.uid), r, &g.name); if hresp.clicked() || hresp.drag_started() || hresp.secondary_clicked() { - let add = ui.input(|i| i.modifiers.shift || i.modifiers.command); - if add && hresp.clicked() { + let (shift, command) = ui.input(|i| (i.modifiers.shift, i.modifiers.command)); + if shift && hresp.clicked() { + // A range from the last effect picked, which stays the end the next range starts from. + let from = app.session.state.selected_props.iter().rev().find_map(|(l, u)| fx.iter().position(|f| *l == layer.id && f.uid == *u)); + let from = from.unwrap_or(fi); + let range = fx.iter().enumerate().filter(|(i, _)| (from.min(fi)..=from.max(fi)).contains(i)); + let mut picked: Vec<_> = range.filter(|(i, _)| *i != from).map(|(_, f)| (layer.id, f.uid)).collect(); + picked.extend(fx.get(from).map(|f| (layer.id, f.uid))); + app.session.state.selected_props = picked; + } else if command && hresp.clicked() { if selected { app.session.state.selected_props.retain(|(l, u)| !(*l == layer.id && *u == g.uid)); } else { diff --git a/crates/ui-egui/tests/it/ui_effect_controls.rs b/crates/ui-egui/tests/it/ui_effect_controls.rs index d6138ba5c..357596681 100644 --- a/crates/ui-egui/tests/it/ui_effect_controls.rs +++ b/crates/ui-egui/tests/it/ui_effect_controls.rs @@ -319,6 +319,37 @@ fn command_clicking_an_effect_twirl_closes_and_opens_its_groups() { assert!(others.iter().all(|u| closed.contains(u)), "other effects stay as they were"); } +/// Shift-clicking effect headers selects the range from the last effect picked, which stays the +/// end the next range starts from; Cmd/Ctrl adds or takes out one effect and moves that end. +#[test] +fn shift_click_on_effect_headers_selects_a_range_and_command_toggles() { + let (app, x) = app(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app); + let fx: Vec = layer(&h.state().session, x.small).effects().unwrap().groups().map(|g| g.uid).collect(); + // All twirled closed, so every header is on screen. + h.state_mut().ui.fx_closed.extend(fx.iter().copied()); + h.run_steps(3); + let pick = |h: &mut Harness<'_, EffectcraftApp>, i: usize, modifiers: egui::Modifiers| { + let at = rect_of(h, &format!("effectControls.effect.{}", fx[i])).center(); + h.event(egui::Event::ModifiersChanged(modifiers)); + click(h, at); + h.event(egui::Event::ModifiersChanged(Default::default())); + h.step(); + let picked = h.state().session.state.selected_props.iter().map(|(_, u)| *u).collect::>(); + fx.iter().enumerate().filter(|(_, u)| picked.contains(u)).map(|(i, _)| i).collect::>() + }; + let (shift, command) = (egui::Modifiers::SHIFT, egui::Modifiers::COMMAND); + assert_eq!(pick(&mut h, 3, shift), [3], "with nothing picked, the range is the effect itself"); + assert_eq!(pick(&mut h, 1, Default::default()), [1]); + assert_eq!(pick(&mut h, 3, shift), [1, 2, 3], "Shift takes in the effects between"); + assert_eq!(pick(&mut h, 0, shift), [0, 1], "the next range starts from the same effect"); + assert_eq!(pick(&mut h, 4, shift), [1, 2, 3, 4]); + assert_eq!(pick(&mut h, 2, command), [1, 3, 4], "Cmd/Ctrl takes one out"); + assert_eq!(pick(&mut h, 5, command), [1, 3, 4, 5], "and adds one"); + assert_eq!(pick(&mut h, 3, shift), [3, 4, 5], "which is where the next range starts"); + assert_eq!(pick(&mut h, 0, Default::default()), [0], "a plain click picks one"); +} + 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])) From d025b59e6d77ae038712bb9431c3a548e5c37f17 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 06:03:05 -0500 Subject: [PATCH 56/71] OCIO config: a mismatched closing bracket ends a flow collection, and multiline flows are assembled in linear time (#2939) --- crates/effects/src/ocio_config.rs | 63 ++++++++++++++++++++----- crates/effects/src/ocio_config/tests.rs | 18 +++++++ 2 files changed, 68 insertions(+), 13 deletions(-) diff --git a/crates/effects/src/ocio_config.rs b/crates/effects/src/ocio_config.rs index af860c2fc..e31281ede 100644 --- a/crates/effects/src/ocio_config.rs +++ b/crates/effects/src/ocio_config.rs @@ -168,19 +168,29 @@ fn split_key(s: &str) -> Option<(String, &str)> { None } -/// Bracket balance of a flow fragment (outside quotes). -fn balance(s: &str) -> i32 { - let (mut sq, mut dq, mut d) = (false, false, 0i32); - for c in s.chars() { - match c { - '\'' if !dq => sq = !sq, - '"' if !sq => dq = !dq, - '{' | '[' if !sq && !dq => d = d.saturating_add(1), - '}' | ']' if !sq && !dq => d = d.saturating_sub(1), - _ => {} +/// Bracket balance of a flow fragment (outside quotes), kept as lines are appended so each +/// character is looked at once however many lines a collection spans. +#[derive(Default)] +struct Balance { + sq: bool, + dq: bool, + d: i32, +} + +impl Balance { + /// Takes in the next piece of the fragment and returns the balance so far. + fn feed(&mut self, s: &str) -> i32 { + for c in s.chars() { + match c { + '\'' if !self.dq => self.sq = !self.sq, + '"' if !self.sq => self.dq = !self.dq, + '{' | '[' if !self.sq && !self.dq => self.d = self.d.saturating_add(1), + '}' | ']' if !self.sq && !self.dq => self.d = self.d.saturating_sub(1), + _ => {} + } } + self.d } - d } struct Flow<'a> { @@ -223,12 +233,19 @@ impl Flow<'_> { self.i += 1; break; } + // A `]` cannot close a mapping: the text is malformed, so the rest is + // skipped (as past the depth limit) instead of read again and again. + Some(b']') => { + self.i = self.s.len(); + break; + } Some(b',') => { self.i += 1; continue; } _ => {} } + let start = self.i; let k = self.scalar(true); self.ws(); if self.peek() == Some(b':') { @@ -236,6 +253,11 @@ impl Flow<'_> { } let v = self.nested(); m.push((unquote(&k), v)); + // Every entry consumes text; one that did not would repeat forever. + if self.i == start { + self.i = self.s.len(); + break; + } } Yaml::Map(m) } @@ -250,11 +272,23 @@ impl Flow<'_> { self.i += 1; break; } + // A `}` cannot close a sequence: skip the malformed rest (see above). + Some(b'}') => { + self.i = self.s.len(); + break; + } Some(b',') => { self.i += 1; continue; } - _ => v.push(self.nested()), + _ => { + let start = self.i; + v.push(self.nested()); + if self.i == start { + self.i = self.s.len(); + break; + } + } } } Yaml::Seq(v) @@ -364,11 +398,14 @@ impl Lines { if rest.starts_with('{') || rest.starts_with('[') || (rest.starts_with('!') && (rest.contains('{') || rest.contains('['))) { // Flow collections may continue on the next lines. let mut text = rest.to_string(); - while balance(&text) > 0 + let mut bal = Balance::default(); + let mut open = bal.feed(&text); + while open > 0 && let Some((_, next)) = self.v.get(*i) { text.push(' '); text.push_str(next); + open = bal.feed(next); *i += 1; } return flow(&text, depth); diff --git a/crates/effects/src/ocio_config/tests.rs b/crates/effects/src/ocio_config/tests.rs index f534fe0e2..b616e6263 100644 --- a/crates/effects/src/ocio_config/tests.rs +++ b/crates/effects/src/ocio_config/tests.rs @@ -94,6 +94,24 @@ fn yaml_nesting_is_bounded() { assert!(Config::parse(&flow, Path::new(".")).is_err()); } +/// A closing bracket of the wrong kind ends the collection instead of being read forever, and a +/// flow collection spread over many lines is assembled in linear time. +#[test] +fn yaml_malformed_flow_is_bounded() { + for text in ["a: {x: ]\nb: 1\n", "a: [x, }\nb: 1\n", "a: [{k: ], 1]\n", "a: {[}: ]}\n"] { + let _ = parse_yaml(text); + } + assert!(matches!(parse_yaml("a: {x: ]\n"), Yaml::Map(_))); + let mut many = String::from("a: [\n"); + for _ in 0..200_000 { + many.push_str(" 1,\n"); + } + many.push_str("]\nb: 2\n"); + let y = parse_yaml(&many); + assert_eq!(y.get("a").map(|a| a.seq().len()), Some(200_000)); + assert_eq!(y.get("b").and_then(Yaml::str), Some("2")); +} + #[test] fn parses_spaces_roles_displays_and_converts() { let dir = temp_dir("basic"); From 3eb7ac7c5eeb8b6b2daa20d63ebb2aa7e2ef3931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= Date: Tue, 3 Nov 2026 12:47:53 +0100 Subject: [PATCH 57/71] Hold the probe build in the remaining crypto-pick reading tests The "while reading" sections of the waiting-click, in-order and undo/redo tests now hold the build with the Held wrapper until their asserts are done, instead of relying on a 4K plate being slow to read. The undo tests use the shared helpers from ui_crypto_pick.rs, so they now go through crypto_exr with k = 1 rather than a copy without it. --- crates/ui-egui/tests/it/ui_crypto_pick.rs | 92 ++++++++++++--- .../ui-egui/tests/it/ui_crypto_pick_undo.rs | 105 ++++++------------ 2 files changed, 109 insertions(+), 88 deletions(-) diff --git a/crates/ui-egui/tests/it/ui_crypto_pick.rs b/crates/ui-egui/tests/it/ui_crypto_pick.rs index f75f0c8c6..0d1767c7e 100644 --- a/crates/ui-egui/tests/it/ui_crypto_pick.rs +++ b/crates/ui-egui/tests/it/ui_crypto_pick.rs @@ -23,13 +23,13 @@ const MAC_HINT: &str = "Click: replace · Shift: add · Option or Cmd: remove"; const PAUSED: &str = "Stop playback to pick"; /// The ID of the 37 × 29 px cell at pixel (x, y) of a full-size frame. -fn cell(x: usize, y: usize) -> String { +pub(crate) fn cell(x: usize, y: usize) -> String { format!("c{}_{}", x / 37, y / 29) } /// A `w` × `h` EXR (white RGBA) whose CryptoObject has an ID per cell of a `k`-times larger frame /// (`k` = 2: a half-size proxy), all named in its manifest. -fn crypto_exr(dir: &std::path::Path, name: &str, w: usize, h: usize, k: usize) -> String { +pub(crate) fn crypto_exr(dir: &std::path::Path, name: &str, w: usize, h: usize, k: usize) -> String { use exr::prelude::*; let ids: Vec = (0..w * h).map(|i| crypto_float(crypto_hash(&cell(i % w * k, i / w * k)))).collect(); let ch = |n: &str, v: Vec| AnyChannel::new(n, FlatSamples::F32(v)); @@ -105,7 +105,7 @@ fn app(s: Session, ec: f32) -> EffectcraftApp { app } -fn settle(h: &mut Harness<'_, EffectcraftApp>) { +pub(crate) fn settle(h: &mut Harness<'_, EffectcraftApp>) { for _ in 0..1500 { h.step(); if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().is_ok_and(|v| *v > 0.0) { @@ -116,12 +116,12 @@ fn settle(h: &mut Harness<'_, EffectcraftApp>) { h.run_steps(4); } -fn chip(h: &Harness<'_, EffectcraftApp>) -> Option { +pub(crate) fn chip(h: &Harness<'_, EffectcraftApp>) -> Option { h.state().auto.find("viewer.cryptoPick").map(|e| e.label.clone()) } /// Step until the chip stops saying the probe is being built (the job runs on its own thread). -fn wait_probe(h: &mut Harness<'_, EffectcraftApp>) { +pub(crate) fn wait_probe(h: &mut Harness<'_, EffectcraftApp>) { for _ in 0..3000 { h.step(); if chip(h).as_deref() != Some(READING) { @@ -153,7 +153,7 @@ fn click(h: &mut Harness<'_, EffectcraftApp>, pos: Pos2, modifiers: Modifiers) { h.run_steps(2); } -fn at(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { +pub(crate) fn at(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")).clone(); pos2(e.rect[0] + e.rect[2] * 0.5, e.rect[1] + e.rect[3] * 0.5) } @@ -443,43 +443,74 @@ fn drain(h: &mut Harness<'_, EffectcraftApp>) { /// Waiting clicks: the first hover starts the probe; a click made meanwhile waits then picks; /// an edit, Esc, disarming, or hiding the viewer while it waits drops it. -fn waiting_clicks_are_dropped_by_edits_esc_disarm_and_hiding_the_viewer(h: &mut Harness<'_, EffectcraftApp>, ids: &Ids, a: Pos2, b: Pos2) { +/// Each section's probe build is held (`hold` / `log`) across its "while reading" / "waits" +/// asserts and the action that drops the click, then released before `wait_probe` / `drain` / +/// `hover` so a held build can finish. +fn waiting_clicks_are_dropped_by_edits_esc_disarm_and_hiding_the_viewer( + h: &mut Harness<'_, EffectcraftApp>, + ids: &Ids, + a: Pos2, + b: Pos2, + hold: &std::sync::atomic::AtomicBool, + log: &ReadLog, +) { + use std::sync::atomic::Ordering; // A click before the probe is built waits for it. // (egui reports the hover a pass after the pointer moves.) + hold.store(true, Ordering::Release); + log.lock().unwrap().clear(); h.event(Event::PointerMoved(a)); h.run_steps(2); + wait_held_id_read(h, log); assert_eq!(chip(h).as_deref(), Some(READING), "the first hover starts the probe off the UI thread"); press(h, a); assert_eq!(selection(h, ids), "", "the click waits"); + hold.store(false, Ordering::Release); wait_probe(h); h.run_steps(2); assert_eq!(selection(h, ids), "c27_20", "then picks"); assert_eq!(hover(h, b), "c50_40"); let steps = h.state().session.history.undo.len(); // An edit while a click waits drops the click. + // edit(41) drops the probe; press starts a new build (held). edit(42) cancels that build and + // drops the click — release so the cancelled build can leave the hold, then wait_probe. edit(h, 41.0); + hold.store(true, Ordering::Release); + log.lock().unwrap().clear(); press(h, b); + wait_held_id_read(h, log); assert_eq!(chip(h).as_deref(), Some(READING)); edit(h, 42.0); + hold.store(false, Ordering::Release); wait_probe(h); h.run_steps(2); assert_eq!(selection(h, ids), "c27_20", "not picked after the edit"); assert_eq!(h.state().session.history.undo.len(), steps + 2, "only the two edits"); - // Esc while a click waits: no pick, disarmed. + // Esc while a click waits: no pick, disarmed. Hold the build started by press; Esc cancels it. edit(h, 43.0); + hold.store(true, Ordering::Release); + log.lock().unwrap().clear(); press(h, b); + wait_held_id_read(h, log); + assert_eq!(chip(h).as_deref(), Some(READING)); h.key_press(egui::Key::Escape); h.run_steps(2); assert!(h.state().ui.fx_pick.is_none() && chip(h).is_none()); + hold.store(false, Ordering::Release); drain(h); assert_eq!(selection(h, ids), "c27_20"); // Disarmed while a click waits, then armed again: the old click is not made. + // arm() waits the probe — hold only after arming, across the READING assert and disarm. edit(h, 44.0); arm(h, ids); + hold.store(true, Ordering::Release); + log.lock().unwrap().clear(); press(h, b); + wait_held_id_read(h, log); assert_eq!(chip(h).as_deref(), Some(READING)); h.state_mut().ui.fx_pick = None; h.run_steps(2); + hold.store(false, Ordering::Release); arm(h, ids); drain(h); assert_eq!(selection(h, ids), "c27_20", "the old click stays unmade"); @@ -487,11 +518,15 @@ fn waiting_clicks_are_dropped_by_edits_esc_disarm_and_hiding_the_viewer(h: &mut // The viewer hidden while a click waits (Effect Controls maximised), then shown again: the // pick still on, the old click is not made either. edit(h, 45.0); + hold.store(true, Ordering::Release); + log.lock().unwrap().clear(); press(h, b); + wait_held_id_read(h, log); assert_eq!(chip(h).as_deref(), Some(READING)); h.state_mut().ui.maximized = Some(PanelKind::EffectControls); h.run_steps(2); assert!(h.state().auto.find("viewer.fxPick").is_none(), "the viewer is hidden"); + hold.store(false, Ordering::Release); drain(h); h.state_mut().ui.maximized = None; h.run_steps(2); @@ -555,17 +590,20 @@ fn popups_dialogs_and_comp_switches_end_or_block_them(h: &mut Harness<'_, Effect assert!(h.state().ui.fx_pick.is_none() && chip(h).is_none(), "ended with the comp switch"); } -/// The asynchronous probe (a 4K plate, so it takes a while): the first hover says it is reading, -/// a click made meanwhile waits and then picks; an edit, Esc or disarming while it waits drops -/// it. Popups and dialogs over the viewer take the pointer. Another selected layer keeps the -/// pick; deleting the layer or switching comps ends it. +/// The asynchronous probe (a 4K plate behind a blur; each "while reading" section holds the +/// build): the first hover says it is reading, a click made meanwhile waits and then picks; an +/// edit, Esc or disarming while it waits drops it. Popups and dialogs over the viewer take the +/// pointer. Another selected layer keeps the pick; deleting the layer or switching comps ends it. #[test] fn clicks_wait_for_the_probe_and_edits_popups_dialogs_and_comp_switches_end_or_block_them() { - let (s, ids) = crypto_session("async", 3840, 2160, true); + let (mut s, ids) = crypto_session("async", 3840, 2160, true); + let hold = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let log = ReadLog::default(); + s.footage = std::sync::Arc::new(Held(s.footage.clone(), hold.clone(), log.clone())); let mut h = harness(s, vec2(1280.0, 800.0), 1.0, 420.0); arm(&mut h, &ids); let (a, b) = (comp_to_screen(&h.ctx, [1017.5, 594.5]).unwrap(), comp_to_screen(&h.ctx, [1868.5, 1174.5]).unwrap()); - waiting_clicks_are_dropped_by_edits_esc_disarm_and_hiding_the_viewer(&mut h, &ids, a, b); + waiting_clicks_are_dropped_by_edits_esc_disarm_and_hiding_the_viewer(&mut h, &ids, a, b, &hold, &log); popups_dialogs_and_comp_switches_end_or_block_them(&mut h, &ids, b); let _ = std::fs::remove_dir_all(&ids.dir); } @@ -654,15 +692,23 @@ fn builds(h: &Harness<'_, EffectcraftApp>) -> u64 { h.state().session.crypto_probe_stats().1 } -/// Clicks made while the IDs are being read are all made, in order, once they are. +/// Clicks made while the IDs are being read (a 4K plate behind a blur; the build is held until +/// the clicks are checked) are all made, in order, once they are. #[test] fn every_click_made_while_reading_is_made_in_order() { - let (s, ids) = crypto_session("multi", 3840, 2160, true); + use std::sync::atomic::Ordering; + let (mut s, ids) = crypto_session("multi", 3840, 2160, true); + let hold = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let log = ReadLog::default(); + s.footage = std::sync::Arc::new(Held(s.footage.clone(), hold.clone(), log.clone())); let mut h = harness(s, vec2(1280.0, 800.0), 1.0, 420.0); arm(&mut h, &ids); let (a, b) = (comp_to_screen(&h.ctx, [1017.5, 594.5]).unwrap(), comp_to_screen(&h.ctx, [1868.5, 1174.5]).unwrap()); + hold.store(true, Ordering::Release); + log.lock().unwrap().clear(); h.event(Event::PointerMoved(a)); h.run_steps(2); + wait_held_id_read(&mut h, &log); assert_eq!(chip(&h).as_deref(), Some(READING)); let steps = h.state().session.history.undo.len(); for (p, m) in [(a, Modifiers::NONE), (b, Modifiers::SHIFT), (a, Modifiers::ALT), (a, Modifiers::SHIFT)] { @@ -678,6 +724,7 @@ fn every_click_made_while_reading_is_made_in_order() { } assert_eq!(chip(&h).as_deref(), Some(READING), "all four made while reading"); assert_eq!(selection(&h, &ids), ""); + hold.store(false, Ordering::Release); wait_probe(&mut h); h.run_steps(3); assert_eq!(selection(&h, &ids), "c50_40, c27_20", "replace, add, remove, add, in order"); @@ -1159,6 +1206,19 @@ impl effectcraft_engine::render::FootageSource for Held { } } +/// Step until a held probe build has reached the ID (`aux`) read, so READING asserts are about a +/// build that is really running. +pub(crate) fn wait_held_id_read(h: &mut Harness<'_, EffectcraftApp>, log: &ReadLog) { + for _ in 0..3000 { + h.step(); + if log.lock().unwrap().iter().any(|(_, frame)| !*frame) { + return; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + panic!("the held build never reached the ID read"); +} + /// Disarmed and armed again while a probe is built: the new hover waits for that build to end /// before it starts its own (one build at a time, so one probe's memory). #[test] diff --git a/crates/ui-egui/tests/it/ui_crypto_pick_undo.rs b/crates/ui-egui/tests/it/ui_crypto_pick_undo.rs index a83ba29e6..22aaeb9a5 100644 --- a/crates/ui-egui/tests/it/ui_crypto_pick_undo.rs +++ b/crates/ui-egui/tests/it/ui_crypto_pick_undo.rs @@ -6,14 +6,13 @@ //! viewer's chip and highlight, and the eyedropper's armed state are checked. Undo while the IDs //! are being read and with clicks waiting, across Open / Revert / New, and after Esc. -use crate::ui_crypto_pick::{Held, ReadLog}; +use crate::ui_crypto_pick::{Held, ReadLog, at, chip, crypto_exr, settle, wait_held_id_read, wait_probe}; use effectcraft_engine::Session; use effectcraft_engine::project::{LayerId, Resolution}; -use effectcraft_engine::raster::channels3d::{crypto_float, crypto_hash}; use effectcraft_ui_egui::EffectcraftApp; use effectcraft_ui_egui::dock::{self, PanelKind, SplitSize}; use effectcraft_ui_egui::panels::viewer::comp_to_screen; -use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui::{Event, Key, Modifiers, PointerButton, Pos2, vec2}; use egui_kittest::Harness; use serde_json::{Value, json}; use std::sync::atomic::Ordering; @@ -30,32 +29,7 @@ const CTRL: Modifiers = Modifiers { alt: false, ctrl: !MAC, shift: false, mac_cm const CTRL_SHIFT: Modifiers = Modifiers { alt: false, ctrl: !MAC, shift: true, mac_cmd: MAC, command: true }; // --------------------------------------------------------------------------------------------- -// Fixture (as in ui_crypto_pick.rs): an EXR whose CryptoObject has an ID per 37 × 29 px cell. - -fn cell(x: usize, y: usize) -> String { - format!("c{}_{}", x / 37, y / 29) -} - -fn crypto_exr(dir: &std::path::Path, name: &str, w: usize, h: usize) -> String { - use exr::prelude::*; - let ids: Vec = (0..w * h).map(|i| crypto_float(crypto_hash(&cell(i % w, i / w)))).collect(); - let ch = |n: &str, v: Vec| AnyChannel::new(n, FlatSamples::F32(v)); - let one = || vec![1.0f32; w * h]; - let channels = AnyChannels::sort( - vec![ch("R", one()), ch("G", one()), ch("B", one()), ch("A", one()), ch("CryptoObject00.R", ids), ch("CryptoObject00.G", one())].into(), - ); - let mut layer = Layer::new((w, h), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels); - let names: serde_json::Map = (0..w.div_ceil(37)) - .flat_map(|x| (0..h.div_ceil(29)).map(move |y| cell(x * 37, y * 29))) - .map(|n| (n.clone(), json!(format!("{:08x}", crypto_hash(&n))))) - .collect(); - for (key, v) in [("name", "CryptoObject".to_string()), ("manifest", Value::Object(names).to_string())] { - layer.attributes.other.insert(Text::from(format!("cryptomatte/f834d0a/{key}").as_str()), AttributeValue::Text(Text::from(v.as_str()))); - } - let path = dir.join(name); - Image::from_layer(layer).write().to_file(&path).unwrap(); - path.to_string_lossy().to_string() -} +// Fixture: session / populate / harness (shared EXR helpers from ui_crypto_pick.rs). struct Fix { comp: u64, @@ -74,7 +48,7 @@ struct Fix { fn session(tag: &str, w: usize, h: usize, blur: bool, precomp: bool) -> (Session, Fix) { let dir = std::env::temp_dir().join(format!("ec-ui-crypick-undo-{tag}-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); - let path = crypto_exr(&dir, "plate.exr", w, h); + let path = crypto_exr(&dir, "plate.exr", w, h, 1); let mut s = effectcraft_host::session(); let f = populate(&mut s, &path, w, h, blur, precomp, dir); (s, f) @@ -125,36 +99,11 @@ fn harness(s: Session, size: egui::Vec2) -> Harness<'static, EffectcraftApp> { h } -fn settle(h: &mut Harness<'_, EffectcraftApp>) { - for _ in 0..1500 { - h.step(); - if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().is_ok_and(|v| *v > 0.0) { - break; - } - std::thread::sleep(std::time::Duration::from_millis(10)); - } - h.run_steps(4); -} - -fn chip(h: &Harness<'_, EffectcraftApp>) -> Option { - h.state().auto.find("viewer.cryptoPick").map(|e| e.label.clone()) -} - fn highlight(h: &Harness<'_, EffectcraftApp>) -> bool { h.state().auto.find("viewer.cryptoPick.highlight").is_some() } -fn wait_probe(h: &mut Harness<'_, EffectcraftApp>) { - for _ in 0..3000 { - h.step(); - if chip(h).as_deref() != Some(READING) { - return; - } - std::thread::sleep(std::time::Duration::from_millis(2)); - } - panic!("the probe never finished"); -} - +/// Like `ui_crypto_pick::hover`, plus an extra step so the chip label is current after the probe. fn hover(h: &mut Harness<'_, EffectcraftApp>, pos: Pos2) -> String { h.event(Event::PointerMoved(pos)); h.step(); @@ -190,11 +139,6 @@ fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) { h.run_steps(2); } -fn at(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { - let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")).clone(); - pos2(e.rect[0] + e.rect[2] * 0.5, e.rect[1] + e.rect[3] * 0.5) -} - /// The Selection property uid of the layer's (first) Cryptomatte effect. fn sel_uid(s: &Session, comp: u64, layer: u64) -> Option { let l = s.project.comp(effectcraft_engine::project::ItemId(comp))?.layer(LayerId(layer))?; @@ -873,16 +817,7 @@ fn clicks_queued_while_reading_are_one_step_each_and_undo_in_order() { } assert_eq!(chip(&h).as_deref(), Some(READING), "all four made while reading"); assert_eq!(h.state().session.history.undo.len(), before, "none made yet"); - for _ in 0..3000 { - h.step(); - if log.lock().unwrap().iter().any(|(_, frame)| !*frame) { - break; - } - std::thread::sleep(std::time::Duration::from_millis(2)); - } - if !log.lock().unwrap().iter().any(|(_, frame)| !*frame) { - panic!("the held build never reached the ID read"); - } + wait_held_id_read(&mut h, &log); for _ in 0..10 { h.step(); std::thread::sleep(std::time::Duration::from_millis(2)); @@ -934,8 +869,11 @@ fn blur_value(h: &Harness<'_, EffectcraftApp>, f: &Fix) -> Value { #[test] fn undo_and_redo_while_reading_and_with_clicks_waiting() { - let (s, f) = session("reading", 3840, 2160, true, false); + let (mut s, f) = session("reading", 3840, 2160, true, false); let dims = [3840.0, 2160.0]; + let hold = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let log = ReadLog::default(); + s.footage = std::sync::Arc::new(Held(s.footage.clone(), hold.clone(), log.clone())); let mut h = harness(s, vec2(1280.0, 860.0)); let mut m = Model::new(&h, f.comp); let pl = f.plate; @@ -970,26 +908,38 @@ fn undo_and_redo_while_reading_and_with_clicks_waiting() { // (b) Ctrl+Z while the IDs are being read (a build in flight for the state undone). blur(&mut h, &mut m, 41.0); + hold.store(true, Ordering::Release); + log.lock().unwrap().clear(); h.event(Event::PointerMoved(pa)); h.run_steps(2); + wait_held_id_read(&mut h, &log); assert_eq!(chip(&h).as_deref(), Some(READING)); undo_now(&mut h, &mut m, false); + hold.store(false, Ordering::Release); assert_eq!(blur_value(&h, &f), json!(40.0)); verify(&mut h, &f, &mut m, dims, "(b) undo while reading"); assert_eq!(hover(&mut h, pb), "c50_40", "the probe answers for the state undone to"); // ... and Ctrl+Shift+Z while reading. + hold.store(true, Ordering::Release); + log.lock().unwrap().clear(); h.state_mut().session.drop_crypto_probe(); h.event(Event::PointerMoved(pa)); h.run_steps(2); + wait_held_id_read(&mut h, &log); + assert_eq!(chip(&h).as_deref(), Some(READING)); undo_now(&mut h, &mut m, true); + hold.store(false, Ordering::Release); assert_eq!(blur_value(&h, &f), json!(41.0)); verify(&mut h, &f, &mut m, dims, "(b) redo while reading"); // (c) A click waiting for the probe, then Ctrl+Z: the undo goes first and the waiting click // is not made on the state undone to (it was aimed at the state before the undo). blur(&mut h, &mut m, 42.0); + hold.store(true, Ordering::Release); + log.lock().unwrap().clear(); h.event(Event::PointerMoved(pb)); h.run_steps(2); + wait_held_id_read(&mut h, &log); assert_eq!(chip(&h).as_deref(), Some(READING)); press(&mut h, pb, Modifiers::SHIFT); assert_eq!(chip(&h).as_deref(), Some(READING), "the click waits"); @@ -997,6 +947,7 @@ fn undo_and_redo_while_reading_and_with_clicks_waiting() { undo_now(&mut h, &mut m, false); // (Asked at once: a status message goes after 5 s, which is 20 steps here.) assert!(h.state().ui.status.starts_with("Pick not made"), "the dropped click says so: {:?}", h.state().ui.status); + hold.store(false, Ordering::Release); wait_probe(&mut h); h.run_steps(3); verify(&mut h, &f, &mut m, dims, "(c) waiting click, then Ctrl+Z: the click is not made"); @@ -1023,26 +974,36 @@ fn undo_and_redo_while_reading_and_with_clicks_waiting() { blur(&mut h, &mut m, 43.0); verify(&mut h, &f, &mut m, dims, "(e) blur 43"); // (Undo goes back to a render the layer cache keeps: forget it, so the probe takes a while.) + // Hold before undo_now: it steps the UI and would otherwise start (and finish) a rebuild. h.state().session.layer_cache.clear(); + hold.store(true, Ordering::Release); + log.lock().unwrap().clear(); undo_now(&mut h, &mut m, false); let pa = sp(&h, &m, a); h.event(Event::PointerMoved(pa)); h.run_steps(2); + wait_held_id_read(&mut h, &log); assert_eq!(chip(&h).as_deref(), Some(READING)); press(&mut h, pa, Modifiers::SHIFT); undo_now(&mut h, &mut m, true); + hold.store(false, Ordering::Release); wait_probe(&mut h); h.run_steps(3); verify(&mut h, &f, &mut m, dims, "(e) waiting click, then Ctrl+Shift+Z: the click is not made"); // (f) A click waiting, Ctrl+Z, another click: only the second is made, on the undone state. + hold.store(true, Ordering::Release); + log.lock().unwrap().clear(); blur(&mut h, &mut m, 44.0); h.event(Event::PointerMoved(pa)); h.run_steps(2); + wait_held_id_read(&mut h, &log); assert_eq!(chip(&h).as_deref(), Some(READING)); press(&mut h, pa, Modifiers::ALT); undo_now(&mut h, &mut m, false); press(&mut h, pa, Modifiers::SHIFT); + assert_eq!(chip(&h).as_deref(), Some(READING), "both clicks made while the build is held"); + hold.store(false, Ordering::Release); wait_probe(&mut h); h.run_steps(3); let mut o = m.state().clone(); From e8e3262a8649d21dd3ff530011f5a79f9f9dfdb3 Mon Sep 17 00:00:00 2001 From: zitann2020 Date: Tue, 3 Nov 2026 22:14:22 +0900 Subject: [PATCH 58/71] =?UTF-8?q?i18n(ja):=20the=20work=20area=20is=20?= =?UTF-8?q?=E3=83=AF=E3=83=BC=E3=82=AF=E3=82=A8=E3=83=AA=E3=82=A2=20in=20t?= =?UTF-8?q?he=20panel=20too?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel catalog said 作業領域 for Work Area while the four menu rows say ワークエリア. The installed After Effects 2026 settles it: Edit > Preferences > Previews reads キャッシュ範囲: ワークエリアと現在の時間. The row draws the Content-Aware Fill panel range menu and the Time Span form, so a reader met both words for one thing depending on where they looked. One row, no other change. Co-Authored-By: DeepSeek V4.1 Flash --- crates/ui-egui/src/i18n/ui.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index dd00b4123..2ed6b2c07 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -1034,7 +1034,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Feet + Frames (35mm)", "フィート + フレーム (35mm)"), ("Feet + Frames (16mm)", "フィート + フレーム (16mm)"), ("Current Frame", "現在のフレーム"), - ("Work Area", "作業領域"), + ("Work Area", "ワークエリア"), ("Create Markers", "マーカーを作成"), ("Split Layers", "レイヤーを分割"), ("Split and Precompose", "分割してプリコンポーズ"), From a5e0da657e4ac31eda3e8d9f29cba361db753f25 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 13:58:54 +0000 Subject: [PATCH 59/71] Pre-compose: layers left behind drop links to moved layers, default names are numbered Unselected layers parented to, or track-matted by, a layer that moved into the new composition kept ids that no longer exist in the outer comp. The command's default name was always "Pre-comp 1"; it now picks the lowest unused number. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01RDvxCS8xcR28VArjzn1x7U --- crates/engine/src/cmd/layer/timing.rs | 19 ++++++++++++++++- crates/engine/src/tests/menu_cmds.rs | 30 +++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/layer/timing.rs b/crates/engine/src/cmd/layer/timing.rs index 650544d17..15a6628d4 100644 --- a/crates/engine/src/cmd/layer/timing.rs +++ b/crates/engine/src/cmd/layer/timing.rs @@ -201,6 +201,11 @@ pub(super) fn arrange(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// "Pre-comp N" with the lowest N no item already uses. +fn unused_precomp_name(proj: &effectcraft_project::Project) -> String { + (1u32..).map(|n| format!("Pre-comp {n}")).find(|n| !proj.items.values().any(|i| &i.name == n)).unwrap_or_else(|| "Pre-comp".to_string()) +} + /// Layer ▸ Pre-compose. `mode: move` (default; "Move all attributes into the new composition") /// nests the selected layers as they are; `mode: leave` ("Leave all attributes in", one footage, /// solid or precomp layer) nests only the layer's source, sized to it, and keeps the layer's @@ -212,7 +217,10 @@ pub(super) fn precompose(s: &mut Session, p: &Value) -> Result { if ids.is_empty() { return Err(bad(c, "no layers")); } - let name = str_p(p, "name").unwrap_or("Pre-comp 1").to_string(); + let name = match str_p(p, "name") { + Some(n) => n.to_string(), + None => unused_precomp_name(&s.project), + }; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let leave = match str_p(p, "mode").unwrap_or("move") { "move" => false, @@ -280,6 +288,15 @@ pub(super) fn precompose(s: &mut Session, p: &Value) -> Result { let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let at = c.layers.iter().position(|l| ids.contains(&l.id)).unwrap_or(0); c.layers.retain(|l| !ids.contains(&l.id)); + // Layers left behind must not keep pointing at layers that moved into the new comp. + for l in &mut c.layers { + if l.parent.is_some_and(|p| ids.contains(&p)) { + l.parent = None; + } + if l.track_matte.as_ref().is_some_and(|m| ids.contains(&m.layer)) { + l.track_matte = None; + } + } let lid = l.id; c.layers.insert(at.min(c.layers.len()), l); st.selected_layers = vec![lid]; diff --git a/crates/engine/src/tests/menu_cmds.rs b/crates/engine/src/tests/menu_cmds.rs index d30fc156a..6698cbd0b 100644 --- a/crates/engine/src/tests/menu_cmds.rs +++ b/crates/engine/src/tests/menu_cmds.rs @@ -2035,3 +2035,33 @@ fn pasting_a_mask_onto_its_own_layer_drops_the_source_vertex_selection() { s.execute("edit.clear", json!({})).unwrap(); assert_eq!(layer(&s, a).masks().unwrap().groups().count(), 1, "the pasted mask goes, the source stays"); } + +#[test] +fn precompose_unlinks_layers_left_behind_and_numbers_default_names() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "frameRate": 30, "duration": 4})).unwrap(); + let mk = |s: &mut Session, name: &str| { + s.execute("layer.newSolid", json!({"name": name, "color": "#fff", "width": 64, "height": 64})).unwrap()["layer"].as_u64().unwrap() + }; + let (a, b, c, d) = (mk(&mut s, "A"), mk(&mut s, "B"), mk(&mut s, "C"), mk(&mut s, "D")); + s.execute("layer.setParent", json!({"layers": [c], "parent": a})).unwrap(); + s.execute("layer.setTrackMatte", json!({"layers": [d], "matte": b, "kind": "alpha"})).unwrap(); + let cid = s.active_comp_id().unwrap(); + s.execute("layer.precompose", json!({"layers": [a, b]})).unwrap(); + let comp = s.project.comp(cid).unwrap(); + assert_eq!(comp.layers.len(), 3); + for l in &comp.layers { + assert!(l.parent.is_none_or(|p| comp.layer(p).is_some()), "{} has a dangling parent", l.name); + assert!(l.track_matte.as_ref().is_none_or(|m| comp.layer(m.layer).is_some()), "{} has a dangling matte", l.name); + } + s.execute("layer.precompose", json!({"layers": [c, d]})).unwrap(); + let mut names: Vec = s + .project + .items + .values() + .filter(|i| matches!(i.kind, effectcraft_project::ItemKind::Comp(_)) && i.name.starts_with("Pre-comp")) + .map(|i| i.name.clone()) + .collect(); + names.sort(); + assert_eq!(names, ["Pre-comp 1", "Pre-comp 2"]); +} From 26228247e298dc22f0eb270ab17106cd11b28033 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 3 Nov 2026 09:30:22 -0500 Subject: [PATCH 60/71] Follow-ups to the batch: name numbering keeps no double space (#2908), Material Options error names the parameters (#2924), corrupt file timecodes stay on timecode (#2835), restore #2934's scroll-gutter asserts, #2825's Motion Tile test compiles Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/stylize2.rs | 4 ++-- crates/engine/src/cmd/model3d.rs | 2 +- crates/interchange/src/import.rs | 7 ++++++- crates/project/src/item.rs | 2 +- crates/ui-egui/tests/it/ui_scrolling.rs | 4 ++-- 5 files changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/effects/src/stylize2.rs b/crates/effects/src/stylize2.rs index d1020b1bc..94eda6370 100644 --- a/crates/effects/src/stylize2.rs +++ b/crates/effects/src/stylize2.rs @@ -827,8 +827,8 @@ mod tests { 0.0, ); assert_eq!((out.img.width, out.img.height), (10, 8)); - for (x, mirrored) in [(2u32, 0u32), (7, 9)] { - for y in 0..out.img.height { + for (x, mirrored) in [(2i64, 0i64), (7, 9)] { + for y in 0..i64::from(out.img.height) { let a = out.img.get(x, y); assert!((a[3] - 1.0).abs() < 1e-5, "fold x={x},y={y} faded to alpha {}", a[3]); let b = img.get(mirrored, y); diff --git a/crates/engine/src/cmd/model3d.rs b/crates/engine/src/cmd/model3d.rs index f4e62f6b7..37a70db00 100644 --- a/crates/engine/src/cmd/model3d.rs +++ b/crates/engine/src/cmd/model3d.rs @@ -245,7 +245,7 @@ fn apply_material(l: &mut Layer, p: &Value, t: Tick, cmd: &str) -> Result, missing: &[&str]) -> EngineError { let names = missing.iter().map(|k| format!("`{k}`")).collect::>().join(", "); let Some(l) = l else { return bad("material.set", format!("no layer has {names}")) }; - let Some(g) = l.props.sub("materialOptions") else { return bad("material.set", format!("`{}` has no Material Options", l.name)) }; + let Some(g) = l.props.sub("materialOptions") else { return bad("material.set", format!("`{}` has no Material Options, so it has no {names}", l.name)) }; let has: Vec<&str> = g.children.iter().map(|c| c.match_id()).collect(); let why = if matches!(l.source, LayerSource::Model { .. }) { ": a 3D model layer keeps its model file's materials" } else { "" }; bad( diff --git a/crates/interchange/src/import.rs b/crates/interchange/src/import.rs index 6831103a7..b7095ae1f 100644 --- a/crates/interchange/src/import.rs +++ b/crates/interchange/src/import.rs @@ -185,6 +185,9 @@ pub fn import( const EDL_MEDIA_TIME_NOTE: &str = "EDL source timecodes were imported as media time"; /// An EDL's source times are its source timecodes, which FilmCraft's reader keeps as media time. +/// The largest start timecode, in frames, a file may have: a day at 1000 fps. +const MAX_FILE_TIMECODE: i64 = 24 * 3600 * 1000; + /// Put the clips of each file that has a start timecode on the file's own time, as FilmCraft does /// when it links the media of an imported EDL. Returns the names of the media that stay on /// timecode though their clips use an hour or more of it (the file is missing or has none). @@ -212,7 +215,9 @@ fn rebase_to_file_timecode(project: &mut fp::Project, probe: &mut dyn FnMut(&str let mut left = Vec::new(); for (id, name, path, offline) in files { match if offline { None } else { probe(&path) } { - Some(Footage { file_timecode: Some(tc), native_rate, frame_rate, .. }) => { + // A corrupt timecode past a day of frames (at up to 1000 fps) would overflow the tick + // arithmetic; such a file stays on timecode. + Some(Footage { file_timecode: Some(tc), native_rate, frame_rate, .. }) if (0..=MAX_FILE_TIMECODE).contains(&tc) => { filmcraft_interchange::rebase_source_timecode(project, id, tc, crate::rate_out(native_rate.unwrap_or(frame_rate))); } _ if on_timecode.contains(&id) => left.push(name), diff --git a/crates/project/src/item.rs b/crates/project/src/item.rs index 047f1ef3f..ae66557a4 100644 --- a/crates/project/src/item.rs +++ b/crates/project/src/item.rs @@ -12,7 +12,7 @@ pub(crate) fn numbered_name(base: &str, taken: impl Fn(&str) -> bool) -> String let head = base.trim_end_matches(|c: char| c.is_ascii_digit()); let stem = match head.strip_suffix(' ') { Some(stem) if head.len() < base.len() && !stem.trim_end().is_empty() => stem.trim_end(), - _ => base, + _ => base.trim_end(), }; (2u64..).map(|k| format!("{stem} {k}")).find(|n| !taken(n)).unwrap_or_default() } diff --git a/crates/ui-egui/tests/it/ui_scrolling.rs b/crates/ui-egui/tests/it/ui_scrolling.rs index 001eb4503..2913a6770 100644 --- a/crates/ui-egui/tests/it/ui_scrolling.rs +++ b/crates/ui-egui/tests/it/ui_scrolling.rs @@ -409,13 +409,13 @@ fn panel_rows_stop_before_their_scroll_bar() { assert!(scrolled(&h, bar).1 > 0.0, "{bar}: the rows overflow"); let track = rect(&h, bar); if let Some(right) = rightmost(&h, prefix, bar, Some(track)) { - if right > track.min.x { eprintln!("MUT {}", format!("{bar}: the rows end at {right}, on the track {track:?}")); } + assert!(right <= track.min.x, "{bar}: the rows end at {right}, on the track {track:?}"); } else { assert!(matches!(bar, "info.scroll" | "markers.scroll"), "{bar}: no rows beside the track"); } let lane = Rect::from_x_y_ranges(track.min.x..=track.max.x, track.y_range()); for (text, at) in painted_text(&h) { - if at.intersects(lane) { eprintln!("MUT {}", format!("{bar}: {text:?} shows at {at:?}, on the track {track:?}")); } + assert!(!at.intersects(lane), "{bar}: {text:?} shows at {at:?}, on the track {track:?}"); } } let _ = std::fs::remove_dir_all(&folder); From b77db389afb5c1174770965c9554a33fa302e233 Mon Sep 17 00:00:00 2001 From: SHXJ <21096283+tommy1616@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:34:55 +0800 Subject: [PATCH 61/71] #2037: complete the Traditional Chinese effect-name catalog Add 308 Traditional Chinese effect names and 21 category labels, written from the English source with Taiwan creative-software terms. Update the completeness tests to require the zh-hant table and recount the localization docs. --- crates/ui-egui/src/i18n/effects.rs | 455 ++++++++++++++++++++++++++--- docs/gaps.md | 2 +- docs/localization-parity.md | 26 +- 3 files changed, 437 insertions(+), 46 deletions(-) diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index 5793225e3..5497e651e 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -30,6 +30,13 @@ //! `Face Measurements` / `Face Track Points` are EffectCraft's own effects, so their wording is //! ours. //! +//! The Traditional Chinese names for Taiwan are written from the meaning of the English ones, with +//! the terms Taiwan's creative software uses (`圖層`, `遮罩`, `筆刷`, `雜訊`, `發光`, `漸層`); they +//! are not converted from the Simplified table. Brand and coined names (`Cryptomatte`, +//! `EXtractoR`, `IDentifier`, `Key Light`, `Unmult`, `Lumetri Color`, `Colorama`, `Minimax`, +//! `Vegas`, `Mocha`) stay in English; the `CC` series keeps its prefix and translates the +//! descriptive tail, and the `VR` and `OCIO` sets keep their prefix in Chinese word order. +//! //! The French names are written from the meaning of the English ones, with the vocabulary of the //! French menus; they come from no other application's interface. Brand and coined names //! (`Cryptomatte`, `EXtractoR`, `IDentifier`, `Key Light`, `Unmult`, `Lumetri Color`, `Colorama`, @@ -70,9 +77,9 @@ impl Catalog { /// /// **Adding a language** is two tables and one arm: write its rows (one per effect, ids from /// [`SIMPLIFIED_CHINESE`]) and its category labels, name them in the `static` below and in this -/// `match`. Nothing else changes — not the lookups, not one panel. `zh-hant` and `ja` are listed -/// with empty tables, so a translator fills rows in without touching this function, and until -/// then those languages draw the registry's English name. +/// `match`. Nothing else changes — not the lookups, not one panel. `zh-hant` now covers every +/// effect and category; `ja` ships a table that a translator fills in without touching this +/// function. An effect with no row draws the registry's English name until its row arrives. fn catalog(language: &str) -> Option<&'static Catalog> { static SIMPLIFIED: Catalog = Catalog { rows: SIMPLIFIED_CHINESE, categories: SIMPLIFIED_CHINESE_CATEGORIES, index: OnceLock::new() }; static TRADITIONAL: Catalog = Catalog { rows: TRADITIONAL_CHINESE, categories: TRADITIONAL_CHINESE_CATEGORIES, index: OnceLock::new() }; @@ -462,10 +469,323 @@ const SIMPLIFIED_CHINESE: &[Row] = &[ ("ec.vr.sharpen", "VR Sharpen", "VR 锐化"), ("ec.vr.spheretoplane", "VR Sphere to Plane", "VR 球面到平面"), ]; -/// Traditional Chinese (`zh-hant`): no rows yet. A translator fills one row per effect, ids from -/// [`SIMPLIFIED_CHINESE`]; a row may repeat the English name, which is what the language draws -/// for that effect. -const TRADITIONAL_CHINESE: &[Row] = &[]; +/// Traditional Chinese (`zh-hant`): one row per effect in the registry, ids from +/// [`SIMPLIFIED_CHINESE`]. Written from the meaning of the English names with the vocabulary +/// Taiwan's creative software uses (圖層, 遮罩, 筆刷, 雜訊, 發光, 漸層), not converted from the +/// Simplified table. Brand and coined names (`Cryptomatte`, `EXtractoR`, `IDentifier`, `Key Light`, +/// `Unmult`, `Lumetri Color`, `Colorama`, `Minimax`, `Vegas`, `Mocha`) stay in English; the `CC` +/// series keeps its prefix and translates the descriptive tail, and the `VR` and `OCIO` sets keep +/// their prefix in Chinese word order. +const TRADITIONAL_CHINESE: &[Row] = &[ + ("ec.3d.channelextract", "3D Channel Extract", "3D 通道擷取"), + ("ec.3d.cryptomatte", "Cryptomatte", "Cryptomatte"), + ("ec.3d.depthmatte", "Depth Matte", "深度遮罩"), + ("ec.3d.depthoffield", "Depth of Field", "景深"), + ("ec.3d.extractor", "EXtractoR", "EXtractoR"), + ("ec.3d.fog3d", "Fog 3D", "3D 霧"), + ("ec.3d.identifier", "IDentifier", "IDentifier"), + ("ec.3d.idmatte", "ID Matte", "ID 遮罩"), + ("ec.3d.instance3d", "3D Layer Instance", "3D 圖層實體"), + ("ec.audio.backwards", "Backwards", "倒轉"), + ("ec.audio.basstreble", "Bass & Treble", "低音與高音"), + ("ec.audio.compressor", "Compressor", "壓縮器"), + ("ec.audio.delay", "Delay", "延遲"), + ("ec.audio.distortion", "Distortion", "失真"), + ("ec.audio.flangechorus", "Flange & Chorus", "鑲邊與合聲"), + ("ec.audio.gate", "Gate", "雜訊閘"), + ("ec.audio.highlowpass", "High-Low Pass", "高通/低通"), + ("ec.audio.modulator", "Modulator", "調變器"), + ("ec.audio.parametriceq", "Parametric EQ", "參數等化器"), + ("ec.audio.reverb", "Reverb", "殘響"), + ("ec.audio.stereomixer", "Stereo Mixer", "立體聲混音器"), + ("ec.audio.tone", "Tone", "音調"), + ("ec.blur.bilateral", "Bilateral Blur", "雙邊模糊"), + ("ec.blur.cameralens", "Camera Lens Blur", "相機鏡頭模糊"), + ("ec.blur.camerashakedeblur", "Camera-Shake Deblur", "相機晃動去模糊"), + ("ec.blur.cccross", "CC Cross Blur", "CC 十字模糊"), + ("ec.blur.ccradial", "CC Radial Blur", "CC 徑向模糊"), + ("ec.blur.ccradialfast", "CC Radial Fast Blur", "CC 徑向快速模糊"), + ("ec.blur.ccvector", "CC Vector Blur", "CC 向量模糊"), + ("ec.blur.channel", "Channel Blur", "通道模糊"), + ("ec.blur.compound", "Compound Blur", "複合模糊"), + ("ec.blur.directional", "Directional Blur", "方向模糊"), + ("ec.blur.fastbox", "Fast Box Blur", "快速方框模糊"), + ("ec.blur.gaussian", "Gaussian Blur", "高斯模糊"), + ("ec.blur.radial", "Radial Blur", "徑向模糊"), + ("ec.blur.reduceflicker", "Reduce Interlace Flicker", "減少交錯閃爍"), + ("ec.blur.sharpen", "Sharpen", "銳利化"), + ("ec.blur.smart", "Smart Blur", "智慧型模糊"), + ("ec.blur.unsharp", "Unsharp Mask", "遮色片銳利化"), + ("ec.channel.arithmetic", "Arithmetic", "算術"), + ("ec.channel.blend", "Blend", "混合"), + ("ec.channel.calculations", "Calculations", "運算"), + ("ec.channel.cccomposite", "CC Composite", "CC 合成"), + ("ec.channel.combiner", "Channel Combiner", "通道合成器"), + ("ec.channel.compoundarithmetic", "Compound Arithmetic", "複合算術"), + ("ec.channel.invert", "Invert", "反轉"), + ("ec.channel.minimax", "Minimax", "Minimax"), + ("ec.channel.removecolormatting", "Remove Color Matting", "移除色彩殘邊"), + ("ec.channel.setchannels", "Set Channels", "設定通道"), + ("ec.channel.setmatte", "Set Matte", "設定遮罩"), + ("ec.channel.shiftchannels", "Shift Channels", "轉移通道"), + ("ec.channel.solidcomposite", "Solid Composite", "實色合成"), + ("ec.color.autocolor", "Auto Color", "自動色彩"), + ("ec.color.autocontrast", "Auto Contrast", "自動對比"), + ("ec.color.autolevels", "Auto Levels", "自動色階"), + ("ec.color.blackwhite", "Black & White", "黑白"), + ("ec.color.brightnesscontrast", "Brightness & Contrast", "亮度與對比"), + ("ec.color.broadcast", "Broadcast Colors", "廣播色彩"), + ("ec.color.cccolorneutralizer", "CC Color Neutralizer", "CC 色彩中和器"), + ("ec.color.cccoloroffset", "CC Color Offset", "CC 色彩偏移"), + ("ec.color.cckernel", "CC Kernel", "CC 核心"), + ("ec.color.cctoner", "CC Toner", "CC 色調"), + ("ec.color.changecolor", "Change Color", "變更色彩"), + ("ec.color.changetocolor", "Change to Color", "變更為色彩"), + ("ec.color.channelmixer", "Channel Mixer", "通道混合器"), + ("ec.color.colorama", "Colorama", "Colorama"), + ("ec.color.colorbalance", "Color Balance", "色彩平衡"), + ("ec.color.colorbalancehls", "Color Balance (HLS)", "色彩平衡(HLS)"), + ("ec.color.colorlink", "Color Link", "色彩連結"), + ("ec.color.colorstabilizer", "Color Stabilizer", "色彩穩定器"), + ("ec.color.curves", "Curves", "曲線"), + ("ec.color.equalize", "Equalize", "等化"), + ("ec.color.exposure", "Exposure", "曝光"), + ("ec.color.gammapedestalgain", "Gamma/Pedestal/Gain", "伽瑪/基準/增益"), + ("ec.color.huesaturation", "Hue/Saturation", "色相/飽和度"), + ("ec.color.leavecolor", "Leave Color", "保留色彩"), + ("ec.color.levels", "Levels", "色階"), + ("ec.color.levelsic", "Levels (Individual Controls)", "色階(個別控制項)"), + ("ec.color.lumetri", "Lumetri Color", "Lumetri Color"), + ("ec.color.ociocdl", "OCIO CDL Transform", "OCIO CDL 轉換"), + ("ec.color.ociocolorspace", "OCIO Color Space Transform", "OCIO 色彩空間轉換"), + ("ec.color.ociodisplay", "OCIO Display Transform", "OCIO 顯示轉換"), + ("ec.color.ociofile", "OCIO File Transform", "OCIO 檔案轉換"), + ("ec.color.ociolook", "OCIO Look Transform", "OCIO 外觀轉換"), + ("ec.color.photofilter", "Photo Filter", "相片濾鏡"), + ("ec.color.psarbitrarymap", "PS Arbitrary Map", "PS 任意映射"), + ("ec.color.selectivecolor", "Selective Color", "選擇性色彩"), + ("ec.color.shadowhighlight", "Shadow/Highlight", "陰影/亮部"), + ("ec.color.tint", "Tint", "染色"), + ("ec.color.tritone", "Tritone", "三色調"), + ("ec.color.vibrance", "Vibrance", "自然飽和度"), + ("ec.color.videolimiter", "Video Limiter", "視訊限制器"), + ("ec.control.angle", "Angle Control", "角度控制"), + ("ec.control.checkbox", "Checkbox Control", "核取方塊控制"), + ("ec.control.color", "Color Control", "色彩控制"), + ("ec.control.dropdown", "Dropdown Menu Control", "下拉式選單控制"), + ("ec.control.layer", "Layer Control", "圖層控制"), + ("ec.control.point", "Point Control", "點控制"), + ("ec.control.point3d", "3D Point Control", "3D 點控制"), + ("ec.control.slider", "Slider Control", "滑桿控制"), + ("ec.control.stereo3d", "Stereo 3D Controls", "立體 3D 控制"), + ("ec.distort.bezierwarp", "Bezier Warp", "貝茲彎曲"), + ("ec.distort.bulge", "Bulge", "凸出"), + ("ec.distort.ccbender", "CC Bender", "CC 彎曲"), + ("ec.distort.ccbendit", "CC Bend It", "CC 彎折"), + ("ec.distort.ccblobbylize", "CC Blobbylize", "CC 團塊化"), + ("ec.distort.ccflomotion", "CC Flo Motion", "CC 流動"), + ("ec.distort.ccgriddler", "CC Griddler", "CC 格線"), + ("ec.distort.cclens", "CC Lens", "CC 鏡頭"), + ("ec.distort.ccpageturn", "CC Page Turn", "CC 翻頁"), + ("ec.distort.ccpowerpin", "CC Power Pin", "CC 強力釘選"), + ("ec.distort.ccripplepulse", "CC Ripple Pulse", "CC 漣漪脈衝"), + ("ec.distort.ccslant", "CC Slant", "CC 傾斜"), + ("ec.distort.ccsmear", "CC Smear", "CC 塗抹"), + ("ec.distort.ccsplit", "CC Split", "CC 分割"), + ("ec.distort.ccsplit2", "CC Split 2", "CC 分割 2"), + ("ec.distort.cctiler", "CC Tiler", "CC 並排"), + ("ec.distort.cornerpin", "Corner Pin", "邊角釘選"), + ("ec.distort.displacementmap", "Displacement Map", "位移映射"), + ("ec.distort.liquify", "Liquify", "液化"), + ("ec.distort.magnify", "Magnify", "放大"), + ("ec.distort.meshwarp", "Mesh Warp", "網格彎曲"), + ("ec.distort.mirror", "Mirror", "鏡像"), + ("ec.distort.offset", "Offset", "位移"), + ("ec.distort.opticscompensation", "Optics Compensation", "光學補償"), + ("ec.distort.polar", "Polar Coordinates", "極座標"), + ("ec.distort.puppet", "Puppet", "木偶"), + ("ec.distort.reshape", "Reshape", "重塑"), + ("ec.distort.ripple", "Ripple", "漣漪"), + ("ec.distort.rollingshutterrepair", "Rolling Shutter Repair", "滾動快門修正"), + ("ec.distort.smear", "Smear", "塗抹"), + ("ec.distort.spherize", "Spherize", "球面化"), + ("ec.distort.transform", "Transform", "變形"), + ("ec.distort.turbulentdisplace", "Turbulent Displace", "擾流位移"), + ("ec.distort.twirl", "Twirl", "扭轉"), + ("ec.distort.twirllegacy", "Twirl (Legacy)", "扭轉(舊版)"), + ("ec.distort.upscale", "Detail-preserving Upscale", "保留細節放大"), + ("ec.distort.warp", "Warp", "彎曲"), + ("ec.distort.warpstabilizer", "Warp Stabilizer", "彎曲穩定器"), + ("ec.distort.wavewarp", "Wave Warp", "波浪彎曲"), + ("ec.generate.advancedlightning", "Advanced Lightning", "進階閃電"), + ("ec.generate.audiospectrum", "Audio Spectrum", "音訊頻譜"), + ("ec.generate.audiowaveform", "Audio Waveform", "音訊波形"), + ("ec.generate.beam", "Beam", "光束"), + ("ec.generate.ccgluegun", "CC Glue Gun", "CC 膠水槍"), + ("ec.generate.cclightburst", "CC Light Burst 2.5", "CC 光爆 2.5"), + ("ec.generate.cclightrays", "CC Light Rays", "CC 光線"), + ("ec.generate.cclightsweep", "CC Light Sweep", "CC 光線掃掠"), + ("ec.generate.ccthreads", "CC Threads", "CC 絲線"), + ("ec.generate.cellpattern", "Cell Pattern", "細胞圖樣"), + ("ec.generate.checkerboard", "Checkerboard", "棋盤格"), + ("ec.generate.circle", "Circle", "圓形"), + ("ec.generate.ellipse", "Ellipse", "橢圓"), + ("ec.generate.eyedropperfill", "Eyedropper Fill", "滴管填色"), + ("ec.generate.fill", "Fill", "填色"), + ("ec.generate.fourcolor", "4-Color Gradient", "四色漸層"), + ("ec.generate.fractal", "Fractal", "碎形"), + ("ec.generate.gradientramp", "Gradient Ramp", "漸層色帶"), + ("ec.generate.grid", "Grid", "格線"), + ("ec.generate.lensflare", "Lens Flare", "鏡頭光暈"), + ("ec.generate.paintbucket", "Paint Bucket", "油漆桶"), + ("ec.generate.radiowaves", "Radio Waves", "無線電波"), + ("ec.generate.scribble", "Scribble", "塗鴉"), + ("ec.generate.stroke", "Stroke", "筆畫"), + ("ec.generate.vegas", "Vegas", "Vegas"), + ("ec.generate.writeon", "Write-on", "書寫"), + ("ec.key.advancedspill", "Advanced Spill Suppressor", "進階溢色抑制"), + ("ec.key.ccsimplewireremoval", "CC Simple Wire Removal", "CC 簡易鋼絲移除"), + ("ec.key.colordifference", "Color Difference Key", "色彩差異去背"), + ("ec.key.colorkey", "Color Key", "色彩去背"), + ("ec.key.colorrange", "Color Range", "色彩範圍"), + ("ec.key.differencematte", "Difference Matte", "差異遮罩"), + ("ec.key.extract", "Extract", "擷取"), + ("ec.key.innerouter", "Inner/Outer Key", "內/外去背"), + ("ec.key.keycleaner", "Key Cleaner", "去背淨化"), + ("ec.key.linearcolor", "Linear Color Key", "線性色彩去背"), + ("ec.key.luma", "Luma Key", "亮度去背"), + ("ec.key.screen", "Screen Key", "螢幕去背"), + ("ec.key.spill", "Spill Suppressor", "溢色抑制"), + ("ec.key.unmult", "Unmult", "Unmult"), + ("ec.keying.keylight", "Key Light", "Key Light"), + ("ec.matte.mattechoker", "Matte Choker", "遮罩收縮器"), + ("ec.matte.objectmatte", "Object Matte", "物件遮罩"), + ("ec.matte.refinehard", "Refine Hard Matte", "調整硬邊遮罩"), + ("ec.matte.refinesoft", "Refine Soft Matte", "調整柔邊遮罩"), + ("ec.matte.rotobrush", "Roto Brush & Refine Edge", "Roto 筆刷與邊緣調整"), + ("ec.matte.simplechoker", "Simple Choker", "簡單收縮器"), + ("ec.noise.addgrain", "Add Grain", "新增顆粒"), + ("ec.noise.curlnoise", "Curl Noise", "捲曲雜訊"), + ("ec.noise.dustscratches", "Dust & Scratches", "汙點與刮痕"), + ("ec.noise.fractal", "Fractal Noise", "碎形雜訊"), + ("ec.noise.matchgrain", "Match Grain", "符合顆粒"), + ("ec.noise.median", "Median", "中值"), + ("ec.noise.medianlegacy", "Median (Legacy)", "中值(舊版)"), + ("ec.noise.noise", "Noise", "雜訊"), + ("ec.noise.noisealpha", "Noise Alpha", "雜訊 Alpha"), + ("ec.noise.noisehls", "Noise HLS", "雜訊 HLS"), + ("ec.noise.noisehlsauto", "Noise HLS Auto", "雜訊 HLS 自動"), + ("ec.noise.removegrain", "Remove Grain", "移除顆粒"), + ("ec.noise.turbulent", "Turbulent Noise", "擾流雜訊"), + ("ec.obsolete.basic3d", "Basic 3D", "基礎 3D"), + ("ec.obsolete.basictext", "Basic Text", "基礎文字"), + ("ec.obsolete.gaussianlegacy", "Gaussian Blur (Legacy)", "高斯模糊(舊版)"), + ("ec.obsolete.lightning", "Lightning", "閃電"), + ("ec.obsolete.mochashape", "Mocha shape", "Mocha 形狀"), + ("ec.obsolete.pathtext", "Path Text", "路徑文字"), + ("ec.paint.paint", "Paint", "繪畫"), + ("ec.perspective.3dglasses", "3D Glasses", "3D 眼鏡"), + ("ec.perspective.bevelalpha", "Bevel Alpha", "Alpha 斜角"), + ("ec.perspective.beveledges", "Bevel Edges", "邊緣斜角"), + ("ec.perspective.cameratracker", "3D Camera Tracker", "3D 攝影機追蹤"), + ("ec.perspective.cccylinder", "CC Cylinder", "CC 圓柱"), + ("ec.perspective.ccenvironment", "CC Environment", "CC 環境"), + ("ec.perspective.ccsphere", "CC Sphere", "CC 球體"), + ("ec.perspective.ccspotlight", "CC Spotlight", "CC 聚光燈"), + ("ec.perspective.dropshadow", "Drop Shadow", "陰影"), + ("ec.perspective.radialshadow", "Radial Shadow", "徑向陰影"), + ("ec.sim.carddance", "Card Dance", "卡片舞蹈"), + ("ec.sim.caustics", "Caustics", "焦散"), + ("ec.sim.ccballaction", "CC Ball Action", "CC 球狀動作"), + ("ec.sim.ccbubbles", "CC Bubbles", "CC 泡泡"), + ("ec.sim.ccdrizzle", "CC Drizzle", "CC 細雨"), + ("ec.sim.cchair", "CC Hair", "CC 毛髮"), + ("ec.sim.ccmrmercury", "CC Mr. Mercury", "CC 水銀先生"), + ("ec.sim.ccparticlesystems2", "CC Particle Systems II", "CC 粒子系統 II"), + ("ec.sim.ccparticleworld", "CC Particle World", "CC 粒子世界"), + ("ec.sim.ccpixelpolly", "CC Pixel Polly", "CC 像素破碎"), + ("ec.sim.ccrainfall", "CC Rainfall", "CC 下雨"), + ("ec.sim.ccscatterize", "CC Scatterize", "CC 散開"), + ("ec.sim.ccsnowfall", "CC Snowfall", "CC 下雪"), + ("ec.sim.ccstarburst", "CC Star Burst", "CC 星爆"), + ("ec.sim.foam", "Foam", "泡沫"), + ("ec.sim.particleplayground", "Particle Playground", "粒子遊樂場"), + ("ec.sim.shatter", "Shatter", "粉碎"), + ("ec.sim.waveworld", "Wave World", "波浪世界"), + ("ec.stylize.brushstrokes", "Brush Strokes", "筆觸"), + ("ec.stylize.cartoon", "Cartoon", "卡通"), + ("ec.stylize.ccblockload", "CC Block Load", "CC 區塊載入"), + ("ec.stylize.ccburnfilm", "CC Burn Film", "CC 燒灼底片"), + ("ec.stylize.ccglass", "CC Glass", "CC 玻璃"), + ("ec.stylize.cchextile", "CC HexTile", "CC 六角並排"), + ("ec.stylize.cckaleida", "CC Kaleida", "CC 萬花筒"), + ("ec.stylize.ccmrsmoothie", "CC Mr. Smoothie", "CC 平滑先生"), + ("ec.stylize.ccplastic", "CC Plastic", "CC 塑膠"), + ("ec.stylize.ccrepetile", "CC RepeTile", "CC 重複並排"), + ("ec.stylize.ccthreshold", "CC Threshold", "CC 臨界值"), + ("ec.stylize.ccthresholdrgb", "CC Threshold RGB", "CC 臨界值 RGB"), + ("ec.stylize.ccvignette", "CC Vignette", "CC 暗角"), + ("ec.stylize.coloremboss", "Color Emboss", "彩色浮雕"), + ("ec.stylize.emboss", "Emboss", "浮雕"), + ("ec.stylize.findedges", "Find Edges", "尋找邊緣"), + ("ec.stylize.glow", "Glow", "發光"), + ("ec.stylize.mosaic", "Mosaic", "馬賽克"), + ("ec.stylize.motiontile", "Motion Tile", "動態並排"), + ("ec.stylize.posterize", "Posterize", "色調分離"), + ("ec.stylize.roughenedges", "Roughen Edges", "粗糙邊緣"), + ("ec.stylize.scatter", "Scatter", "散佈"), + ("ec.stylize.strobe", "Strobe Light", "頻閃光"), + ("ec.stylize.texturize", "Texturize", "材質化"), + ("ec.stylize.threshold", "Threshold", "臨界值"), + ("ec.text.numbers", "Numbers", "數字"), + ("ec.text.timecode", "Timecode", "時間碼"), + ("ec.time.ccforcemotionblur", "CC Force Motion Blur", "CC 強制動態模糊"), + ("ec.time.ccwidetime", "CC Wide Time", "CC 寬廣時間"), + ("ec.time.echo", "Echo", "回音"), + ("ec.time.pixelmotionblur", "Pixel Motion Blur", "像素動態模糊"), + ("ec.time.posterizetime", "Posterize Time", "色調分離時間"), + ("ec.time.timedifference", "Time Difference", "時間差異"), + ("ec.time.timedisplacement", "Time Displacement", "時間位移"), + ("ec.time.timewarp", "Timewarp", "時間扭曲"), + ("ec.transition.blockdissolve", "Block Dissolve", "區塊溶解"), + ("ec.transition.cardwipe", "Card Wipe", "卡片擦拭"), + ("ec.transition.ccglasswipe", "CC Glass Wipe", "CC 玻璃擦拭"), + ("ec.transition.ccgridwipe", "CC Grid Wipe", "CC 格線擦拭"), + ("ec.transition.ccimagewipe", "CC Image Wipe", "CC 影像擦拭"), + ("ec.transition.ccjaws", "CC Jaws", "CC 巨顎"), + ("ec.transition.cclightwipe", "CC Light Wipe", "CC 光線擦拭"), + ("ec.transition.cclinesweep", "CC Line Sweep", "CC 線條掃掠"), + ("ec.transition.ccradialscalewipe", "CC Radial ScaleWipe", "CC 徑向縮放擦拭"), + ("ec.transition.ccscalewipe", "CC Scale Wipe", "CC 縮放擦拭"), + ("ec.transition.cctwister", "CC Twister", "CC 扭轉"), + ("ec.transition.ccwarpomatic", "CC WarpoMatic", "CC 自動彎曲"), + ("ec.transition.gradientwipe", "Gradient Wipe", "漸層擦拭"), + ("ec.transition.iriswipe", "Iris Wipe", "光圈擦拭"), + ("ec.transition.linearwipe", "Linear Wipe", "線性擦拭"), + ("ec.transition.radialwipe", "Radial Wipe", "徑向擦拭"), + ("ec.transition.venetian", "Venetian Blinds", "百葉窗"), + ("ec.utility.applylut", "Apply Color LUT", "套用色彩 LUT"), + ("ec.utility.ccoverbrights", "CC Overbrights", "CC 過亮"), + ("ec.utility.cineon", "Cineon Converter", "Cineon 轉換器"), + ("ec.utility.colorprofileconverter", "Color Profile Converter", "色彩設定檔轉換器"), + ("ec.utility.facemeasurements", "Face Measurements", "臉部測量"), + ("ec.utility.facetrackpoints", "Face Track Points", "臉部追蹤點"), + ("ec.utility.growbounds", "Grow Bounds", "擴展範圍"), + ("ec.utility.hdrcompander", "HDR Compander", "HDR 壓縮擴展器"), + ("ec.utility.hdrcompression", "HDR Highlight Compression", "HDR 亮部壓縮"), + ("ec.vr.blur", "VR Blur", "VR 模糊"), + ("ec.vr.chromaticaberrations", "VR Chromatic Aberrations", "VR 色差"), + ("ec.vr.colorgradients", "VR Color Gradients", "VR 色彩漸層"), + ("ec.vr.converter", "VR Converter", "VR 轉換器"), + ("ec.vr.denoise", "VR De-Noise", "VR 降噪"), + ("ec.vr.digitalglitch", "VR Digital Glitch", "VR 數位故障"), + ("ec.vr.fractalnoise", "VR Fractal Noise", "VR 碎形雜訊"), + ("ec.vr.glow", "VR Glow", "VR 發光"), + ("ec.vr.planetosphere", "VR Plane to Sphere", "VR 平面轉球體"), + ("ec.vr.rotatesphere", "VR Rotate Sphere", "VR 旋轉球體"), + ("ec.vr.sharpen", "VR Sharpen", "VR 銳利化"), + ("ec.vr.spheretoplane", "VR Sphere to Plane", "VR 球體轉平面"), +]; /// Japanese (`ja`): reserved for the Japanese data point on #821, so both languages share this /// one catalog instead of inventing a second mechanism. A translator fills one row per effect, @@ -795,8 +1115,32 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.vr.spheretoplane", "VR Sphere to Plane", "VR 球体から平面"), ]; -/// Traditional Chinese (`zh-hant`) category labels, keyed by the registry's English category. -const TRADITIONAL_CHINESE_CATEGORIES: &[(&str, &str)] = &[]; +/// The 21 category folder names in Traditional Chinese, written from the English source with the +/// vocabulary Taiwan's creative software uses; the labels stay distinct so the Effect menu shows +/// one folder per category. +const TRADITIONAL_CHINESE_CATEGORIES: &[(&str, &str)] = &[ + ("3D Channel", "3D 通道"), + ("Audio", "音訊"), + ("Blur & Sharpen", "模糊與銳利化"), + ("Channel", "通道"), + ("Color Correction", "色彩校正"), + ("Distort", "扭曲"), + ("Expression Controls", "表示式控制"), + ("Generate", "產生"), + ("Immersive Video", "沉浸式影片"), + ("Keying", "去背"), + ("Matte", "遮罩"), + ("Noise & Grain", "雜訊與顆粒"), + ("Obsolete", "過時"), + ("Paint", "繪畫"), + ("Perspective", "透視"), + ("Simulation", "模擬"), + ("Stylize", "風格化"), + ("Text", "文字"), + ("Time", "時間"), + ("Transition", "轉場"), + ("Utility", "公用程式"), +]; /// Japanese (`ja`) category labels, keyed by the registry's English category. const JAPANESE_CATEGORIES: &[(&str, &str)] = &[ @@ -1880,38 +2224,42 @@ mod tests { #[test] fn every_row_names_a_registry_effect() { let registry = effects::registry(); - for (id, english, translated) in SIMPLIFIED_CHINESE { - let Some(spec) = registry.iter().find(|spec| spec.id == *id) else { - panic!("{id} ({english}) is not an effect in the registry"); - }; - assert_eq!(*english, spec.name, "{id}: the catalog says {english:?}, the registry {:?}", spec.name); - assert!(!translated.is_empty(), "{id}: empty translation"); + for (language, rows) in COMPLETE { + for (id, english, translated) in *rows { + let Some(spec) = registry.iter().find(|spec| spec.id == *id) else { + panic!("{language} row {id} ({english}) is not an effect in the registry"); + }; + assert_eq!(*english, spec.name, "{language} row {id}: the catalog says {english:?}, the registry {:?}", spec.name); + assert!(!translated.is_empty(), "{language} row {id}: empty translation"); + } } } /// Rows are keyed by id, so a repeated id would shadow a translation. #[test] fn ids_are_unique() { - let mut ids: Vec<&str> = SIMPLIFIED_CHINESE.iter().map(|(id, _, _)| *id).collect(); - let listed = ids.len(); - ids.sort_unstable(); - ids.dedup(); - assert_eq!(listed, ids.len(), "an effect id is listed twice"); + for (language, rows) in COMPLETE.iter().chain(PARTIAL) { + let mut ids: Vec<&str> = rows.iter().map(|(id, _, _)| *id).collect(); + let listed = ids.len(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(listed, ids.len(), "{language} lists an effect id twice"); + } } - /// The languages that have no rows yet, with the table a translator fills in for each. A new - /// language is added here and to [`catalog`]; the test then guards its rows as they arrive. - const PENDING: &[(&str, &[Row])] = &[("zh-hant", TRADITIONAL_CHINESE)]; + /// The languages whose tables are complete: one row per effect in the registry and one row + /// per category. A translator moves a language here from [`PARTIAL`] when its table is done; + /// the completeness tests then fail if a new effect or category arrives without a row. + const COMPLETE: &[(&str, &[Row])] = &[("zh-hans", SIMPLIFIED_CHINESE), ("zh-hant", TRADITIONAL_CHINESE)]; - /// The languages whose tables are being filled in: guarded like [`PENDING`] row by row. + /// The languages whose tables are being filled in: guarded row by row. const PARTIAL: &[(&str, &[Row])] = &[("ja", JAPANESE_EFFECTS), ("ka", GEORGIAN), ("sv", SWEDISH), ("fr", FRENCH)]; - /// A pending language may lag Simplified Chinese but may not invent ids or disagree about - /// which effect an id names: a translator fills these tables row by row. While they are - /// empty the test is a no-op, which is why the slot ships before the data does. + /// A partial language may lag Simplified Chinese but may not invent ids or disagree about + /// which effect an id names: a translator fills these tables row by row. #[test] - fn a_pending_language_only_uses_known_ids() { - for (language, rows) in PENDING.iter().chain(PARTIAL) { + fn a_partial_language_only_uses_known_ids() { + for (language, rows) in PARTIAL { let mut ids: Vec<&str> = vec![]; for (id, english, translated) in *rows { let Some((_, hans_english, _)) = SIMPLIFIED_CHINESE.iter().find(|(hans_id, _, _)| hans_id == id) else { @@ -1928,6 +2276,35 @@ mod tests { } } + /// A complete language translates every effect the registry has and labels every category, so + /// a new effect or category cannot ship in English to a Chinese interface. + #[test] + fn a_complete_language_covers_the_registry() { + let registry = effects::registry(); + let categories = effects::categories(); + for (language, rows) in COMPLETE { + for spec in registry { + assert!( + rows.iter().any(|(id, _, _)| *id == spec.id), + "{language} has no row for {} ({}): a complete table covers every effect", + spec.name, + spec.id + ); + } + let labels = match *language { + "zh-hans" => SIMPLIFIED_CHINESE_CATEGORIES, + "zh-hant" => TRADITIONAL_CHINESE_CATEGORIES, + other => panic!("{other} is complete but has no category table in this test"), + }; + for source in &categories { + assert!( + labels.iter().any(|(english, _)| english == source), + "{language} has no category label for {source}: a complete table covers every category" + ); + } + } + } + /// Every category row of every language names a category that exists, else the generated /// Effect menu and Effects & Presets would carry a label no folder has. #[test] @@ -1966,13 +2343,13 @@ mod tests { /// English, and a language with no catalog or no row for an effect, draws the registry's /// name rather than an empty label; a language with a row draws the row. Every table in - /// [`PENDING`] and [`PARTIAL`] has an arm in [`catalog`]: an arm-less table's rows would be dead. + /// [`COMPLETE`] and [`PARTIAL`] has an arm in [`catalog`]: an arm-less table's rows would be dead. #[test] fn languages_without_rows_draw_the_registry_name() { - for (language, _) in PENDING.iter().chain(PARTIAL) { + for (language, _) in COMPLETE.iter().chain(PARTIAL) { assert!(catalog(language).is_some(), "{language} has a table but no arm in `catalog`: its rows would be dead"); } - let tables = PENDING.iter().chain(PARTIAL).copied().chain([("zh-hans", SIMPLIFIED_CHINESE)]); + let tables = COMPLETE.iter().chain(PARTIAL).map(|(language, rows)| (*language, *rows)); for (language, rows) in [("en", &[][..]), ("uk", &[][..]), ("cs", &[][..])].into_iter().chain(tables) { set_current(language); let expected = rows.iter().find(|(id, _, _)| *id == "ec.blur.gaussian").map_or("Gaussian Blur", |(_, _, translated)| *translated); @@ -2027,7 +2404,7 @@ mod tests { } #[cfg(test)] -mod japanese_category_tests { +mod category_name_tests { use super::*; use std::collections::HashMap; @@ -2042,4 +2419,16 @@ mod japanese_category_tests { } } } + + /// The same for Traditional Chinese: one repeated label would hide a category behind another + /// in the Effect menu. + #[test] + fn no_two_categories_share_a_traditional_chinese_name() { + let mut seen: HashMap<&str, &str> = HashMap::new(); + for (english, traditional) in TRADITIONAL_CHINESE_CATEGORIES { + if let Some(other) = seen.insert(traditional, english) { + panic!("`{english}` and `{other}` both draw as `{traditional}`"); + } + } + } } diff --git a/docs/gaps.md b/docs/gaps.md index 016aeff22..e01227170 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -72,7 +72,7 @@ are grouped by area; the issue tracker has the rest. | 13 | **3D model formats and materials** | After Effects 26 imports FBX, USD/USDZ, STL besides glTF and OBJ (`usd_plugins`), and Substance 3D materials with mesh displacement (26.0–26.2); we read glTF, OBJ, STL (binary and ASCII, with facet colours) and FBX 7.x (binary and ASCII: meshes, hierarchy, Lambert/Phong materials and textures; no FBX animation or skinning yet) | Product shots and 3D scenes come as FBX/USDZ | 25–45 h | — · [file-format-parity.md](file-format-parity.md) | | 14 | **Object Matte** (26.2, disk cache in 26.5) | First slice (#783): the Object Matte effect (Effect ▸ Matte) and `objectMatte.apply` turn a point (and optional box) on a reference frame into a matte and follow the object up to 600 frames each way, or segment every frame on its own; Roto Brush's engine, cache and model (MobileSAM from Settings ▸ Roto Brush, classical graph cut without it). Since then: the Object Matte tool in the viewer (click / drag a box, hover crosshair), the box as the model's box prompt, background tracking with progress and cancel, Freeze, and segmentations kept in the disk cache (26.5). Missing: a comparison with After Effects ([plan](fidelity/object-matte.md)) | The new one-click roto | 10–20 h | G7 · [target-app-parity.md](target-app-parity.md) | | 15 | **Hardware video decode and encode** | No VideoToolbox, NVENC, Quick Sync or VA-API; #498 asks for it (answered: CUDA / NVENC need Nvidia's proprietary SDK through FFI, which the no-`unsafe` rule forbids); open PR #819 adds hardware decoding via `filmcraft-platform`; FilmCraft has VideoToolbox / NVENC / VA-API paths to borrow | 4K playback and export speed | 25–45 h | G5/G6 · [hardware-parity.md](hardware-parity.md) | -| 16 | **Localization depth** | Menus in 11 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs, ka: 630 rows each, 629 in Georgian); panels in 7 (ja 996 rows, zh-hans 998, zh-hant 951, uk 524, pt-br 695, cs 518, ka 885); effect names translated in Simplified Chinese (307), Japanese (297) and Georgian (307, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | +| 16 | **Localization depth** | Menus in 11 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs, ka: 630 rows each, 629 in Georgian); panels in 7 (ja 996 rows, zh-hans 998, zh-hant 951, uk 524, pt-br 695, cs 518, ka 885); effect names translated in Simplified Chinese (308), Traditional Chinese (308, plus 21 categories), Japanese (297) and Georgian (308, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | | 17 | **Media Browser lists files import refuses** | The Media Browser, the import and proxy dialogs and `media::is_importable` share one movie list since #1701, AVI included (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored unless Interpret Footage ▸ Cineon Settings converts it; MXF OP1a / OP-Atom since #767; AVI since #1701, without DV, MPEG-4 Part 2 or other legacy codecs yet); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | | 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) | | 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index d535f78d3..8de16b4ae 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** minor (Polish (`pl`): menus 630, panels 695; recounted at this tree after #1774: Japanese 1,923 of ≈ 4,200 interface strings (46%, with 297 effect names), Simplified Chinese 1,935 (46%, with 307 effect names), Traditional Chinese 1,581 (38%, its panel catalog 951 rows after #2244); overall ≈ 24%; previously: minor, the panel/dialog catalogs grow by 303 rows in Japanese, Simplified Chinese and Traditional Chinese: panel tabs, form and dialog labels, context menus, tracker/graph/text-panel labels, credits and File-panel footers; previously: minor, catalogs recounted at `upstream/main` 470ea7a3: menus in 10 languages, panels in 6, Hangul probe (#599)) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-03 · **Change:** Traditional Chinese (`zh-hant`) effect names are complete: 308 names and 21 categories (#2037). Previously: minor (Polish (`pl`): menus 630, panels 695; recounted at this tree after #1774: Japanese 1,923 of ≈ 4,200 interface strings (46%, with 297 effect names), Simplified Chinese 1,935 (46%, with 307 effect names), Traditional Chinese 1,581 (38%, its panel catalog 951 rows after #2244); overall ≈ 24%; previously: minor, the panel/dialog catalogs grow by 303 rows in Japanese, Simplified Chinese and Traditional Chinese: panel tabs, form and dialog labels, context menus, tracker/graph/text-panel labels, credits and File-panel footers; previously: minor, catalogs recounted at `upstream/main` 470ea7a3: menus in 10 languages, panels in 6, Hangul probe (#599)) · **Target:** Adobe After Effects 2026 How much of EffectCraft's interface is translated, language by language, compared with After Effects. [gaps.md](gaps.md) gap 16 and workstream G9 hold the work; @@ -29,18 +29,19 @@ interface; Arabic and Hebrew text *in layers* is supported through its Middle Ea the Simplified Chinese catalog's 694 rows, plus 191 for panels converted to the catalog since (885 in all). Swedish (added later) also has one. - **Effect names:** `crates/ui-egui/src/i18n/effects.rs`, keyed by **effect id**, in Simplified - Chinese (307 rows, one per effect in the registry), Japanese (297 of the 307 ids), Georgian - (307 rows, plus its 21 categories) and Swedish (307 rows, plus its 21 categories). The registry's + Chinese (308 rows, one per effect in the registry), Traditional Chinese (308 rows, plus its 21 + categories), Japanese (297 of the 308 ids), Georgian (308 rows, plus its 21 categories) and + Swedish (308 rows, plus its 21 categories). The registry's English name stays the effect's identity: `effect.list`, `effect.apply {"effect": …}`, scripts and expressions match on it, and the property tree stores it as the effect group's name. Only the drawing — the Effect menu, Effect Controls, the Timeline and Effects & Presets — goes through the catalog, so a project file is identical whichever interface language opens it. A repeat (`Gaussian Blur 2`) keeps the index the engine appends, and a group the user renamed in Effect Controls stays verbatim. Adding a language there is two tables and one arm, and no - panel changes: Traditional Chinese has its table in place and empty, and Japanese fills 297 of - the 307 ids, so the rest draw English until a translator completes them. Each language decides - for itself which names stay English — Adobe's Japanese interface leaves the `CC`, `VR` and - `OCIO` sets in English while its Chinese one translates them — so a row repeating the English + panel changes: Traditional Chinese now covers all 308 ids and 21 categories, and Japanese fills + 297 of the 308 ids, so the rest draw English until a translator completes them. Each language + decides for itself which names stay English — Adobe's Japanese interface leaves the `CC`, `VR` + and `OCIO` sets in English while its Chinese one translates them — so a row repeating the English name is a choice, not a missing row. - **Missing strings:** a string with no row in the interface language shows in English, and no test, build or release fails on a missing translation. The `missing_translations_report` test @@ -76,14 +77,14 @@ counted, so the true percentages are slightly lower. | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | To `full` | |---|---|---|---|---|---|---|---| | English | `en` | ≈ 4,200 (100%, source) | All; docs in English | — | yes | full | — | -| Simplified Chinese | `zh-hans` | 1,935 (≈ 46%): menus 630 rows, panels 998, effect names 307 | Panels and dialogs partly; no help, effect names translated and their parameter names not; Home screen literals unwrapped (#574, closed) | System CJK fallback; Han follows the UI language; CJK text in comps renders wrongly (#453); IME in text layers | no | partial | 6–9 h + review | +| Simplified Chinese | `zh-hans` | 1,936 (≈ 46%): menus 630 rows, panels 998, effect names 308 | Panels and dialogs partly; no help, effect names translated and their parameter names not; Home screen literals unwrapped (#574, closed) | System CJK fallback; Han follows the UI language; CJK text in comps renders wrongly (#453); IME in text layers | no | partial | 6–9 h + review | | Spanish | `es` | 1,142 (≈ 29%): menus 629 rows, panels 513 | Menus only; the panel catalog from #996 | Latin, fine | no | partial | 9–12 h + review | | Hindi | `hi` | 0 | — | UI: Devanagari fallback font but no shaping; text layers shape Devanagari | no | none (After Effects: none) | 9–13 h + shaping in the UI | | Arabic | `ar` | 0 | — | UI: Arabic fallback font but no shaping and no RTL layout; text layers: bidi + shaping | no | none (After Effects: none) | 9–13 h + 15–25 h RTL UI | | French | `fr` | 2,124 (≈ 51%): menus 632 rows, panels 1,184, effect names 308 | Panels and dialogs at the source list of the other panel catalogs; effect names and their 21 categories; no help; machine-translated from the English, not yet native-speaker proofread | Latin, fine | no | partial | 2–4 h + review | | Portuguese (Brazil) | `pt-br` | 1,324 (≈ 34%): menus 629 rows, panels 695 | Panels and dialogs at the Chinese catalogs' source list; the six keys main added after that count are translated too; AI-assisted, not yet native-speaker proofread | Latin, fine | no | partial | independent native review | | Indonesian | `id` | 0 | — | Latin, fine | no | none (After Effects: none) | 8–12 h + review | -| Japanese | `ja` | 1,923 (≈ 46%): menus 630 rows, panels 996, effect names 297 | Panels and dialogs partly; no help, effect names translated for 297 of the 307 ids and their parameter names not; Home screen literals unwrapped (#574, closed) | Japanese system fallback; vertical text and Tate-Chu-Yoko in text layers; IME | no | partial | 6–9 h + review | +| Japanese | `ja` | 1,923 (≈ 46%): menus 630 rows, panels 996, effect names 297 | Panels and dialogs partly; no help, effect names translated for 297 of the 308 ids and their parameter names not; Home screen literals unwrapped (#574, closed) | Japanese system fallback; vertical text and Tate-Chu-Yoko in text layers; IME | no | partial | 6–9 h + review | | German | `de` | 624 (≈ 16%) | Menus only (open PR #666 adds interface translations) | Latin, fine | no | menus only | 7–10 h + review | | Korean | `ko` | 0 | — | Hangul fallback probe added (#599, closed); not checked on macOS here | no | none | 9–13 h + review | | Vietnamese | `vi` | 0 | — | Latin with stacked diacritics; untested | no | none (After Effects: none) | 8–12 h + review | @@ -92,12 +93,12 @@ Other languages EffectCraft ships (6): | Language | Code | UI strings translated | Status | To `full` | |---|---|---|---|---| -| Traditional Chinese | `zh-hant` | 1,581 (≈ 38%): menus 630 rows, panels 951 | partial | 6–9 h + review | +| Traditional Chinese | `zh-hant` | 1,910 (≈ 45%): menus 630 rows, panels 951, effect names 308 + 21 categories | partial; effect names complete, parameter names and engine text still in English | 3–5 h + review | | Ukrainian | `uk` | 1,153 (≈ 29%): menus 629 rows, panels 524 | partial | 6–9 h + review | | Polish | `pl` | 1,325 (≈ 34%): menus 630 rows, panels 695 | partial; unwrapped UI, effects and engine text still in English; AI-drafted, native review welcome | Remaining strings + native review | | Czech | `cs` | 1,147 (≈ 29%): menus 629 rows, panels 518 | Existing catalog entries covered; unwrapped UI, effects and engine text still in English; AI-assisted, not yet native-speaker proofread | Remaining strings + independent native review | -| Georgian | `ka` | 1,514 (≈ 39%): menus 629 rows, panels 885 (the Simplified Chinese catalog's 694 plus 191 Georgian-only rows: form dialogs, context menus, column headers), effect names 307 + 21 categories; native-speaker reviewed | partial | 5–8 h | -| Swedish | `sv` | menus 630 rows, panels 723, effect names 307 and categories 21; added after the measurement above | Settings, Edit menu and Match System (`sv-*` locales) offer it; parameter and engine text still in English; not yet native-speaker reviewed | Remaining strings + native review | +| Georgian | `ka` | 1,514 (≈ 39%): menus 629 rows, panels 885 (the Simplified Chinese catalog's 694 plus 191 Georgian-only rows: form dialogs, context menus, column headers), effect names 308 + 21 categories; native-speaker reviewed | partial | 5–8 h | +| Swedish | `sv` | menus 630 rows, panels 723, effect names 308 and categories 21; added after the measurement above | Settings, Edit menu and Match System (`sv-*` locales) offer it; parameter and engine text still in English; not yet native-speaker reviewed | Remaining strings + native review | Russian (`ru`) has a menu catalog (630 rows, ≈ 15%); After Effects also ships Italian, which EffectCraft does not (8–12 h). @@ -119,6 +120,7 @@ can give; only Georgian, contributed by a native speaker (#1843), has had it. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Traditional Chinese (`zh-hant`): effect names complete — 308 names and 21 categories (#2037); the menu and panel catalogs stay at 630 and 951 rows | | 2026-10-30 | minor | Recounted on the merged tree after #1774: Japanese 1,923 (46%, 297 effect names), Simplified Chinese 1,935 (46%, 307 effect names), Traditional Chinese 1,581 (38%, 951 panel rows after #2244's prune); overall ≈ 24% | | 2026-10-22 | minor | Panel/dialog second step: 300 more catalog rows in Japanese, Simplified Chinese and Traditional Chinese, plus the three rows the review found missing (Basic, Advanced and Play/Stop) — panel tabs, form and dialog labels, panel context menus, the tracker and graph controls, text-panel options, File-panel footers and credits; the panel tables are now 996 Japanese and 998 Simplified Chinese rows, all drawn through `tr`/`tr_args`, with the tests keeping the three catalogs listing the same sources; the 30 October recount above carries the share | | 2026-10-18 | minor | Brazilian Portuguese completes its panel catalog (278 new rows, 417 → 695, the Chinese catalogs' source list); the doc recount closes the drift the batch merges left in every language's panel count; AI-assisted, not yet native-speaker proofread | From d16c720e59129b7276aaeb060917facaf0013c13 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 3 Nov 2026 09:52:40 -0500 Subject: [PATCH 62/71] Follow-up to #2981: a missing Chinese effect row draws English instead of failing the build, the font probe sees effect and category characters, coverage totals recounted Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/effects.rs | 51 ++++++++++++------------------ crates/ui-egui/src/i18n/ui.rs | 8 +++-- docs/localization-parity.md | 12 +++---- 3 files changed, 31 insertions(+), 40 deletions(-) diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index 5497e651e..ec952ccb0 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -105,6 +105,13 @@ pub(crate) fn name(id: &str, english: &str) -> String { catalog(current()).and_then(|c| c.row(id)).map_or_else(|| english.to_string(), |(_, translated)| translated.to_string()) } +/// The characters of `language`'s effect names and category labels, so the interface's font probe +/// registers a face for each of them as it does for the panel catalog's. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn characters(language: &str) -> impl Iterator { + catalog(language).into_iter().flat_map(|c| c.rows.iter().map(|(_, _, t)| *t).chain(c.categories.iter().map(|(_, t)| *t))).flat_map(str::chars) +} + /// The number of effect-name rows `language` translates, for the row-count report; 0 when it has /// no catalog or when it is English. #[cfg(test)] @@ -2248,8 +2255,8 @@ mod tests { } /// The languages whose tables are complete: one row per effect in the registry and one row - /// per category. A translator moves a language here from [`PARTIAL`] when its table is done; - /// the completeness tests then fail if a new effect or category arrives without a row. + /// per category when they were written. A new effect without a row draws its English name (as + /// in every language), so it does not block other work; the rows are guarded like [`PARTIAL`]'s. const COMPLETE: &[(&str, &[Row])] = &[("zh-hans", SIMPLIFIED_CHINESE), ("zh-hant", TRADITIONAL_CHINESE)]; /// The languages whose tables are being filled in: guarded row by row. @@ -2276,35 +2283,6 @@ mod tests { } } - /// A complete language translates every effect the registry has and labels every category, so - /// a new effect or category cannot ship in English to a Chinese interface. - #[test] - fn a_complete_language_covers_the_registry() { - let registry = effects::registry(); - let categories = effects::categories(); - for (language, rows) in COMPLETE { - for spec in registry { - assert!( - rows.iter().any(|(id, _, _)| *id == spec.id), - "{language} has no row for {} ({}): a complete table covers every effect", - spec.name, - spec.id - ); - } - let labels = match *language { - "zh-hans" => SIMPLIFIED_CHINESE_CATEGORIES, - "zh-hant" => TRADITIONAL_CHINESE_CATEGORIES, - other => panic!("{other} is complete but has no category table in this test"), - }; - for source in &categories { - assert!( - labels.iter().any(|(english, _)| english == source), - "{language} has no category label for {source}: a complete table covers every category" - ); - } - } - } - /// Every category row of every language names a category that exists, else the generated /// Effect menu and Effects & Presets would carry a label no folder has. #[test] @@ -2420,6 +2398,17 @@ mod category_name_tests { } } + /// The interface's font probe sees the effect names and category labels, not only the panel + /// catalog: `鑲` appears only in Flange & Chorus (`鑲邊與合聲`), and a sparse Han face missing + /// it would otherwise draw a box. + #[test] + fn the_font_probe_sees_effect_and_category_characters() { + let chars = crate::i18n::catalog_characters("zh-hant"); + assert!(chars.contains(&'鑲'), "an effect name's character is probed"); + let (_, label) = TRADITIONAL_CHINESE_CATEGORIES.first().copied().unwrap_or_default(); + assert!(label.chars().all(|ch| chars.contains(&ch)), "{label}: a category label's characters are probed"); + } + /// The same for Traditional Chinese: one repeated label would hide a category behind another /// in the Effect menu. #[test] diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 2ed6b2c07..dab315ffc 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -145,11 +145,13 @@ fn string_literal(rest: &str) -> Option { None } -/// Characters required by the fixed panel catalog, for system-font coverage selection. -/// Sorted and deduplicated so fallback registration is deterministic. +/// Characters required by the fixed panel catalog and the effect names and categories, for +/// system-font coverage selection. Sorted and deduplicated so fallback registration is +/// deterministic. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn catalog_characters(language: &str) -> Vec { - catalog(language).into_iter().flat_map(|c| c.values()).flat_map(|s| s.chars()).collect::>().into_iter().collect() + let panels = catalog(language).into_iter().flat_map(|c| c.values()).flat_map(|s| s.chars()); + panels.chain(super::effects::characters(language)).collect::>().into_iter().collect() } /// Translate one interface string: the current language's row for `text`, or `text` itself. diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 8de16b4ae..b0a87120d 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -69,7 +69,7 @@ and recounted again at this tree after #1774 by counting catalog rows (`const` t `crates/ui-egui/src/i18n.rs`, `i18n/ui.rs` and `i18n/effects.rs`: 630 menu rows, 998 panel/dialog sources in Simplified Chinese, 951 in Traditional Chinese and 996 in Japanese). The denominator, **≈ 4,200 user-visible strings**, is a rounded estimate: the 630 fixed menu entries, -the 998 panel/dialog sources, 307 effect names, ≈ 1,650 distinct effect parameter names and +the 998 panel/dialog sources, 308 effect names, ≈ 1,650 distinct effect parameter names and ≈ 620 other unwrapped interface strings (Home screen literals, engine-built tooltips, error and status messages). It counts distinct strings, not catalog rows; engine messages are only partly counted, so the true percentages are slightly lower. @@ -93,18 +93,18 @@ Other languages EffectCraft ships (6): | Language | Code | UI strings translated | Status | To `full` | |---|---|---|---|---| -| Traditional Chinese | `zh-hant` | 1,910 (≈ 45%): menus 630 rows, panels 951, effect names 308 + 21 categories | partial; effect names complete, parameter names and engine text still in English | 3–5 h + review | +| Traditional Chinese | `zh-hant` | 1,889 (≈ 45%): menus 630 rows, panels 951, effect names 308 (+ 21 categories, not counted, as for the other languages) | partial; effect names complete, parameter names and engine text still in English | 3–5 h + review | | Ukrainian | `uk` | 1,153 (≈ 29%): menus 629 rows, panels 524 | partial | 6–9 h + review | | Polish | `pl` | 1,325 (≈ 34%): menus 630 rows, panels 695 | partial; unwrapped UI, effects and engine text still in English; AI-drafted, native review welcome | Remaining strings + native review | | Czech | `cs` | 1,147 (≈ 29%): menus 629 rows, panels 518 | Existing catalog entries covered; unwrapped UI, effects and engine text still in English; AI-assisted, not yet native-speaker proofread | Remaining strings + independent native review | -| Georgian | `ka` | 1,514 (≈ 39%): menus 629 rows, panels 885 (the Simplified Chinese catalog's 694 plus 191 Georgian-only rows: form dialogs, context menus, column headers), effect names 308 + 21 categories; native-speaker reviewed | partial | 5–8 h | +| Georgian | `ka` | 1,822 (≈ 43%): menus 629 rows, panels 885 (the Simplified Chinese catalog's 694 plus 191 Georgian-only rows: form dialogs, context menus, column headers), effect names 308 + 21 categories; native-speaker reviewed | partial | 5–8 h | | Swedish | `sv` | menus 630 rows, panels 723, effect names 308 and categories 21; added after the measurement above | Settings, Edit menu and Match System (`sv-*` locales) offer it; parameter and engine text still in English; not yet native-speaker reviewed | Remaining strings + native review | Russian (`ru`) has a menu catalog (630 rows, ≈ 15%); After Effects also ships Italian, which EffectCraft does not (8–12 h). -**Overall: ≈ 24%**, the average coverage of After Effects' 10 non-English languages -(Simplified Chinese 46%, Traditional Chinese 38%, Japanese 46%, Brazilian Portuguese 34%, -Spanish 29%, French, German and Russian 16%, Korean and Italian 0%; 241 / 10, was ≈ 21% on +**Overall: ≈ 25%**, the average coverage of After Effects' 10 non-English languages +(Simplified Chinese 46%, Traditional Chinese 45%, Japanese 46%, Brazilian Portuguese 34%, +Spanish 29%, French, German and Russian 16%, Korean and Italian 0%; 248 / 10, was ≈ 24% before #2037's effect names and ≈ 21% on 22 October; Ukrainian (≈ 29%) and Czech (≈ 29%) are EffectCraft-only and stay out of that average). This is the localization row in [target-app-parity.md](target-app-parity.md#by-dimension). From b7dee07cf44fcb42e0afa662043c0dc3bf3f8553 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 09:57:48 -0500 Subject: [PATCH 63/71] Path booleans: sparse winding numbers and bounded merge runs (#2946) Winding numbers were dense vectors up to each segment's path index, and the topology keeps one per segment, so memory grew with the square of the path count. They now store only the nonzero (path, winding) pairs. tidy_segments re-checked the whole run on every merge attempt, so a long chain of collinear pieces cost quadratic time. A run now merges at most 64 pieces; longer runs leave a few extra joints on the same line or curve. --- crates/path/src/boolean.rs | 114 +++++++++++++++++++++++++++++-------- 1 file changed, 91 insertions(+), 23 deletions(-) diff --git a/crates/path/src/boolean.rs b/crates/path/src/boolean.rs index bcd395566..0323fc4ee 100644 --- a/crates/path/src/boolean.rs +++ b/crates/path/src/boolean.rs @@ -26,24 +26,19 @@ pub(crate) fn inside(rule: FillRule, w: i32) -> bool { } } -/// Winding numbers for an arbitrary number of input paths (tag = input index). -#[derive(Clone, Debug, Default)] -pub(crate) struct Multi(Vec); +/// Winding numbers for an arbitrary number of input paths (tag = input index), stored sparsely: +/// the nonzero `(tag, winding)` pairs in tag order. A segment's winding names only its own path, +/// so the topology's per-segment copies stay small however many paths take part (a dense +/// vector up to each segment's tag would grow with the square of the path count). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct Multi(Vec<(usize, i32)>); impl Multi { fn get(&self, i: usize) -> i32 { - self.0.get(i).copied().unwrap_or(0) + self.0.binary_search_by_key(&i, |e| e.0).map_or(0, |k| self.0[k].1) } } -impl PartialEq for Multi { - fn eq(&self, other: &Self) -> bool { - let n = self.0.len().max(other.0.len()); - (0..n).all(|i| self.get(i) == other.get(i)) - } -} -impl Eq for Multi {} - impl std::ops::Add for Multi { type Output = Multi; fn add(mut self, rhs: Self) -> Multi { @@ -54,26 +49,51 @@ impl std::ops::Add for Multi { impl std::ops::AddAssign for Multi { fn add_assign(&mut self, rhs: Self) { - if self.0.len() < rhs.0.len() { - self.0.resize(rhs.0.len(), 0); + if rhs.0.is_empty() { + return; } - for (a, b) in self.0.iter_mut().zip(rhs.0) { - *a += b; + // Merge the two tag-ordered lists, dropping windings that cancel to zero (so equal + // windings have equal representations). + let (a, b) = (std::mem::take(&mut self.0), rhs.0); + let mut out = Vec::with_capacity(a.len() + b.len()); + let (mut i, mut j) = (0, 0); + while i < a.len() || j < b.len() { + let e = match (a.get(i), b.get(j)) { + (Some(&x), Some(&y)) if x.0 == y.0 => { + i += 1; + j += 1; + (x.0, x.1 + y.1) + } + (Some(&x), Some(&y)) if x.0 < y.0 => { + i += 1; + x + } + (Some(&x), None) => { + i += 1; + x + } + (_, Some(&y)) => { + j += 1; + y + } + (None, None) => break, + }; + if e.1 != 0 { + out.push(e); + } } + self.0 = out; } } impl WindingNumber for Multi { type Tag = usize; fn single(tag: usize, positive: bool) -> Self { - let mut v = vec![0; tag + 1]; - v[tag] = if positive { 1 } else { -1 }; - Multi(v) + Multi(vec![(tag, if positive { 1 } else { -1 })]) } fn of_tag(&self, tag: usize) -> Self { - let mut v = vec![0; tag + 1]; - v[tag] = self.get(tag); - Multi(v) + let w = self.get(tag); + Multi(if w == 0 { Vec::new() } else { vec![(tag, w)] }) } } @@ -250,6 +270,9 @@ pub(crate) fn to_seg(s: PathSeg) -> Seg { } } +/// Most segments `tidy_segments` merges into one. +const MAX_MERGE_RUN: usize = 64; + /// Merge segments of a closed loop that were split from one curve (or collinear lines) back /// together, dropping degenerate pieces. fn tidy_segments(segs: Vec, tidy: &Tidy) -> Vec { @@ -284,7 +307,9 @@ fn tidy_segments(segs: Vec, tidy: &Tidy) -> Vec { while i < n { let mut cur = segs[i]; let mut j = i + 1; - while j < n && mergeable(&segs[j - 1], &segs[j]) { + // Each attempt re-checks the whole run, so a run is capped: a long chain of redundant + // pieces costs linear, not quadratic, work and leaves at most a few extra joints. + while j < n && j - i < MAX_MERGE_RUN && mergeable(&segs[j - 1], &segs[j]) { match try_merge(&segs[i..=j], tidy) { Some(m) => { cur = m; @@ -383,3 +408,46 @@ pub fn filled_area(path: &BezPath, rule: FillRule) -> f64 { None => 0.0, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sparse_windings_add_and_restrict() { + let a = Multi::single(3, true) + Multi::single(1000, false); + assert_eq!((a.get(3), a.get(1000), a.get(7)), (1, -1, 0)); + // Only nonzero entries are stored, whatever the tag. + assert_eq!(a.0.len(), 2); + assert_eq!(a.of_tag(1000), Multi::single(1000, false)); + assert_eq!(a.of_tag(5), Multi::default()); + // Windings that cancel compare equal to none at all. + assert_eq!(a.clone() + Multi::single(3, false) + Multi::single(1000, true), Multi::default()); + } + + #[test] + fn many_paths_union_keeps_each_square() { + let squares: Vec<(BezPath, FillRule)> = + (0..400).map(|i| (kurbo::Rect::new(i as f64 * 3.0, 0.0, i as f64 * 3.0 + 2.0, 2.0).to_path(0.1), FillRule::NonZero)).collect(); + let out = boolean_n(&squares, |m| m.iter().any(|&x| x)); + assert_eq!(out.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count(), 400); + assert!((out.area().abs() - 400.0 * 4.0).abs() < 1e-6); + } + + #[test] + fn long_collinear_runs_merge_in_bounded_pieces() { + // A square whose top edge is split into thousands of collinear pieces. + let n: usize = 5000; + let mut segs: Vec = (0..n).map(|k| Seg::line(Point::new(k as f64, 0.0), Point::new(k as f64 + 1.0, 0.0))).collect(); + let w = n as f64; + segs.push(Seg::line(Point::new(w, 0.0), Point::new(w, w))); + segs.push(Seg::line(Point::new(w, w), Point::new(0.0, w))); + segs.push(Seg::line(Point::new(0.0, w), Point::new(0.0, 0.0))); + let out = tidy_segments(segs, &Tidy::free(DEFAULT_PRECISION)); + // The top edge comes back as n / MAX_MERGE_RUN straight pieces on the same line. + assert!(out.len() <= n.div_ceil(MAX_MERGE_RUN) + 3, "{}", out.len()); + assert!(out.iter().all(|s| s.line)); + let top: f64 = out.iter().filter(|s| s.c.p0.y == 0.0 && s.c.p3.y == 0.0).map(|s| s.c.p3.x - s.c.p0.x).sum(); + assert_eq!(top, w); + } +} From 5464a2380e26464c428938e89b6c9eb9ea720546 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 12:32:20 -0500 Subject: [PATCH 64/71] Mask feather: bound the variable feather's distance pass (#2947) * Mask feather: bound the variable feather's distance pass Every flattened segment scanned its box grown by the largest feather radius, so a path with many vertices and a radius spanning the mask revisited every pixel once per segment. The pixel visits are now counted first; past a budget of 2^31 the flattened polyline is thinned (every other point, arc lengths kept) until it fits, and only a degenerate case falls back to the hard mask. * clippy manual_is_multiple_of --- crates/path/src/feather.rs | 79 +++++++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/crates/path/src/feather.rs b/crates/path/src/feather.rs index e8e0fc4a8..47a4bda7e 100644 --- a/crates/path/src/feather.rs +++ b/crates/path/src/feather.rs @@ -70,6 +70,22 @@ fn flatten(sp: &ShapePath, m: &Mat3) -> Flat { } impl Flat { + /// Keep every other point (and the last), with their arc lengths. + fn thin(&mut self) { + let n = self.pts.len(); + let keep = |i: usize| i.is_multiple_of(2) || i + 1 == n; + let mut i = 0; + self.pts.retain(|_| { + i += 1; + keep(i - 1) + }); + let mut i = 0; + self.s.retain(|_| { + i += 1; + keep(i - 1) + }); + } + /// Arc length of a feather point. fn arc_of(&self, f: &FeatherPoint) -> Option { let table = self.seg_tables.get(f.segment)?; @@ -123,14 +139,39 @@ impl Profile { } } +/// Most pixel visits (summed over the flattened path's segments) a variable feather's distance +/// pass may take: a few seconds of work, far beyond a feathered mask at 4K with a radius of a +/// few hundred pixels. Past it the flattened path is thinned (see [`variable_feather_coverage`]). +pub const FEATHER_WORK_BUDGET: u64 = 1 << 31; + +/// Pixel visits of the distance pass over the polyline `pts`: each segment's box grown by +/// `reach`, clipped to the `w × h` mask. +fn feather_work(pts: &[[f64; 2]], reach: f64, w: u32, h: u32) -> u64 { + let span = |lo: f64, hi: f64, n: u32| -> u64 { + let a = (lo - reach).floor().max(0.0); + let b = (hi + reach).ceil().min(f64::from(n) - 1.0); + if b >= a { (b - a + 1.0) as u64 } else { 0 } + }; + pts.windows(2) + .map(|p| { + let (a, b) = (p[0], p[1]); + span(a[0].min(b[0]), a[0].max(b[0]), w).saturating_mul(span(a[1].min(b[1]), a[1].max(b[1]), h)) + }) + .fold(0u64, u64::saturating_add) +} + /// Coverage of a mask path with feather points, transformed by `m` (translate + uniform scale) /// into a `w × h` mask. `expansion` (layer pixels) grows (>0) or shrinks the mask first; /// `linear` selects the Linear feather falloff instead of Smooth. pub fn variable_feather_coverage(sp: &ShapePath, m: &Mat3, w: u32, h: u32, expansion: f64, linear: bool) -> Mask { + coverage_within(sp, m, (w, h), expansion, linear, FEATHER_WORK_BUDGET) +} + +fn coverage_within(sp: &ShapePath, m: &Mat3, (w, h): (u32, u32), expansion: f64, linear: bool, budget: u64) -> Mask { let scale = m.mean_scale(); let path = to_kurbo(sp); let hard = fill_coverage(std::slice::from_ref(&path), m, w, h, FillRule::NonZero); - let flat = flatten(sp, m); + let mut flat = flatten(sp, m); if flat.pts.len() < 2 { return hard; } @@ -148,6 +189,16 @@ pub fn variable_feather_coverage(sp: &ShapePath, m: &Mat3, w: u32, h: u32, expan let rmax = prof.pts.iter().map(|p| p.1.max(p.2)).fold(0.0, f64::max); let exp = expansion * scale; let reach = rmax + exp.abs() + 2.0; + // Each flattened segment visits its box grown by the reach, so many segments with a reach + // spanning the mask would revisit every pixel once per segment. Past the budget the + // polyline is thinned (every other point, keeping their arc lengths) until it fits: the + // distances move by a fraction of the chords, small next to a feather that wide. + while feather_work(&flat.pts, reach, w, h) > budget { + if flat.pts.len() <= 8 { + return hard; + } + flat.thin(); + } let (wu, hu) = (w as usize, h as usize); let mut dist = vec![f32::INFINITY; wu * hu]; let mut arc = vec![0.0f32; wu * hu]; @@ -218,6 +269,32 @@ mod tests { m.data[(y * m.width + x) as usize] } + #[test] + fn feather_work_is_bounded_by_thinning_the_path() { + // Thousands of vertices with a feather reaching across the mask: the polyline is thinned + // until the distance pass fits the budget, and the result is still a feathered edge. + let n = 2000; + let verts: Vec<[f64; 2]> = (0..n) + .map(|i| { + let a = i as f64 / n as f64 * std::f64::consts::TAU; + [100.0 + 50.0 * a.cos(), 100.0 + 50.0 * a.sin()] + }) + .collect(); + let mut sp = ShapePath::polygon(&verts, true); + sp.feather = vec![FeatherPoint { segment: 0, t: 0.5, radius: 40.0, tension: 0.0 }]; + let budget = 1 << 22; + let full = flatten(&sp, &Mat3::IDENTITY); + assert!(feather_work(&full.pts, 42.0, 200, 200) > budget); + let m = coverage_within(&sp, &Mat3::IDENTITY, (200, 200), 0.0, true, budget); + let unbounded = coverage_within(&sp, &Mat3::IDENTITY, (200, 200), 0.0, true, u64::MAX); + // Outside the edge: 1 at the circle falling linearly to 0 at 40 px, close to the + // unthinned result. + assert!(at(&m, 100, 100) > 0.99); + let (thin, exact) = (at(&m, 100, 30), at(&unbounded, 100, 30)); + assert!(thin > 0.4 && thin < 0.6 && (thin - exact).abs() < 0.05, "{thin} {exact}"); + assert!(at(&m, 100, 5) < 0.01); + } + #[test] fn no_points_is_the_hard_mask() { let m = variable_feather_coverage(&square(), &Mat3::IDENTITY, 200, 200, 0.0, false); From 2f31c22a09fb8cb512006973a57b64bcc330c9f7 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 14:10:24 -0500 Subject: [PATCH 65/71] fix(prefs): when recovering from a bad value, try only the settings' own pages one by one (#3010) --- crates/engine/src/settings/prefs/document.rs | 8 ++++++++ crates/engine/src/tests/settings.rs | 16 ++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/crates/engine/src/settings/prefs/document.rs b/crates/engine/src/settings/prefs/document.rs index 1f68c4a06..5db41e554 100644 --- a/crates/engine/src/settings/prefs/document.rs +++ b/crates/engine/src/settings/prefs/document.rs @@ -76,7 +76,15 @@ impl Prefs { Err(_) => { let mut base = serde_json::to_value(Prefs::default()).unwrap_or_default(); if let (Some(b), Some(o)) = (base.as_object_mut(), v.as_object()) { + // Only the settings' own pages are tried one by one (each trial parses the + // whole document): any other key is kept as it is, so a document of many + // unknown keys costs one pass, not one parse per key. + let pages: Vec = b.keys().cloned().collect(); for (k, x) in o { + if !pages.contains(k) { + b.insert(k.clone(), x.clone()); + continue; + } let mut trial = b.clone(); trial.insert(k.clone(), x.clone()); if serde_json::from_value::(Value::Object(trial)).is_ok() { diff --git a/crates/engine/src/tests/settings.rs b/crates/engine/src/tests/settings.rs index 5e38c90a3..7d65061ea 100644 --- a/crates/engine/src/tests/settings.rs +++ b/crates/engine/src/tests/settings.rs @@ -40,6 +40,22 @@ fn prefs_round_trip_through_json() { assert_eq!(Prefs::from_json(&Prefs::default().to_json()), Prefs::default()); } +/// A document with one bad page and many unknown keys recovers its good pages in one pass per +/// page, not one full parse per key. +#[test] +fn prefs_recovery_tries_only_the_settings_pages() { + let mut doc = serde_json::Map::new(); + doc.insert("general".into(), json!({"undoLevels": "not a number"})); + doc.insert("labels".into(), serde_json::to_value(Prefs::default()).unwrap()["labels"].clone()); + for i in 0..20_000 { + doc.insert(format!("unknown{i}"), json!({"x": i})); + } + let t = std::time::Instant::now(); + let p = Prefs::from_json(&serde_json::Value::Object(doc).to_string()); + assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed()); + assert_eq!(p.general, Prefs::default().general); +} + #[test] fn prefs_migrate_old_layout_and_keep_unknown_keys() { let old = r#"{ From 08362b9c044247472a490eae396527a1c6b0dbcf Mon Sep 17 00:00:00 2001 From: Matej <100806068+MatejKozlovsky@users.noreply.github.com> Date: Tue, 3 Nov 2026 21:12:19 +0100 Subject: [PATCH 66/71] GPU: free effect working textures after each call instead of at frame end (#1523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * GPU: free effect working textures after each call instead of at frame end A GPU frame keeps its encoder open while the CPU renderer runs layers with GPU effects (Gpu::effects -> run_chain). Each call's Enc handed its textures back to the shared pool, where they waited for every open encoder, so all effect intermediates of a frame stayed allocated until the frame ended (17.7 GB by layer 17 of a heavy 4K comp, then OOM). Effect calls now borrow a pool owner (Enc::lent) that is given back once the call's work is submitted or discarded; its kept textures are trimmed to the pool budget, so the rest are freed after each call. * GPU: one pool budget for idle effect calls, trimmed at the end of a render Review of #1523: each idle lent owner kept up to POOL_BUDGET of textures of its own, outside the shared accounting, and there were as many idle owners as effect calls ever ran at once (16 frame threads: ~12 GB after a 4K render). Giving a lent owner back now moves its kept textures to the shared free list, so one POOL_BUDGET bounds what the free list and every finished effect call keep. They skip Pool::release's wait for open encoders: only the lent Enc recorded work on them, and that work is submitted or discarded. Each lend is a fresh owner id, so a lease that outlives its call (an image handed to another encoder) is released through Pool::release; this does not rely on the result being read back. Accelerator::render_end (called when an export ends) drops the kept textures. * GPU: evict the pool's oldest free textures, guard escaped leases Review of #1523 (follow-up to 0a884fb6): - Pool::make_free rejected a texture when the free list was full and never evicted, so once sizes that no longer occur (per-frame blur bounds, padded layer sizes) filled the budget, every later free was a drop and every take an allocation. It now evicts the least recently freed textures until the new one fits. - A lease read by another Enc (dispatch, copy, readback, display) or handed to a cache is marked escaped and always released through the guarded Pool::release, so a lent call can no longer reuse a texture that a still open encoder reads. - Docs: POOL_BUDGET caps the free list only; textures waiting for open encoders and those of live Encs are outside it. - export_async calls render_end from a drop guard, so the pool is also trimmed when an export panics or its future is dropped. - Tests use a private context with its own pool budget instead of a global override; new tests for stale sizes and an image read by an open encoder. * GPU: evict only stale pool sizes, count evicted textures until the GPU frees them Evicting the least recently freed texture for every newcomer thrashed when a frame frees more than the budget in a repeating order (blur/glow intermediates): each free evicted the size the next call takes first, so misses came back to the old level and evict + reallocate raised the peak. The free list now evicts only sizes nobody asked for in the last 2 output frames (Accelerator::frame_end, called by the export per frame); otherwise the incoming texture is dropped and the kept set keeps hitting. Evicted bytes count against the budget until the work submitted so far is done, so evictions run at most one budget ahead of the GPU. * GPU: lower the shared pool budget to 256 MB On 4K RTX 3090 runs 256 MB matches the old code's peak reserved on d_blurglow (9204 MB) and is 256-512 MB above it on c_effects (6592 vs 6080 MB), while staying faster. * GPU: end frames on the multi-frame render paths, route recording through Enc::record Only the export pipeline called frame_end, so off export (preview, remote frames) the pool's clock stood still, no size went stale and a full free list dropped every newcomer. passes::comp_frame (exports), remote frames and the preview workers now end each frame; test without pool_frame_end. Docs: the frame clock is coarse with many frames in flight; dying only throttles eviction, approximately; no cache path escapes images. Enc::record is the single way to get the encoder outside context.rs and marks the images it names (Enc::uses). * GPU: wait for the empty submission while evicted pool textures still count stale_sizes_do_not_starve_a_steady_size failed with 0 hits and 20 misses on hardware adapters: pool_frame_end submitted an empty batch and then only did a non-blocking Poll, and the queue's work-done callback did not run before the next frame, so Pool::dying never drained and make_free stopped evicting stale sizes. While dying is non-zero, pool_frame_end now waits on that empty submission for up to the readback timeout (the pool lock is already released). A wait timeout just moves on and does not retire the context or its readbacks; other poll errors still retire them. Without dying textures it keeps the cheap Poll. Enc::crop records through Enc::record(&[src, &dst]) so both pooled images are registered like on every other recording path (review). * GPU: do not repeat the pool frame-end wait until evicted textures drain A wait that frees none of the evicted bytes (timeout, or a WebGPU poll that does not block) marks the pool stalled; later frames only poll until dying drops. Test covers the no-repeat and partial-progress cases; dying decrement saturates. * Remote frames: end a pending frame that a new render replaces A render arriving while a multi-pass frame waits for readbacks dropped that frame without `frame_end`, so the GPU pool's clock skipped it. Errors return before `frame_begin`. * Render: end the frame even when a comp frame panics `passes::comp_frame` called `frame_end` only after a normal return, so a frame that panicked (or whose future was dropped between passes) never aged the GPU pool. * GPU pool: log the reuse rate at render end; test reuse across effect calls and frames Log cumulative hits/misses (and reuse %) once per render before trimming. Tests cover reuse within an open frame across effect calls, and across repeated frames. --------- Co-authored-by: Matej Kozlovský Co-authored-by: bflatastic Co-authored-by: Claude Opus 5.5 Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- crates/engine/src/background/remote.rs | 8 + crates/engine/src/background/remote/tests.rs | 59 +++ crates/export/src/lib.rs | 14 + crates/gpu/src/adv3d.rs | 10 +- crates/gpu/src/context.rs | 366 ++++++++++++++++++- crates/gpu/src/lib.rs | 16 +- crates/gpu/src/tests.rs | 275 +++++++++++++- crates/gpu/src/yuv.rs | 2 +- crates/render/src/accel.rs | 12 + crates/render/src/passes.rs | 18 +- crates/render/src/tests.rs | 44 +++ crates/ui-egui/src/frames.rs | 13 +- 12 files changed, 810 insertions(+), 27 deletions(-) diff --git a/crates/engine/src/background/remote.rs b/crates/engine/src/background/remote.rs index 6539ac767..27f9396e1 100644 --- a/crates/engine/src/background/remote.rs +++ b/crates/engine/src/background/remote.rs @@ -417,6 +417,10 @@ impl FrameServer { auto = Some((key, gpu)); } } + // A frame still waiting for readbacks is replaced: end it, so every `frame_begin` has its `frame_end`. + if self.pending.take().is_some() { + a.frame_end(); + } a.frame_begin(); } self.pending = Some(Pending { id, project: p, comp, time, opts, t0: web_time::Instant::now(), passes: 0, auto }); @@ -457,6 +461,10 @@ impl FrameServer { { pick.record(key, chose, ms); } + // The frame is done (its `frame_begin` ran in `handle`): the GPU pool ages its sizes. + if let Some(a) = &self.accel { + a.frame_end(); + } vec![( FrameReply::Frame { id: pr.id, width: img.width, height: img.height, ms, passes: pr.passes, gpu, layer_misses, layer_hits }, Some(rgba8_premultiplied(&img)), diff --git a/crates/engine/src/background/remote/tests.rs b/crates/engine/src/background/remote/tests.rs index 85f7a1807..e2db10b8e 100644 --- a/crates/engine/src/background/remote/tests.rs +++ b/crates/engine/src/background/remote/tests.rs @@ -192,6 +192,8 @@ struct Deferred { asked: std::sync::Mutex>, gate: Arc, chains: std::sync::atomic::AtomicUsize, + begins: std::sync::atomic::AtomicUsize, + ends: std::sync::atomic::AtomicUsize, auto: effectcraft_render::AutoPick, } @@ -245,8 +247,12 @@ impl Accelerator for Deferred { Some(&self.auto) } fn frame_begin(&self) { + self.begins.fetch_add(1, std::sync::atomic::Ordering::Relaxed); self.ready.lock().unwrap().clear(); } + fn frame_end(&self) { + self.ends.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } fn pass_begin(&self) { self.gate.store(false, std::sync::atomic::Ordering::Relaxed); } @@ -316,3 +322,56 @@ fn deferred_readbacks_render_in_passes() { let st = acc.auto.stats(effectcraft_render::AutoKey::new(comp, opts.scale, false)).unwrap(); assert!(st.cpu_frames > 0 && st.gpu_frames > 0, "{st:?}"); } + +/// `frame_begin` and `frame_end` stay paired: early fails (before `frame_begin`), a pending frame replaced by a new render, a finished multi-pass frame, and a CPU frame. +#[test] +fn every_frame_begin_has_a_frame_end() { + use std::sync::atomic::Ordering::Relaxed; + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + let comp = s.active_comp_id().unwrap(); + s.execute("layer.newSolid", json!({"color": "#3388ff", "name": "Blue", "width": 200, "height": 120})).unwrap(); + s.execute("effect.apply", json!({"effect": "ec.blur.gaussian"})).unwrap(); + s.execute("effect.apply", json!({"effect": "ec.stylize.glow"})).unwrap(); + s.execute("layer.newSolid", json!({"color": "#ffcc00", "name": "Yellow", "width": 80, "height": 80})).unwrap(); + s.execute("effect.apply", json!({"effect": "ec.color.levels"})).unwrap(); + let opts = RenderOpts { scale: 0.25, backend: Backend::Gpu, ..Default::default() }; + let t = Tick::from_seconds_f64(1.0); + let mut w = server(&s); + let acc = Arc::new(Deferred::default()); + w.set_accel(Some(acc.clone())); + let mut m = Mirror::default(); + w.handle(round(&m.sync(s.revision, &s.project).unwrap())); + let req = |id, revision, comp, opts: RenderOpts| { + round(&FrameMsg::Render { id, revision, comp, time: t, opts: Box::new(opts), disk: None, layers: false, prefetch: vec![] }) + }; + let load = || (acc.begins.load(Relaxed), acc.ends.load(Relaxed)); + // Early fails never touch the frame clock. + assert!(matches!(w.handle(req(1, s.revision + 1, comp, opts))[0].0, FrameReply::Failed { .. })); + assert!(matches!(w.handle(req(2, s.revision, ItemId(999_999), opts))[0].0, FrameReply::Failed { .. })); + assert_eq!(load(), (0, 0)); + // First GPU pass misses and leaves the frame pending. + assert!(w.handle(req(3, s.revision, comp, opts)).is_empty() && w.waiting()); + assert_eq!(load(), (1, 0)); + // A fail while pending leaves the frame and the counters alone. + assert!(matches!(w.handle(req(4, s.revision + 1, comp, opts))[0].0, FrameReply::Failed { .. })); + assert!(w.waiting()); + assert_eq!(load(), (1, 0)); + // A new valid render replaces the pending frame (ends the old one). + assert!(w.handle(req(5, s.revision, comp, opts)).is_empty() && w.waiting()); + assert_eq!(load(), (2, 1)); + let mut out = vec![]; + for _ in 0..10 { + acc.settle(); + out = w.resume(); + if !w.waiting() { + break; + } + } + assert!(matches!(out[0].0, FrameReply::Frame { id: 5, .. })); + assert_eq!(load(), (2, 2)); + // CPU still pairs begin/end on the accelerator. + let cpu = RenderOpts { backend: Backend::Cpu, ..opts }; + assert!(matches!(w.handle(req(6, s.revision, comp, cpu))[0].0, FrameReply::Frame { id: 6, .. })); + assert_eq!(load(), (3, 3)); +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 560d07978..0a8f6b4ec 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -175,7 +175,9 @@ pub fn export(job: &Job, progress: &mut dyn FnMut(&Progress) -> bool) -> Result< pub async fn export_async(job: &Job<'_>, progress: &mut dyn FnMut(&Progress) -> bool) -> Result { let t0 = Instant::now(); let cx = Cx::new(job); + let render = RenderEnd(job.accel); let mut r = export_with(&cx, progress, t0).await; + drop(render); if matches!(r, Err(ExportError::Cancelled)) { cx.remove_cancelled_outputs()?; return r; @@ -188,6 +190,18 @@ pub async fn export_async(job: &Job<'_>, progress: &mut dyn FnMut(&Progress) -> r } +/// Calls [`effectcraft_render::Accelerator::render_end`] when dropped: after the export, and +/// also when it panics or its future is dropped (a browser worker aborting it). +struct RenderEnd<'a>(Option<&'a dyn effectcraft_render::Accelerator>); + +impl Drop for RenderEnd<'_> { + fn drop(&mut self) { + if let Some(a) = self.0 { + a.render_end(); + } + } +} + async fn export_with(cx: &Cx<'_>, progress: &mut dyn FnMut(&Progress) -> bool, t0: Instant) -> Result { let comp = cx.comp().ok_or(ExportError::NoComp)?; let total = cx.settings.frame_count(comp); diff --git a/crates/gpu/src/adv3d.rs b/crates/gpu/src/adv3d.rs index c96e57d2d..b88322316 100644 --- a/crates/gpu/src/adv3d.rs +++ b/crates/gpu/src/adv3d.rs @@ -489,7 +489,7 @@ fn raster_into(e: &mut Enc, s: &Scene) -> Option<(wgpu::Texture, wgpu::Texture)> let zout = tex(DEPTH_OUT, rt); let depth = tex(DEPTH, wgpu::TextureUsages::RENDER_ATTACHMENT); let (cv, zv, dv) = (color.create_view(&Default::default()), zout.create_view(&Default::default()), depth.create_view(&Default::default())); - let enc = e.encoder(); + let enc = e.record(&[]); { let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("adv3d"), @@ -763,7 +763,7 @@ fn dispatch(e: &mut Enc, kernel: &str, u0: [u32; 4], f0: [f32; 4], b: Binds, gro } entries.push(wgpu::BindGroupEntry { binding: 10, resource: wgpu::BindingResource::TextureView(b.out.unwrap_or(&post.dummy_out)) }); let bg = g.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some(kernel), layout: &post.bgl, entries: &entries }); - let enc = e.encoder(); + let enc = e.record(&[]); let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some(kernel), timestamp_writes: None }); pass.set_pipeline(pipe); pass.set_bind_group(0, &bg, &[]); @@ -799,7 +799,7 @@ fn read_buffer(e: &mut Enc, buf: &wgpu::Buffer, size: u64) -> Option> { usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); - e.encoder().copy_buffer_to_buffer(buf, 0, &rb, 0, size); + e.record(&[]).copy_buffer_to_buffer(buf, 0, &rb, 0, size); e.submit(); let out = e.g.read_buffer(&rb, row, row, 1).map_err(|e| log::error!("gpu 3d readback: {e}")).ok()?; Some(out) @@ -922,6 +922,7 @@ fn depth_of_field(e: &mut Enc, acc: &wgpu::Buffer, depth: &wgpu::Buffer, (w, h): /// The resolved run as an image in the canvas's encoding, composited over `canvas` when given. pub(crate) fn finish(e: &mut Enc, r: &Resolved, encode: bool, canvas: Option<&GpuImage>) -> GpuImage { + canvas.inspect(|c| e.uses(c)); let out = e.image(r.width, r.height); let ov = out.texture.create_view(&Default::default()); let cv = canvas.map(|c| c.texture.create_view(&Default::default())); @@ -1023,7 +1024,7 @@ fn render_prepared_deferred(g: &GpuContext, d: &std::sync::Arc GpuImage { + e.uses(canvas); let out = e.image(canvas.width, canvas.height); e.copy_into(canvas, &out, 0, 0); if pixels.is_empty() { diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 44e777b5a..2c9a40735 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -42,7 +42,17 @@ pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float; const UPLOAD_BUDGET: usize = 1536 << 20; /// Working textures kept for reuse once their last image is dropped (bytes). -const POOL_BUDGET: usize = 768 << 20; +const POOL_BUDGET: usize = 256 << 20; + +/// Frames after which a size the pool was not asked for is stale: only stale sizes are +/// evicted to make room (see [`Pool::make_free`]). +/// +/// The clock is coarse: it advances on every finished frame ([`GpuContext::pool_frame_end`]) +/// from any thread, so with many frames in flight (an export renders up to 16 at once) this +/// means "not asked for during the last 2 completions", not "during 2 frames' rendering". A +/// size a slower in-flight frame still needs can go stale and be evicted. That costs a pool +/// miss (a new allocation), never a wrong pixel. +const STALE_FRAMES: u64 = 2; /// Readback staging buffers kept for reuse across sizes (bytes). const STAGING_BUDGET: u64 = 256 << 20; @@ -66,6 +76,20 @@ impl GpuImage { pub fn new(texture: wgpu::Texture, width: u32, height: u32) -> GpuImage { GpuImage { texture, width, height, _lease: None, levels: None } } + + /// This image leaves the [`Enc`] that made it (read by an encoder that may stay open, see + /// [`Enc::uses`]): when its last clone drops, its texture waits for every open encoder + /// ([`Pool::release`]) instead of going back to that `Enc` for immediate reuse. + pub(crate) fn escape(&self) { + if let Some(l) = &self._lease { + l.escaped.store(true, std::sync::atomic::Ordering::Relaxed); + } + } + + /// A pooled image of another `Enc` than `owner` (see [`GpuImage::escape`]). + fn foreign_to(&self, owner: u64) -> bool { + self._lease.as_ref().is_some_and(|l| l.owner != owner) + } } /// Hands a pooled texture back to its [`Pool`] when the last [`GpuImage`] holding it drops. @@ -74,6 +98,10 @@ struct Lease { pool: Weak>, /// The [`Enc`] that took it (see [`Pool::owned`]). owner: u64, + /// Another [`Enc`] recorded work on it ([`Enc::uses`], [`GpuImage::escape`]): + /// its owner can no longer reuse it at once, since that other encoder may still be open, so + /// it is released through the guarded [`Pool::release`]. + escaped: std::sync::atomic::AtomicBool, /// The pool's [`Pool::generation`] when it was taken. generation: u64, } @@ -89,7 +117,12 @@ impl Drop for Lease { if let Some(pool) = self.pool.upgrade() && let Ok(mut p) = pool.lock() { - p.release_owned(self.texture.clone(), self.owner, self.generation); + if !self.escaped.load(std::sync::atomic::Ordering::Relaxed) { + p.release_owned(self.texture.clone(), self.owner, self.generation); + } else if self.generation == p.generation { + // Like `release_owned`, nothing taken before a failed frame comes back (#1319). + p.release(self.texture.clone()); + } } } } @@ -103,7 +136,12 @@ impl Drop for Lease { /// it was released has been submitted (or dropped). #[derive(Default)] pub(crate) struct Pool { - free: HashMap<(u32, u32), Vec>, + /// Free textures by size, each with the [`Pool::stamp`] at which it was freed (oldest first). + free: HashMap<(u32, u32), std::collections::VecDeque<(wgpu::Texture, u64)>>, + /// Counts frees, so the free list evicts the oldest texture first. + stamp: u64, + /// Bytes the free list may hold (0 = [`POOL_BUDGET`]); tests lower it per context. + budget: usize, /// (texture, newest encoder open at its release). released: Vec<(wgpu::Texture, u64)>, /// Encoders with recorded, unsubmitted work. @@ -113,6 +151,25 @@ pub(crate) struct Pool { /// Working textures reused / newly allocated (diagnostics, tests). hits: u64, misses: u64, + /// Free textures dropped to make room for newer ones (diagnostics, tests). + evicted: u64, + /// Output frames done so far ([`GpuContext::pool_frame_end`]). + frame: u64, + /// Per size, the [`Pool::frame`] in which it was last asked for (hit or miss). + asked: HashMap<(u32, u32), u64>, + /// Bytes of evicted textures whose GPU memory may not be freed yet: wgpu frees a dropped + /// texture only once the submissions that used it are done. Counted against the budget + /// for evicting, so evicting and reallocating cannot stack up freed-but-held memory. + dying: Arc, + /// The [`Pool::dying`] bytes left after a [`GpuContext::pool_frame_end`] wait that cleared + /// none of them (`0` = not stalled). While `dying` does not drop below it, later frames only + /// poll instead of waiting again (each wait could take up to [`crate::readback::TIMEOUT`] + /// when the queue stopped completing work). + stalled: usize, + /// Number of empty-submission waits from [`GpuContext::pool_frame_end`] (diagnostics, tests). + waits: u64, + /// Reports when the submissions that may use an evicted texture are done. + queue: Option, /// 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 @@ -234,13 +291,100 @@ impl Pool { } } + fn budget(&self) -> usize { + if self.budget == 0 { POOL_BUDGET } else { self.budget } + } + + /// Keep `t` on the free list if it fits. Otherwise make room by evicting stale sizes, least + /// recently freed first: sizes not asked for in the last [`STALE_FRAMES`] output frames (a + /// blur bound or padded layer size that no longer occurs), so they age out instead of + /// filling the budget for good. Sizes still in use are never evicted for a newcomer: a + /// frame that frees more than the budget in a repeating order would otherwise evict, on + /// every free, the texture the next call takes first (LRU on a cycle), and miss on every + /// take. If nothing stale makes room, `t` is dropped (the kept set keeps hitting). + /// + /// Evicted textures count against the budget ([`Pool::dying`]) until the GPU work + /// submitted so far is done. That throttles eviction to roughly one budget ahead of the + /// GPU; it is not a hard bound (wgpu frees a dropped texture only when the device is + /// maintained, which can be later than the work-done callback). fn make_free(&mut self, t: wgpu::Texture) { + use std::sync::atomic::Ordering::Relaxed; let b = tex_bytes(&t); - if self.bytes + b > POOL_BUDGET { + let budget = self.budget(); + if b > budget { + return; + } + let mut evicted = 0; + while self.bytes + b > budget { + let frame = self.frame; + let asked = &self.asked; + let stale = |k: &(u32, u32)| asked.get(k).is_none_or(|f| frame.saturating_sub(*f) >= STALE_FRAMES); + let Some(oldest) = self.free.iter().filter(|(k, _)| stale(k)).filter_map(|(k, q)| q.front().map(|(_, s)| (*s, *k))).min().map(|(_, k)| k) else { + break; + }; + let ob = oldest.0 as usize * oldest.1 as usize * 16; + if self.dying.load(Relaxed) + evicted + ob > budget { + break; + } + let Some(q) = self.free.get_mut(&oldest) else { break }; + if let Some((old, _)) = q.pop_front() { + self.bytes -= tex_bytes(&old); + evicted += ob; + self.evicted += 1; + } + if q.is_empty() { + self.free.remove(&oldest); + } + } + if evicted > 0 { + self.dying.fetch_add(evicted, Relaxed); + let dying = self.dying.clone(); + match &self.queue { + Some(q) => q.on_submitted_work_done(move || { + let _ = dying.try_update(Relaxed, Relaxed, |d| Some(d.saturating_sub(evicted))); + }), + None => { + let _ = dying.try_update(Relaxed, Relaxed, |d| Some(d.saturating_sub(evicted))); + } + } + } + if self.bytes + b > budget { return; } self.bytes += b; - self.free.entry((t.width(), t.height())).or_default().push(t); + self.stamp += 1; + self.free.entry((t.width(), t.height())).or_default().push_back((t, self.stamp)); + } + + /// An output frame is done: sizes not asked for since age towards [`STALE_FRAMES`]. + /// Returns the evicted bytes the caller should wait for: 0 when none still count, or when a + /// previous wait cleared none and none has been freed since. + fn frame_end(&mut self) -> usize { + self.frame += 1; + let (frame, free) = (self.frame, &self.free); + self.asked.retain(|k, f| frame - *f < STALE_FRAMES || free.contains_key(k)); + let d = self.dying.load(std::sync::atomic::Ordering::Relaxed); + if d == 0 { + self.stalled = 0; + return 0; + } + if self.stalled != 0 && d >= self.stalled { + // No progress since the stalled wait: do not wait again. + return 0; + } + self.stalled = 0; + d + } + + /// A [`GpuContext::pool_frame_end`] wait finished with `before` dying bytes still counted + /// beforehand. A wait that freed nothing (timed out, or callbacks not run synchronously, + /// e.g. on WebGPU where poll does not block) marks the pool stalled. + fn waited(&mut self, before: usize) { + self.waits += 1; + let d = self.dying.load(std::sync::atomic::Ordering::Relaxed); + if d >= before { + self.stalled = d; + } } fn open(&mut self) -> u64 { @@ -291,14 +435,54 @@ impl Pool { self.release(t); } + /// An owner for [`Enc::lent`]. It is a fresh id each time, so a lease of an earlier lent + /// `Enc` that drops later (an image that outlived its call) finds no owner and goes + /// through [`Pool::release`], which waits for every open encoder. + fn lend(&mut self) -> u64 { + self.register() + } + + /// [`Enc::lent`] is done with `owner` (its work submitted or discarded): its kept textures + /// go to the shared free list at once. The free list has one [`POOL_BUDGET`] (only stale + /// sizes are evicted), so finished calls no longer keep a budget each (16 frame threads + /// kept up to 16 × `POOL_BUDGET`). The budget caps the free list only: textures waiting + /// for open encoders ([`Pool::released`]) and those held by live `Enc`s are outside it. + /// + /// They skip [`Pool::release`]'s wait for open encoders: a kept texture's lease has + /// dropped and only this `Enc` recorded work on it, which is already submitted or + /// discarded, so later work cannot overtake it on the queue. That is enforced, not assumed: + /// a lease read by another `Enc` is marked escaped + /// ([`GpuImage::escape`]) and released through [`Pool::release`] whenever it drops, and a + /// lease that drops after this call finds no owner and goes the same way. + fn give_back(&mut self, owner: u64) { + if let Some(m) = self.owned.remove(&owner) { + for t in m.into_values().flatten() { + self.make_free(t); + } + } + } + + /// Bytes of working textures kept for reuse: the free list, those waiting for open + /// encoders and those kept by live `Enc`s. + fn retained(&self) -> usize { + let owned = self.owned.values().flat_map(|m| m.values().flatten()); + self.released.iter().map(|(t, _)| t).chain(owned).map(tex_bytes).fold(self.bytes, usize::saturating_add) + } + 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.asked.insert((w, h), self.frame); 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); + self.asked.insert((w, h), self.frame); + let q = self.free.get_mut(&(w, h)); + let t = q.and_then(|q| q.pop_back()).map(|(t, _)| t); + if self.free.get(&(w, h)).is_some_and(|q| q.is_empty()) { + self.free.remove(&(w, h)); + } match &t { Some(t) => { self.bytes -= tex_bytes(t); @@ -739,6 +923,7 @@ impl GpuContext { init_resource(&device, "dummy storage buffer", || buffer_init(&device, &queue, Some("dummy"), &[0u8; 16], wgpu::BufferUsages::STORAGE))?; let dummy_tex = init_resource(&device, "dummy texture view", || dummy.create_view(&Default::default()))?; let max_dim = device.limits().max_texture_dimension_2d; + let pool = Arc::new(Mutex::new(Pool { queue: Some(queue.clone()), ..Default::default() })); Ok(GpuContext { device, queue, @@ -758,7 +943,7 @@ impl GpuContext { uploads: Mutex::new(Uploads::default()), upload_budget: std::sync::atomic::AtomicUsize::new(UPLOAD_BUDGET), upload_full_warned: Default::default(), - pool: Default::default(), + pool, staging: Default::default(), transfers: Default::default(), zeros: Default::default(), @@ -814,6 +999,23 @@ impl GpuContext { }) } + /// Waits on `submission_index` up to `timeout` so wgpu frees resources dropped before that + /// submission. A timeout simply moves on without retiring the context or its readbacks; any + /// other poll error retires the readbacks through the failure path. + fn poll_wait(&self, submission_index: wgpu::SubmissionIndex, timeout: std::time::Duration) { + if self.readbacks.check().is_err() { + return; + } + match self.device.poll(wgpu::PollType::Wait { submission_index: Some(submission_index), timeout: Some(timeout) }) { + Ok(_) | Err(wgpu::PollError::Timeout) => {} + Err(e) => { + let error = format!("GPU poll wait: {e}"); + self.readbacks.retire(error.clone()); + log::error!("{error}"); + } + } + } + /// Start a bounded buffer mapping. Retired contexts reject new requests immediately. pub(crate) fn map_readback( &self, @@ -1054,7 +1256,7 @@ impl GpuContext { let (reused, generation) = self.pool.lock().map(|mut p| (p.take_owned(owner, w, h).or_else(|| p.take(w, h)), p.generation)).unwrap_or((None, u64::MAX)); 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), owner, generation }); + let lease = Arc::new(Lease { texture: texture.clone(), pool: Arc::downgrade(&self.pool), owner, escaped: Default::default(), generation }); (GpuImage { texture, width: w, height: h, _lease: Some(lease), levels: None }, was_reused) } @@ -1081,6 +1283,73 @@ impl GpuContext { } } + /// Bytes of working textures the pool keeps for reuse (see [`GpuContext::trim_pool`]). + pub fn pool_retained_bytes(&self) -> usize { + self.pool.lock().map(|p| p.retained()).unwrap_or_default() + } + + /// Drop the working textures kept for reuse (a render is done). Textures still waiting for + /// an open encoder and those of live `Enc`s are unaffected. + pub fn trim_pool(&self) { + if let Ok(mut p) = self.pool.lock() { + p.clear(); + } + } + + /// An output frame is done: the pool ages the sizes not asked for since (only sizes unused + /// for [`STALE_FRAMES`] frames are evicted to make room for others). When evicted textures + /// still count ([`Pool::dying`]), waits once so their work-done callbacks can clear that + /// count; if the wait frees none of them, later frames only poll until `dying` drops (a + /// stopped queue must not re-wait [`crate::readback::TIMEOUT`] on every frame). + pub fn pool_frame_end(&self) { + let before = self.pool.lock().map(|mut p| p.frame_end()).unwrap_or_default(); + if before > 0 { + // A work-done callback waits for a submission; an empty one closes the wait. + let index = self.queue.submit([]); + self.poll_wait(index, crate::readback::TIMEOUT); + if let Ok(mut p) = self.pool.lock() { + p.waited(before); + } + } else { + let _ = self.poll_readbacks(wgpu::PollType::Poll); + } + } + + /// Bytes the pool's free list may keep (tests lower it on a context of their own). + #[cfg(test)] + pub(crate) fn set_pool_budget(&self, bytes: usize) { + if let Ok(mut p) = self.pool.lock() { + p.budget = bytes; + } + } + + /// Free textures evicted to make room for newer ones. + #[cfg(test)] + pub(crate) fn pool_evictions(&self) -> u64 { + self.pool.lock().map(|p| p.evicted).unwrap_or_default() + } + + /// Empty-submission waits from [`GpuContext::pool_frame_end`]. + #[cfg(test)] + pub(crate) fn pool_waits(&self) -> u64 { + self.pool.lock().map(|p| p.waits).unwrap_or_default() + } + + /// Add or subtract phantom [`Pool::dying`] bytes without a work-done callback (tests that + /// simulate a queue which stopped completing work). + #[cfg(test)] + pub(crate) fn pool_charge_dying(&self, bytes: isize) { + use std::sync::atomic::Ordering::Relaxed; + if let Ok(p) = self.pool.lock() { + if bytes >= 0 { + p.dying.fetch_add(bytes as usize, Relaxed); + } else { + let sub = (-bytes) as usize; + let _ = p.dying.try_update(Relaxed, Relaxed, |d| Some(d.saturating_sub(sub))); + } + } + } + /// Texture pool counters: (working textures reused, newly allocated). pub fn pool_stats(&self) -> (u64, u64) { self.pool.lock().map(|p| (p.hits, p.misses)).unwrap_or_default() @@ -1197,6 +1466,8 @@ pub(crate) struct Enc<'g> { missing: bool, /// This `Enc`'s id in the texture pool (textures it may reuse at once). owner: u64, + /// `owner` is lent ([`Enc::lent`]) and outlives this `Enc`. + lent: bool, } /// Images bound to a compute kernel's source, optional auxiliary and output slots. @@ -1213,7 +1484,11 @@ impl Drop for Enc<'_> { if let Some((_, id)) = self.enc.take() { p.close(id); } - p.unregister(self.owner); + if self.lent { + p.give_back(self.owner); + } else { + p.unregister(self.owner); + } } } } @@ -1221,7 +1496,27 @@ impl Drop for Enc<'_> { impl<'g> Enc<'g> { pub fn new(g: &'g GpuContext) -> Enc<'g> { let owner = g.pool.lock().map(|mut p| p.register()).unwrap_or(0); - Enc { g, enc: None, pending: 0, missing: false, owner } + Enc { g, enc: None, pending: 0, missing: false, owner, lent: false } + } + + /// An `Enc` for self-contained work (an effect chain): once it is dropped, its working + /// textures can be reused at once ([`Pool::give_back`]). Its images stay with it, except a + /// result another `Enc` records work on ([`Enc::uses`]), whose lease then releases through + /// the guarded [`Pool::release`]. A plain [`Enc::new`] hands its + /// textures back through the shared pool, where they wait for every open encoder, and a + /// GPU frame keeps its encoder open while the CPU renders layers with GPU effects, so every + /// effect's intermediates stayed allocated until the frame ended. + pub(crate) fn lent(g: &'g GpuContext) -> Enc<'g> { + let owner = g.pool.lock().map(|mut p| p.lend()).unwrap_or(0); + Enc { g, enc: None, pending: 0, missing: false, owner, lent: true } + } + + /// `img` is used by this `Enc`: if another `Enc` made it, that one must not reuse it at once + /// (this encoder may still be open when its lease drops), so mark it escaped. + pub(crate) fn uses(&self, img: &GpuImage) { + if img.foreign_to(self.owner) { + img.escape(); + } } /// Whether a dispatch since the last call named a kernel the device doesn't have (one @@ -1230,7 +1525,21 @@ impl<'g> Enc<'g> { std::mem::take(&mut self.missing) } - pub(crate) fn encoder(&mut self) -> &mut wgpu::CommandEncoder { + /// The command encoder, to record work that touches `imgs` (every pooled image the commands + /// read or write; buffers and textures of the caller's own need not be named). The only + /// way to record outside this module: naming the images marks foreign leases escaped + /// ([`Enc::uses`]). Callers must name every pooled image the encoder's work uses, here or + /// with separate [`Enc::uses`] calls (`record(&[])` plus `uses()` is allowed); this is a + /// convention, not enforced, and skipping it reintroduces the reuse race it guards (#1441). + pub(crate) fn record(&mut self, imgs: &[&GpuImage]) -> &mut wgpu::CommandEncoder { + for img in imgs { + self.uses(img); + } + self.encoder() + } + + /// The raw encoder: within this module only, after [`Enc::uses`] on every pooled image. + fn encoder(&mut self) -> &mut wgpu::CommandEncoder { let g = self.g; &mut self .enc @@ -1329,7 +1638,8 @@ impl<'g> Enc<'g> { ], }) }); - let enc = self.encoder(); + let imgs: Vec<&GpuImage> = [Some(src), aux, Some(out)].into_iter().flatten().collect(); + let enc = self.record(&imgs); { let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some(entry), timestamp_writes: None }); pass.set_pipeline(pipe); @@ -1354,7 +1664,7 @@ impl<'g> Enc<'g> { /// Copy `src` into a larger zeroed image at (`x`, `y`) (Buf::pad). pub fn copy_into(&mut self, src: &GpuImage, dst: &GpuImage, x: u32, y: u32) { - let enc = self.encoder(); + let enc = self.record(&[src, dst]); enc.copy_texture_to_texture( wgpu::TexelCopyTextureInfo { texture: &src.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, wgpu::TexelCopyTextureInfo { texture: &dst.texture, mip_level: 0, origin: wgpu::Origin3d { x, y, z: 0 }, aspect: wgpu::TextureAspect::All }, @@ -1371,7 +1681,7 @@ impl<'g> Enc<'g> { (b > a).then(|| (a as u32, (a - o) as u32, (b - a) as u32)) }; let (Some((sx, dx, cw)), Some((sy, dy, ch))) = (span(x, w, src.width), span(y, h, src.height)) else { return dst }; - let enc = self.encoder(); + let enc = self.record(&[src, &dst]); enc.copy_texture_to_texture( wgpu::TexelCopyTextureInfo { texture: &src.texture, mip_level: 0, origin: wgpu::Origin3d { x: sx, y: sy, z: 0 }, aspect: wgpu::TextureAspect::All }, wgpu::TexelCopyTextureInfo { texture: &dst.texture, mip_level: 0, origin: wgpu::Origin3d { x: dx, y: dy, z: 0 }, aspect: wgpu::TextureAspect::All }, @@ -1393,6 +1703,7 @@ impl<'g> Enc<'g> { /// Read an image back to the CPU (submits and waits). `None` where readback is impossible. pub fn download(&mut self, img: &GpuImage) -> Option { + self.uses(img); let layout = crate::readback::Layout::new(img.width, img.height, 16, self.g.device.limits().max_buffer_size) .map_err(|e| log::error!("gpu image readback: {e}")) .ok()?; @@ -1509,6 +1820,7 @@ impl<'g> Enc<'g> { /// once they arrived (`Some(None)`: the readback failed), else `None` after starting the /// copy if needed and marking the pass missed. pub(crate) fn read_keyed(&mut self, img: &GpuImage, key: u128, offset: [f64; 2], scale: f64) -> Option> { + self.uses(img); use crate::deferred::Lookup; let d = self.g.deferred.clone()?; match d.lookup(key) { @@ -1536,6 +1848,7 @@ impl<'g> Enc<'g> { /// Copy an image into a storage buffer (RGBA f32 rows) for kernels that read a third image /// through `data`: (buffer, row length in pixels). pub fn image_rows(&mut self, img: &GpuImage) -> (wgpu::Buffer, u32) { + self.uses(img); let row = (img.width * 16).div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; let buf = self.g.device.create_buffer(&wgpu::BufferDescriptor { label: Some("image rows"), @@ -1565,6 +1878,7 @@ impl<'g> Enc<'g> { /// Convert to the viewer's premultiplied RGBA8 texture (egui-wgpu's native texture format), /// with a full mip chain so the viewer minifies it without aliasing (#417). pub fn display(&mut self, img: &GpuImage) -> wgpu::Texture { + self.uses(img); let g = self.g; let levels = mip_levels(img.width, img.height); let tex = g.device.create_texture(&wgpu::TextureDescriptor { @@ -1607,6 +1921,30 @@ pub(crate) fn mip_levels(w: u32, h: u32) -> u32 { u32::BITS - w.max(h).max(1).leading_zeros() } +#[cfg(test)] +mod pool_stall_tests { + use super::Pool; + use std::sync::atomic::Ordering::Relaxed; + + #[test] + fn frame_end_skips_wait_while_stalled_until_dying_drops() { + let mut p = Pool::default(); + p.dying.store(100, Relaxed); + assert_eq!(p.frame_end(), 100); + p.waited(100); + assert_eq!(p.stalled, 100); + assert_eq!(p.waits, 1); + assert_eq!(p.frame_end(), 0); + p.dying.store(150, Relaxed); + assert_eq!(p.frame_end(), 0); + p.dying.store(50, Relaxed); + assert_eq!(p.frame_end(), 50); + assert_eq!(p.stalled, 0); + p.dying.store(0, Relaxed); + assert_eq!(p.frame_end(), 0); + } +} + #[cfg(test)] mod adapter_name_tests { use super::{match_adapter_name, wanted_adapter_name}; diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index c58ccd59e..d0a688e49 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -397,7 +397,7 @@ impl Accelerator for Gpu { fn effects(&self, chain: &[FxStep], buf: &Buf, levels: Option) -> Option { self.ctx.check_health().ok()?; - effects::run_chain(&mut Enc::new(&self.ctx), chain, buf, levels) + effects::run_chain(&mut Enc::lent(&self.ctx), chain, buf, levels) } fn raster_3d(&self, scene: &effectcraft_render::three_d::adv::Scene) -> Option { @@ -419,12 +419,26 @@ impl Accelerator for Gpu { Some(&self.auto) } + fn render_end(&self) { + let (hits, misses) = self.ctx.pool_stats(); + let pct = hits.saturating_mul(100) / hits.saturating_add(misses).max(1); + log::debug!( + target: "effectcraft_gpu", + "gpu pool: {hits} working textures reused, {misses} allocated ({pct}% reuse) so far" + ); + self.ctx.trim_pool(); + } + fn frame_begin(&self) { if let Some(d) = self.ctx.deferred() { d.frame_begin(); } } + fn frame_end(&self) { + self.ctx.pool_frame_end(); + } + fn pass_begin(&self) { if let Some(d) = self.ctx.deferred() { d.pass_begin(); diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 44e8922a6..28c27b5c7 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -784,7 +784,7 @@ fn display_frames_are_mipmapped() { view_formats: &[], }); let mut e = crate::context::Enc::new(&g.ctx); - e.encoder().copy_texture_to_texture( + e.record(&[]).copy_texture_to_texture( wgpu::TexelCopyTextureInfo { texture: &f.texture, mip_level: 1, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, wgpu::TexelCopyTextureInfo { texture: &tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, wgpu::Extent3d { width: w1, height: h1, depth_or_array_layers: 1 }, @@ -920,7 +920,7 @@ fn a_failed_frame_returns_no_texture_to_the_pool() { let rejected = rejected_texture(&g); let earlier = g.within_memory(|| crate::context::Enc::new(&g.ctx).image(48, 16)).expect("an earlier frame"); let mut other = crate::context::Enc::new(&g.ctx); - other.encoder(); + other.record(&[]); g.within_memory(|| (crate::context::Enc::new(&g.ctx).image(64, 32), rejected.create_view(&Default::default()))).expect_err("the frame fails"); drop(earlier); drop(other); @@ -1315,3 +1315,274 @@ fn owned_devices_favour_memory_usage() { assert_eq!(d.label, Some("effectcraft gpu")); assert_eq!(d.required_limits.max_texture_dimension_2d, wgpu::Limits::default().max_texture_dimension_2d); } + +/// A GPU context of its own with a small pool budget, so other tests (sharing [`gpu`]) neither +/// see the budget nor add to what the pool keeps. +fn private_gpu(budget: usize) -> Option { + gpu()?; + let g = Gpu::headless()?; + g.context().set_pool_budget(budget); + Some(g) +} + +/// Three effect calls while a frame encoder is open with recorded work reuse one set of +/// textures: lent calls give them back at once, without waiting for that frame to submit. +#[test] +fn three_effect_calls_in_a_frame_reuse_one_set_of_textures() { + let Some(g) = private_gpu(8 << 20) else { return }; + let ctx = g.context(); + let mut frame = crate::context::Enc::new(ctx); + // Open the frame encoder with real work so the pool has an open id (an empty Enc::new does not). + let src = frame.image(64, 64); + let dst = frame.image(64, 64); + frame.copy_into(&src, &dst, 0, 0); + let (hits0, misses0) = ctx.pool_stats(); + for _ in 0..3 { + let mut e = crate::context::Enc::lent(ctx); + let a = e.image(256, 256); + let b = e.image(256, 256); + drop((a, b)); + drop(e); + } + let (hits, misses) = (ctx.pool_stats().0 - hits0, ctx.pool_stats().1 - misses0); + assert_eq!(misses, 2, "only the first effect call allocates: {hits} hits, {misses} misses"); + assert_eq!(hits, 4, "calls 2 and 3 reuse both textures: {hits} hits, {misses} misses"); + frame.submit(); + drop((src, dst, frame)); +} + +/// Review of #1523: after the first frame fills the free list, later identical frames hit +/// instead of allocating again. +#[test] +fn a_repeated_frame_reuses_after_the_first() { + let Some(g) = private_gpu(8 << 20) else { return }; + let ctx = g.context(); + let frame = || { + let mut e = crate::context::Enc::lent(ctx); + drop((e.image(256, 256), e.image(128, 64))); + drop(e); + ctx.pool_frame_end(); + }; + let (hits0, misses0) = ctx.pool_stats(); + frame(); + let (hits1, misses1) = (ctx.pool_stats().0 - hits0, ctx.pool_stats().1 - misses0); + assert_eq!(misses1, 2, "first frame allocates both sizes: {hits1} hits, {misses1} misses"); + let (hits_a, misses_a) = ctx.pool_stats(); + for _ in 0..3 { + frame(); + } + let (hits, misses) = (ctx.pool_stats().0 - hits_a, ctx.pool_stats().1 - misses_a); + assert_eq!(misses, 0, "frames 2..4 allocate nothing: {hits} hits, {misses} misses"); + assert!(hits >= 6, "frames 2..4 reuse both sizes: {hits} hits"); +} + +/// Review of #1523: every idle effect-call owner kept up to the pool budget of its own, +/// outside the shared accounting, and there are as many as calls ever ran at once (16 frame +/// threads: ~12 GB after a 4K render). Finished calls share the free list's one budget, and +/// the end of a render drops it. +#[test] +fn idle_effect_calls_share_one_pool_budget() { + let budget = 8 << 20; + let Some(g) = private_gpu(budget) else { return }; + let ctx = g.context(); + // 16 effect calls in flight at once, each with three 1 MB working textures. + let mut encs: Vec<_> = (0..16).map(|_| crate::context::Enc::lent(ctx)).collect(); + for e in &mut encs { + let imgs: Vec<_> = (0..3).map(|_| e.image(256, 256)).collect(); + drop(imgs); + } + drop(encs); + let kept = ctx.pool_retained_bytes(); + assert!(kept <= budget, "idle effect calls keep {} MB, budget {} MB", kept >> 20, budget >> 20); + // A later call reuses them instead of allocating. + let (hits, _) = ctx.pool_stats(); + drop(crate::context::Enc::lent(ctx).image(256, 256)); + assert_eq!(ctx.pool_stats().0, hits + 1, "a later effect call reuses a kept texture"); + effectcraft_render::Accelerator::render_end(&g); + assert_eq!(ctx.pool_retained_bytes(), 0, "the end of a render drops the kept textures"); +} + +/// Review of #1523: a full free list rejected every new texture instead of evicting, so once +/// sizes that no longer occur (per-frame blur bounds, padded layer sizes) filled it, a steady +/// size never got a hit again: ~0.75 GB of dead weight plus an allocation per working texture. +#[test] +fn stale_sizes_do_not_starve_a_steady_size() { + let budget = 8 << 20; + let Some(g) = private_gpu(budget) else { return }; + let ctx = g.context(); + for k in 0..16 { + let mut e = crate::context::Enc::lent(ctx); + drop((e.image(256 + k, 256), e.image(256 + k, 256))); + drop(e); + ctx.pool_frame_end(); + } + let (hits0, misses0) = ctx.pool_stats(); + for _ in 0..10 { + let mut e = crate::context::Enc::lent(ctx); + drop((e.image(300, 300), e.image(300, 300))); + drop(e); + ctx.pool_frame_end(); + } + let (hits, misses) = (ctx.pool_stats().0 - hits0, ctx.pool_stats().1 - misses0); + assert!(ctx.pool_retained_bytes() <= budget); + assert!(ctx.pool_evictions() > 0, "the stale sizes were evicted"); + assert!(hits >= 18 && misses <= 2, "steady 300x300 calls after stale sizes: {hits} hits, {misses} misses"); +} + +/// Review of #1523: a `pool_frame_end` wait that clears none of the evicted bytes (timeout, or +/// callbacks that never run) must not re-wait on every later frame while `dying` stays put. +#[test] +fn a_wait_without_progress_is_not_repeated() { + let Some(g) = private_gpu(8 << 20) else { return }; + let ctx = g.context(); + let phantom = 1 << 20; + ctx.pool_charge_dying(phantom as isize); + ctx.pool_frame_end(); + assert_eq!(ctx.pool_waits(), 1, "first frame with dying bytes waits once"); + for _ in 0..3 { + ctx.pool_frame_end(); + } + assert_eq!(ctx.pool_waits(), 1, "no progress: later frames must not wait again"); + ctx.pool_charge_dying(-(phantom as isize)); + ctx.pool_frame_end(); + assert_eq!(ctx.pool_waits(), 1, "dying cleared: poll only, stall cleared"); + ctx.pool_charge_dying(phantom as isize); + ctx.pool_frame_end(); + assert_eq!(ctx.pool_waits(), 2, "a new stall waits once"); + // Partial progress: more dying while stalled → still no wait; drop below stalled → wait. + let extra = phantom / 2; + ctx.pool_charge_dying(extra as isize); + ctx.pool_frame_end(); + assert_eq!(ctx.pool_waits(), 2, "dying above stalled: poll only"); + ctx.pool_charge_dying(-(extra as isize + 1)); + ctx.pool_frame_end(); + assert_eq!(ctx.pool_waits(), 3, "dying dropped below stalled: wait again"); + ctx.pool_charge_dying(-((phantom - 1) as isize)); +} + +/// RTX 3090 4K runs of #1523: evicting the least recently freed texture to fit every newcomer +/// thrashes when a frame frees more than the budget in a repeating order (blur/glow +/// intermediates): each free evicts the size the next call takes first, so every take +/// missed (d_blurglow misses back at the old level, and evict + reallocate raised the peak). +/// Sizes still in use stay; a kept subset of the cycle must keep hitting. +#[test] +fn repeating_sizes_over_the_budget_keep_hitting() { + let budget = 8 << 20; + let Some(g) = private_gpu(budget) else { return }; + let ctx = g.context(); + // Five ~2.1 MB sizes a frame (10.5 MB > 8 MB), one effect call each, same order every frame. + let sizes: Vec<(u32, u32)> = (0..5).map(|k| (256 + 8 * k, 512)).collect(); + let frame = || { + for &(w, h) in &sizes { + drop(crate::context::Enc::lent(ctx).image(w, h)); + } + ctx.pool_frame_end(); + }; + for _ in 0..3 { + frame(); + } + let (hits0, misses0) = ctx.pool_stats(); + let evicted0 = ctx.pool_evictions(); + for _ in 0..10 { + frame(); + } + let (hits, misses) = (ctx.pool_stats().0 - hits0, ctx.pool_stats().1 - misses0); + assert!(ctx.pool_retained_bytes() <= budget); + assert!(hits >= 30, "10 frames of a 5-size cycle over the budget: {hits} hits, {misses} misses"); + assert_eq!(ctx.pool_evictions(), evicted0, "sizes in use were evicted"); +} + +/// Review of #1523: a lent call's image read by another, still open encoder must not go back to +/// the lent call for reuse when its lease drops first: that encoder's read is not submitted yet. +#[test] +fn image_read_by_an_open_encoder_is_not_reused_by_its_lent_call() { + let Some(g) = private_gpu(8 << 20) else { return }; + let ctx = g.context(); + let mut frame = crate::context::Enc::new(ctx); + let mut lent = crate::context::Enc::lent(ctx); + let img = lent.image(64, 64); + let dst = frame.image(64, 64); + frame.copy_into(&img, &dst, 0, 0); + drop(img); + let (hits, _) = ctx.pool_stats(); + drop(lent.image(64, 64)); + assert_eq!(ctx.pool_stats().0, hits, "the lent call reused a texture an open encoder still reads"); + drop(lent); + let (hits, _) = ctx.pool_stats(); + drop(crate::context::Enc::lent(ctx).image(64, 64)); + // The other 64x64 (the lent call's second image) is free; the read one still waits. + let (hits2, _) = ctx.pool_stats(); + assert_eq!(hits2, hits + 1); + let (h, _) = ctx.pool_stats(); + let mut e = crate::context::Enc::lent(ctx); + let a = e.image(64, 64); + let b = e.image(64, 64); + assert_eq!(ctx.pool_stats().0, h + 1, "the read texture waits for the open encoder"); + drop((a, b, e)); + frame.submit(); + drop(dst); +} + +/// The same through [`Gpu::effects`] from 16 threads at once, as an export's frame threads. +#[test] +fn effect_calls_on_16_threads_keep_one_pool_budget() { + if on_gl() { + return; // one context lock per adapter on wgpu's GL backend (see hold_gpu_lock) + } + let budget = 8 << 20; + let Some(g) = private_gpu(budget) else { return }; + let g = &g; + let ctx = g.context(); + let spec = effectcraft_effects::find("ec.blur.gaussian").unwrap(); + let size = [240.0, 240.0]; + let mut params = + effectcraft_effects::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, size))).collect() }; + params.values.insert("blurriness".into(), Value::Scalar(6.0)); + let buf = effectcraft_effects::Buf { img: pattern(3, 240, 240), offset: [0.0, 0.0], scale: 1.0 }; + let go = std::sync::Barrier::new(16); + std::thread::scope(|s| { + for i in 0..16 { + let (params, buf, go) = (¶ms, &buf, &go); + s.spawn(move || { + go.wait(); + for _ in 0..3 { + let fx = effectcraft_effects::EffectCtx { params, time: 0.0, layer_size: size, seed: i, adjustment: false, env: Default::default() }; + effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: fx }], buf, None).expect("the GPU runs the effect"); + } + }); + } + }); + let kept = ctx.pool_retained_bytes(); + assert!(kept <= budget, "16 threads of effect calls keep {} MB, budget {} MB", kept >> 20, budget >> 20); + effectcraft_render::Accelerator::render_end(g); + assert_eq!(ctx.pool_retained_bytes(), 0, "the end of a render drops the kept textures"); +} + +/// Sceptic r2 of #1523: only the export pipeline ended frames, so off export (preview, remote +/// frames) the pool's clock stood still, no size ever went stale and a full free list dropped +/// every newcomer again. Frames rendered through [`effectcraft_render::passes::comp_frame`] +/// (no explicit `pool_frame_end`) must age the sizes they did not ask for. +#[test] +fn frames_outside_the_export_pipeline_age_the_pool() { + let budget = 8 << 20; + let Some(g) = private_gpu(budget) else { return }; + let ctx = g.context(); + // Fill the free list with sizes no frame asks for again. + for k in 0..16 { + let mut e = crate::context::Enc::lent(ctx); + drop((e.image(256 + k, 256), e.image(256 + k, 256))); + } + let evicted0 = ctx.pool_evictions(); + let s = blend_scene(BitDepth::Bpc8, BlendMode::Screen); + let mut r = Renderer::new(&s.p, &Pattern, opts()); + r.accel = Some(&g); + for _ in 0..3 { + effectcraft_render::passes::block_on(effectcraft_render::passes::comp_frame(&r, s.cid, Tick::ZERO)); + } + for _ in 0..4 { + let mut e = crate::context::Enc::lent(ctx); + drop((e.image(300, 300), e.image(300, 300))); + } + assert!(ctx.pool_retained_bytes() <= budget); + assert!(ctx.pool_evictions() > evicted0, "rendered frames did not age the pool: stale sizes were never evicted"); +} diff --git a/crates/gpu/src/yuv.rs b/crates/gpu/src/yuv.rs index aba3ec700..27df90110 100644 --- a/crates/gpu/src/yuv.rs +++ b/crates/gpu/src/yuv.rs @@ -130,7 +130,7 @@ pub(crate) fn record(e: &mut Enc, yp: &YuvPipe, img: &GpuImage, bg: [f32; 3]) -> ], }); { - let enc = e.encoder(); + let enc = e.record(&[img]); { let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("yuv420"), timestamp_writes: None }); pass.set_pipeline(&yp.pipe); diff --git a/crates/render/src/accel.rs b/crates/render/src/accel.rs index e6bbd2113..9b7d29ec6 100644 --- a/crates/render/src/accel.rs +++ b/crates/render/src/accel.rs @@ -96,6 +96,18 @@ pub trait Accelerator: Send + Sync { /// results, `pass_begin` clears the miss. Synchronous accelerators never miss. fn frame_begin(&self) {} fn pass_begin(&self) {} + /// A rendered comp frame is done (the GPU pool ages sizes no frame asks for, and evicts + /// only those once full). The multi-frame render paths call it once per comp frame: + /// [`crate::passes::comp_frame`] (exports; a field-rendered output frame is two), remote frames + /// and the preview workers. One-off renders (`Session::render`, single-frame export, + /// thumbnails) don't, so they don't advance the pool's clock. Without it the pool never + /// finds a stale size and keeps its first sizes, dropping newcomers. + fn frame_end(&self) {} + /// A render of many frames (an export) is done: drop what is kept only to speed up the + /// next frame (pooled working textures). Exports call it, even when they fail, panic or + /// are dropped; other multi-frame paths (preview, remote frames) don't, so there the pool + /// keeps up to its budget until the next export ends. + fn render_end(&self) {} fn pass_missed(&self) -> bool { false } diff --git a/crates/render/src/passes.rs b/crates/render/src/passes.rs index 5f0373b84..37c94fff4 100644 --- a/crates/render/src/passes.rs +++ b/crates/render/src/passes.rs @@ -59,11 +59,25 @@ pub async fn in_passes<'a, T>(accel: Option<&'a dyn Accelerator>, mut render: im Passed { value: render(None), passes: MAX_PASSES + 1, accelerated: false } } +/// Ends the frame for the accelerator ([`Accelerator::frame_end`]) when dropped: also when the +/// render panics or the future is dropped between passes. +struct FrameEnd<'a>(&'a dyn Accelerator); +impl Drop for FrameEnd<'_> { + fn drop(&mut self) { + self.0.frame_end(); + } +} + /// A top-level comp frame through [`in_passes`] ([`Renderer::comp_frame`] in passes). Backend /// Auto picks CPU or GPU for the whole frame (all its passes) and is timed as a whole, as the -/// frame workers do. +/// frame workers do. Ends the frame for the accelerator ([`Accelerator::frame_end`]) on every +/// exit, including a panic. pub async fn comp_frame(r: &Renderer<'_>, comp: ItemId, t: Tick) -> Image { - let Some(a) = r.accel.filter(|a| a.miss_gate().is_some()) else { return r.comp_frame(comp, t) }; + let Some(a) = r.accel else { return r.comp_frame(comp, t) }; + let _end = FrameEnd(a); + if a.miss_gate().is_none() { + return r.comp_frame(comp, t); + } let mut opts = r.opts; let mut auto = None; if opts.backend == Backend::Auto { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 865a4f59f..c77fc2d94 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -838,6 +838,50 @@ impl crate::Accelerator for ProbeAccel { } } +/// Counts [`Accelerator::frame_end`] and optionally panics inside [`Accelerator::comp_frame`]. +struct EndAccel { + ends: std::sync::atomic::AtomicUsize, + panic: std::sync::atomic::AtomicBool, +} +impl crate::Accelerator for EndAccel { + fn name(&self) -> String { + "end".into() + } + fn comp_frame(&self, _r: &crate::Renderer, _comp: ItemId, _t: Tick) -> Option { + if self.panic.load(std::sync::atomic::Ordering::SeqCst) { + panic!("comp_frame"); + } + Some(crate::Image::new(1, 1)) + } + fn supports_effect(&self, _id: &str) -> bool { + false + } + fn effects(&self, _chain: &[crate::FxStep], _buf: &crate::Buf, _levels: Option) -> Option { + None + } + fn frame_end(&self) { + self.ends.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } +} + +#[test] +fn comp_frame_ends_the_frame_when_the_render_panics() { + use std::panic::{AssertUnwindSafe, catch_unwind}; + use std::sync::atomic::Ordering; + let (mut p, cid, comp) = setup(); + let l = solid(&mut p, &comp, [1.0, 0.0, 0.0], 50, 50); + p.comp_mut(cid).unwrap().layers.push(l); + let a = EndAccel { ends: Default::default(), panic: true.into() }; + let mut r = crate::Renderer::new(&p, &crate::NoFootage, crate::RenderOpts { backend: crate::Backend::Gpu, ..Default::default() }); + r.accel = Some(&a); + assert!(catch_unwind(AssertUnwindSafe(|| crate::passes::block_on(crate::passes::comp_frame(&r, cid, Tick::ZERO)))).is_err()); + assert_eq!(a.ends.load(Ordering::SeqCst), 1); + a.panic.store(false, Ordering::SeqCst); + a.ends.store(0, Ordering::SeqCst); + let _ = crate::passes::block_on(crate::passes::comp_frame(&r, cid, Tick::ZERO)); + assert_eq!(a.ends.load(Ordering::SeqCst), 1); +} + #[test] fn auto_backend_sends_2d_and_3d_comps_to_the_gpu() { let (mut p, cid, comp) = setup(); diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 87cd9a17b..9aaaedbc7 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -1382,10 +1382,15 @@ impl Worker { /// `None` if that panicked too. fn render_guarded(&self, job: &Job) -> Option { let attempt = |gpu: bool| std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.render_job(job, gpu))).ok(); - attempt(true).or_else(|| { + let img = attempt(true).or_else(|| { log::error!("a frame render panicked (frame {})", job.key.frame); job.src.gpu.as_ref().and_then(|_| attempt(false)) - }) + }); + // A preview frame is done: the GPU pool ages the sizes it did not ask for. + if let Some(g) = &job.src.gpu { + effectcraft_engine::render::Accelerator::frame_end(g); + } + img } /// A failed scoped attempt retries on CPU. Retired contexts cannot recover by reducing @@ -1418,7 +1423,9 @@ impl Worker { r.cache = Some(&job.src.layer_cache); r.accel = Some(g as &dyn effectcraft_engine::render::Accelerator); r.active_accel()?; - match g.within_memory(|| g.render_display(&r, job.comp, job.t)) { + let f = g.within_memory(|| g.render_display(&r, job.comp, job.t)); + effectcraft_engine::render::Accelerator::frame_end(g); + match f { Ok(f) => f.map(|f| FrameImage::Gpu(Arc::new(f))), Err(e) => { self.gpu_attempt_failed(g, &e); From 6df7eabe05b175725f1212f46ab092bf323e1da5 Mon Sep 17 00:00:00 2001 From: Matej <100806068+MatejKozlovsky@users.noreply.github.com> Date: Tue, 3 Nov 2026 21:46:16 +0100 Subject: [PATCH 67/71] =?UTF-8?q?gpu:=20memory=20back-pressure=20for=20fra?= =?UTF-8?q?mes=20in=20flight=20(fixes=20OOM=20at=203=E2=80=934=20frames=20?= =?UTF-8?q?in=20flight)=20(#1550)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * GPU: free effect working textures after each call instead of at frame end A GPU frame keeps its encoder open while the CPU renderer runs layers with GPU effects (Gpu::effects -> run_chain). Each call's Enc handed its textures back to the shared pool, where they waited for every open encoder, so all effect intermediates of a frame stayed allocated until the frame ended (17.7 GB by layer 17 of a heavy 4K comp, then OOM). Effect calls now borrow a pool owner (Enc::lent) that is given back once the call's work is submitted or discarded; its kept textures are trimmed to the pool budget, so the rest are freed after each call. * GPU: one pool budget for idle effect calls, trimmed at the end of a render Review of #1523: each idle lent owner kept up to POOL_BUDGET of textures of its own, outside the shared accounting, and there were as many idle owners as effect calls ever ran at once (16 frame threads: ~12 GB after a 4K render). Giving a lent owner back now moves its kept textures to the shared free list, so one POOL_BUDGET bounds what the free list and every finished effect call keep. They skip Pool::release's wait for open encoders: only the lent Enc recorded work on them, and that work is submitted or discarded. Each lend is a fresh owner id, so a lease that outlives its call (an image handed to another encoder) is released through Pool::release; this does not rely on the result being read back. Accelerator::render_end (called when an export ends) drops the kept textures. * GPU: evict the pool's oldest free textures, guard escaped leases Review of #1523 (follow-up to 0a884fb6): - Pool::make_free rejected a texture when the free list was full and never evicted, so once sizes that no longer occur (per-frame blur bounds, padded layer sizes) filled the budget, every later free was a drop and every take an allocation. It now evicts the least recently freed textures until the new one fits. - A lease read by another Enc (dispatch, copy, readback, display) or handed to a cache is marked escaped and always released through the guarded Pool::release, so a lent call can no longer reuse a texture that a still open encoder reads. - Docs: POOL_BUDGET caps the free list only; textures waiting for open encoders and those of live Encs are outside it. - export_async calls render_end from a drop guard, so the pool is also trimmed when an export panics or its future is dropped. - Tests use a private context with its own pool budget instead of a global override; new tests for stale sizes and an image read by an open encoder. * GPU: evict only stale pool sizes, count evicted textures until the GPU frees them Evicting the least recently freed texture for every newcomer thrashed when a frame frees more than the budget in a repeating order (blur/glow intermediates): each free evicted the size the next call takes first, so misses came back to the old level and evict + reallocate raised the peak. The free list now evicts only sizes nobody asked for in the last 2 output frames (Accelerator::frame_end, called by the export per frame); otherwise the incoming texture is dropped and the kept set keeps hitting. Evicted bytes count against the budget until the work submitted so far is done, so evictions run at most one budget ahead of the GPU. * GPU: lower the shared pool budget to 256 MB On 4K RTX 3090 runs 256 MB matches the old code's peak reserved on d_blurglow (9204 MB) and is 256-512 MB above it on c_effects (6592 vs 6080 MB), while staying faster. * GPU: end frames on the multi-frame render paths, route recording through Enc::record Only the export pipeline called frame_end, so off export (preview, remote frames) the pool's clock stood still, no size went stale and a full free list dropped every newcomer. passes::comp_frame (exports), remote frames and the preview workers now end each frame; test without pool_frame_end. Docs: the frame clock is coarse with many frames in flight; dying only throttles eviction, approximately; no cache path escapes images. Enc::record is the single way to get the encoder outside context.rs and marks the images it names (Enc::uses). * GPU: cap in-flight frame memory on Vulkan and DX12 (#1550) wgpu destroys a dropped texture or buffer only once the submissions that used it have completed and the device is maintained. With several 4K frames in flight on an export or effect-heavy render, frames can record and drop working textures faster than the GPU retires them, accumulating freed-but-not-destroyed allocations until a 4-8 GB adapter runs out of memory (#879). Add a memory gate (MemCap) checked every 8 effect steps or layers: - Default cap of 3 GiB on discrete GPUs (low enough for 4-8 GB cards while leaving headroom above the 1.5 GiB upload cache and 256 MB shared pool) and 2 GiB on integrated/software adapters, overridable via EFFECTCRAFT_GPU_MEMCAP_MB (0 disables). - Compare total_allocated_bytes against non-reclaimable bytes (upload cache, pool free list, live leases, staging, zeros) with hysteresis and a post-wait floor so the gate only drains when a wait can free dropped allocations and does not spin when caches or other open encoders hold memory over the cap. - Submit and wait on the current Enc's own SubmissionIndex via a dedicated poll_wait helper (serialized by poll_lock) so a timeout simply carries on without retiring the context or its readback pool. * GPU: warn on invalid EFFECTCRAFT_GPU_MEMCAP_MB, register crop sources, require GPU in the mem-cap child test - memcap: an EFFECTCRAFT_GPU_MEMCAP_MB value that is not a whole number of MiB or overflows 64 bits now logs a warning and uses the default cap (it used to fall back silently, or wrap on overflow). Unset still means the default and 0 still turns the gate off. - Enc::crop records through Enc::record, so a foreign source lease is marked escaped like in copy_into. - The mem-cap child test starts its GPU through require_gpu, so EFFECTCRAFT_REQUIRE_GPU=1 fails when the GPU does not start, and prints a skip line (with the adapter name when the backend has no allocator report), which the parent passes on. * GPU: do not repeat the pool frame-end wait until evicted textures drain A wait that frees none of the evicted bytes (timeout, or a WebGPU poll that does not block) marks the pool stalled; later frames only poll until dying drops. Test covers the no-repeat and partial-progress cases; dying decrement saturates. * Remote frames: end a pending frame that a new render replaces A render arriving while a multi-pass frame waits for readbacks dropped that frame without `frame_end`, so the GPU pool's clock skipped it. Errors return before `frame_begin`. * Render: end the frame even when a comp frame panics `passes::comp_frame` called `frame_end` only after a normal return, so a frame that panicked (or whose future was dropped between passes) never aged the GPU pool. * GPU memcap: warn once when kept memory alone is over the cap; test the edge frames A frame whose non-reclaimable memory exceeds the cap never waits when reclaimable bytes stay below the hysteresis (waiting cannot free the kept set) and now logs that once. Tests cover a frame over the cap, two frames that fit one at a time, and a dropped frame giving its memory back. --------- Co-authored-by: Matej Kozlovský Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- crates/export/src/pipeline.rs | 5 +- crates/export/tests/it/h264_gpu_yuv.rs | 18 +- crates/gpu/src/context.rs | 223 ++++++++++++++++++++--- crates/gpu/src/effects.rs | 2 + crates/gpu/src/lib.rs | 1 + crates/gpu/src/memcap.rs | 236 +++++++++++++++++++++++++ crates/gpu/src/tests.rs | 211 ++++++++++++++++++++++ crates/gpu/src/walk.rs | 6 + crates/render/src/accel.rs | 5 +- 9 files changed, 682 insertions(+), 25 deletions(-) create mode 100644 crates/gpu/src/memcap.rs diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index 8e8c90561..b0d1d96c9 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -391,11 +391,14 @@ impl<'a> Cx<'a> { } let t0 = Instant::now(); let r = self.renderer(); - let planes = r.active_accel()?.comp_frame_yuv420(&r, self.job.comp, t, comp.background)?; + let a = r.active_accel()?; + let planes = a.comp_frame_yuv420(&r, self.job.comp, t, comp.background)?; let (cw, ch) = (w.div_ceil(2) as usize, h.div_ceil(2) as usize); if planes.len() != w as usize * h as usize + 2 * cw * ch { return None; } + // A frame rendered off `passes::comp_frame` ends here; on a `None` the fallback renders and ends it. + a.frame_end(); if let Some(ft) = &self.frame_times { ft.lock().unwrap_or_else(|e| e.into_inner()).push((i, t0.elapsed().as_secs_f64())); } diff --git a/crates/export/tests/it/h264_gpu_yuv.rs b/crates/export/tests/it/h264_gpu_yuv.rs index 0f99a7b54..02963ef46 100644 --- a/crates/export/tests/it/h264_gpu_yuv.rs +++ b/crates/export/tests/it/h264_gpu_yuv.rs @@ -24,11 +24,21 @@ struct Fake { /// Frames asking for planes right now, and the most at once after the stop. asking: AtomicUsize, most_after_stop: AtomicUsize, + /// `Accelerator::frame_end` calls: one per rendered comp frame, planes or not. + frame_ends: AtomicUsize, } impl Fake { fn new(yuv: bool, deferred: bool, stop_after: usize) -> Self { - Fake { yuv, yuv_frames: AtomicUsize::new(0), deferred, stop_after, asking: AtomicUsize::new(0), most_after_stop: AtomicUsize::new(0) } + Fake { + yuv, + yuv_frames: AtomicUsize::new(0), + deferred, + stop_after, + asking: AtomicUsize::new(0), + most_after_stop: AtomicUsize::new(0), + frame_ends: AtomicUsize::new(0), + } } } @@ -54,6 +64,9 @@ impl Accelerator for Fake { } Some(effectcraft_raster::yuv::yuv420_over(&r.comp_frame_cpu(comp, t), bg)) } + fn frame_end(&self) { + self.frame_ends.fetch_add(1, Ordering::SeqCst); + } fn available(&self) -> bool { self.yuv_frames.load(Ordering::SeqCst) < self.stop_after } @@ -122,6 +135,8 @@ fn accelerator_planes_encode_like_the_cpu_conversion() { let b = run(&p, cid, Some(&rgba), &s, &d.join("rgba.mp4")); let c = run(&p, cid, None, &s, &d.join("cpu.mp4")); assert_eq!(yuv.yuv_frames.load(Ordering::SeqCst), 20, "every frame came from the accelerator"); + assert_eq!(yuv.frame_ends.load(Ordering::SeqCst), 20, "every planes frame ends once"); + assert_eq!(rgba.frame_ends.load(Ordering::SeqCst), 20, "every RGBA frame ends once"); assert!(a == b && b == c, "the three files differ"); let f = effectcraft_media::probe(&yuv_mp4).expect("probe yuv.mp4"); assert_eq!((f.width, f.height), (64, 48)); @@ -144,6 +159,7 @@ fn frames_the_accelerator_cannot_take_fall_back_to_rgba() { let a = run(&p, cid, Some(&yuv), &odd, &d.join("yuv.mp4")); let c = run(&p, cid, None, &odd, &d.join("cpu.mp4")); assert_eq!(yuv.yuv_frames.load(Ordering::SeqCst), 0); + assert_eq!(yuv.frame_ends.load(Ordering::SeqCst), 6, "every fallback frame ends once"); assert!(a == c); } diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 2c9a40735..543c79283 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -117,6 +117,9 @@ impl Drop for Lease { if let Some(pool) = self.pool.upgrade() && let Ok(mut p) = pool.lock() { + // Always undo `pooled`'s leased count first: `Pool::discard` does not reset + // `leased`, and a stale-generation lease must still clear its contribution. + p.leased = p.leased.saturating_sub(tex_bytes(&self.texture)); if !self.escaped.load(std::sync::atomic::Ordering::Relaxed) { p.release_owned(self.texture.clone(), self.owner, self.generation); } else if self.generation == p.generation { @@ -148,6 +151,8 @@ pub(crate) struct Pool { open: BTreeSet, next: u64, bytes: usize, + /// Bytes currently held by live [`Lease`]s. + leased: usize, /// Working textures reused / newly allocated (diagnostics, tests). hits: u64, misses: u64, @@ -462,6 +467,44 @@ impl Pool { } } + /// Moves `owner`'s returned working textures onto the shared free list (dropping any over + /// budget) right after `owner`'s encoder has been submitted, while keeping `owner` registered. + fn flush_owned(&mut self, owner: u64) { + let Some(m) = self.owned.get_mut(&owner).map(std::mem::take) else { return }; + for t in m.into_values().flatten() { + self.make_free(t); + } + } + + /// Bytes the pool will still keep alive (live `Lease`s, other owners' in-flight working sets, + /// released textures still guarded by other open encoders, and up to `budget` on the shared + /// free list) once `owner` flushes its returned textures and `closing_enc` submits. + fn kept_after_flush(&self, owner: u64, closing_enc: Option) -> usize { + let oldest = self.open.iter().copied().find(|&o| Some(o) != closing_enc).unwrap_or(u64::MAX); + let mut ready = 0usize; + let mut still_released = 0usize; + for (t, w) in &self.released { + let b = tex_bytes(t); + if *w < oldest { + ready += b; + } else { + still_released += b; + } + } + let mut self_owned = 0usize; + let mut other_owned = 0usize; + for (&k, m) in &self.owned { + let b: usize = m.values().flatten().map(tex_bytes).sum(); + if k == owner { + self_owned += b; + } else { + other_owned += b; + } + } + let free_after = self.bytes.max((self.bytes + ready + self_owned).min(self.budget())); + self.leased + other_owned + still_released + free_after + } + /// Bytes of working textures kept for reuse: the free list, those waiting for open /// encoders and those kept by live `Enc`s. fn retained(&self) -> usize { @@ -595,6 +638,11 @@ pub struct GpuContext { /// Export frame → YUV 4:2:0 kernel (built on first use). pub(crate) yuv: std::sync::OnceLock>, readbacks: Arc, + /// Serializes `device.poll` across threads (`wgpu-core 30.0.1` can panic on concurrent + /// `Device::maintain` when two callers poll the same device at once). Shared by every + /// context on the same device. + poll_lock: Arc>, + memcap: crate::memcap::MemCap, } fn tex_entry(binding: u32) -> wgpu::BindGroupLayoutEntry { @@ -923,7 +971,9 @@ impl GpuContext { init_resource(&device, "dummy storage buffer", || buffer_init(&device, &queue, Some("dummy"), &[0u8; 16], wgpu::BufferUsages::STORAGE))?; let dummy_tex = init_resource(&device, "dummy texture view", || dummy.create_view(&Default::default()))?; let max_dim = device.limits().max_texture_dimension_2d; - let pool = Arc::new(Mutex::new(Pool { queue: Some(queue.clone()), ..Default::default() })); + let memcap = crate::memcap::MemCap::new(&info); + let pool_budget = if memcap.cap() > 0 { POOL_BUDGET.min((memcap.cap() / 4) as usize) } else { 0 }; + let pool = Arc::new(Mutex::new(Pool { queue: Some(queue.clone()), budget: pool_budget, ..Default::default() })); Ok(GpuContext { device, queue, @@ -955,6 +1005,8 @@ impl GpuContext { particle_states: Default::default(), deferred: None, readbacks, + poll_lock: Default::default(), + memcap, }) } @@ -988,10 +1040,25 @@ impl GpuContext { crate::readback::failure_notifier(&self.readbacks) } + /// Maximum time a blocking poll holds `poll_lock` in one slice so concurrent readbacks are not + /// starved behind a long `mem_gate` or readback wait. + const POLL_SLICE: std::time::Duration = std::time::Duration::from_millis(20); + /// All device polling routes through this failure bridge. pub(crate) fn poll_readbacks(&self, kind: wgpu::PollType) -> Result { self.readbacks.check()?; - self.device.poll(kind).map_err(|e| { + let res = { + let _guard = match kind { + wgpu::PollType::Poll => match self.poll_lock.try_lock() { + Ok(g) => g, + Err(std::sync::TryLockError::Poisoned(e)) => e.into_inner(), + Err(std::sync::TryLockError::WouldBlock) => return Ok(wgpu::PollStatus::Poll), + }, + _ => self.poll_lock.lock().unwrap_or_else(|e| e.into_inner()), + }; + self.device.poll(kind) + }; + res.map_err(|e| { let error = format!("GPU readback poll: {e}"); self.readbacks.retire(error.clone()); log::error!("{error}"); @@ -999,23 +1066,73 @@ impl GpuContext { }) } - /// Waits on `submission_index` up to `timeout` so wgpu frees resources dropped before that - /// submission. A timeout simply moves on without retiring the context or its readbacks; any - /// other poll error retires the readbacks through the failure path. + /// Waits on `submission_index` up to `timeout` in [`Self::POLL_SLICE`] increments so wgpu + /// frees resources dropped before that submission without holding `poll_lock` for seconds at a + /// time. A timeout simply moves on without retiring the context or its readbacks; any other + /// poll error retires the readbacks through the failure path. fn poll_wait(&self, submission_index: wgpu::SubmissionIndex, timeout: std::time::Duration) { if self.readbacks.check().is_err() { return; } - match self.device.poll(wgpu::PollType::Wait { submission_index: Some(submission_index), timeout: Some(timeout) }) { - Ok(_) | Err(wgpu::PollError::Timeout) => {} - Err(e) => { - let error = format!("GPU poll wait: {e}"); - self.readbacks.retire(error.clone()); - log::error!("{error}"); + let deadline = web_time::Instant::now() + timeout; + loop { + let remaining = deadline.saturating_duration_since(web_time::Instant::now()); + let slice = remaining.min(Self::POLL_SLICE); + let res = { + let _guard = self.poll_lock.lock().unwrap_or_else(|e| e.into_inner()); + self.device.poll(wgpu::PollType::Wait { submission_index: Some(submission_index.clone()), timeout: Some(slice) }) + }; + let done = match res { + Ok(_) => true, + Err(wgpu::PollError::Timeout) => false, + Err(e) => { + let error = format!("GPU poll wait: {e}"); + self.readbacks.retire(error.clone()); + log::error!("{error}"); + true + } + }; + if done || remaining <= slice { + break; } } } + /// Bytes held by wgpu's device allocator (`0` on backends that do not report). + pub(crate) fn allocated_bytes(&self) -> u64 { + self.device.generate_allocator_report().map_or(0, |r| r.total_allocated_bytes) + } + + /// Bytes held in long-lived or still-referenced GPU caches/leases that submitting and waiting + /// on `owner` (`closing_enc`) cannot free. + fn kept_bytes(&self, owner: u64, closing_enc: Option) -> u64 { + let uploads = self.uploads.lock().map(|u| u.bytes as u64).unwrap_or(0); + let pool = self.pool.lock().map(|p| p.kept_after_flush(owner, closing_enc) as u64).unwrap_or(0); + let staging: u64 = self.staging.lock().map(|s| s.iter().map(|(k, v)| k * v.len() as u64).sum()).unwrap_or(0); + let zeros: u64 = self.zeros.lock().map(|z| z.keys().map(|&(w, h)| w as u64 * h as u64 * 16).sum()).unwrap_or(0); + uploads + pool + staging + zeros + } + + #[cfg(test)] + pub(crate) fn memcap_waits(&self) -> u64 { + self.memcap.waits() + } + + #[cfg(test)] + pub(crate) fn memcap_warned(&self) -> bool { + self.memcap.warned() + } + + #[cfg(test)] + pub(crate) fn set_memcap_wait_timeout(&self, timeout: std::time::Duration) { + self.memcap.set_wait_timeout(timeout); + } + + #[cfg(test)] + pub(crate) fn lock_poll_for_test(&self) -> std::sync::MutexGuard<'_, ()> { + self.poll_lock.lock().unwrap_or_else(|e| e.into_inner()) + } + /// Start a bounded buffer mapping. Retired contexts reject new requests immediately. pub(crate) fn map_readback( &self, @@ -1050,25 +1167,52 @@ impl GpuContext { }); } - /// A bounded native readback: polling and receiving share one deadline. + /// A bounded native readback: polling and receiving share one deadline, started once + /// `poll_lock` is first acquired and polled in [`Self::POLL_SLICE`] slices so lock contention + /// never consumes the readback deadline or holds the mutex across the whole timeout. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn read_buffer(&self, buffer: &wgpu::Buffer, row: usize, stride: usize, height: usize) -> Result, String> { - let deadline = std::time::Instant::now() + crate::readback::TIMEOUT; let (tx, rx) = std::sync::mpsc::channel(); self.map_readback(buffer, row, stride, height, move |r| { let _ = tx.send(r); }); + let mut deadline = None; loop { self.readbacks.check()?; - let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if let Ok(result) = rx.try_recv() { + return result; + } + let (remaining, slice, poll_res) = { + let _guard = self.poll_lock.lock().unwrap_or_else(|e| e.into_inner()); + self.readbacks.check()?; + let d = *deadline.get_or_insert_with(|| std::time::Instant::now() + crate::readback::TIMEOUT); + let remaining = d.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + (remaining, std::time::Duration::ZERO, Ok(wgpu::PollStatus::Poll)) + } else { + let slice = remaining.min(Self::POLL_SLICE); + let res = self.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(slice) }); + (remaining, slice, res) + } + }; + if let Ok(result) = rx.try_recv() { + return result; + } if remaining.is_zero() { let error = "GPU readback receive timed out; context retired".to_string(); self.readbacks.retire(error.clone()); return Err(error); } - // Block until the GPU is done (bounded by the deadline: a timeout retires the - // context) rather than polling and sleeping, which adds latency to every readback. - self.poll_readbacks(wgpu::PollType::Wait { submission_index: None, timeout: Some(remaining) })?; + match poll_res { + Ok(_) => {} + Err(wgpu::PollError::Timeout) if remaining > slice => {} + Err(e) => { + let error = format!("GPU readback poll: {e}"); + self.readbacks.retire(error.clone()); + log::error!("{error}"); + return Err(error); + } + } // The callback may be running on another thread that polled the same device. match rx.recv_timeout(remaining.min(std::time::Duration::from_millis(1))) { Ok(result) => return result, @@ -1207,6 +1351,8 @@ impl GpuContext { readbacks: self.readbacks.clone(), software: self.software, yuv: std::sync::OnceLock::new(), + poll_lock: self.poll_lock.clone(), + memcap: self.memcap.fresh(), } } @@ -1253,7 +1399,16 @@ impl GpuContext { /// one is zeroed. fn pooled(&self, w: u32, h: u32, owner: u64) -> (GpuImage, bool) { let (w, h) = (w.max(1), h.max(1)); - let (reused, generation) = self.pool.lock().map(|mut p| (p.take_owned(owner, w, h).or_else(|| p.take(w, h)), p.generation)).unwrap_or((None, u64::MAX)); + let bytes = w as usize * h as usize * 16; + let (reused, generation) = self + .pool + .lock() + .map(|mut p| { + let t = p.take_owned(owner, w, h).or_else(|| p.take(w, h)); + p.leased += bytes; + (t, p.generation) + }) + .unwrap_or((None, u64::MAX)); 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), owner, escaped: Default::default(), generation }); @@ -1461,6 +1616,7 @@ pub(crate) struct Enc<'g> { pub g: &'g GpuContext, /// The open command encoder and its id in the texture pool. enc: Option<(wgpu::CommandEncoder, u64)>, + last_submit: Option, pending: usize, /// A dispatch named a kernel the device doesn't have (see [`Enc::take_missing`]). missing: bool, @@ -1496,7 +1652,7 @@ impl Drop for Enc<'_> { impl<'g> Enc<'g> { pub fn new(g: &'g GpuContext) -> Enc<'g> { let owner = g.pool.lock().map(|mut p| p.register()).unwrap_or(0); - Enc { g, enc: None, pending: 0, missing: false, owner, lent: false } + Enc { g, enc: None, last_submit: None, pending: 0, missing: false, owner, lent: false } } /// An `Enc` for self-contained work (an effect chain): once it is dropped, its working @@ -1508,7 +1664,7 @@ impl<'g> Enc<'g> { /// effect's intermediates stayed allocated until the frame ended. pub(crate) fn lent(g: &'g GpuContext) -> Enc<'g> { let owner = g.pool.lock().map(|mut p| p.lend()).unwrap_or(0); - Enc { g, enc: None, pending: 0, missing: false, owner, lent: true } + Enc { g, enc: None, last_submit: None, pending: 0, missing: false, owner, lent: true } } /// `img` is used by this `Enc`: if another `Enc` made it, that one must not reuse it at once @@ -1693,7 +1849,7 @@ impl<'g> Enc<'g> { /// Submit recorded work. pub fn submit(&mut self) { if let Some((enc, id)) = self.enc.take() { - self.g.queue.submit([enc.finish()]); + self.last_submit = Some(self.g.queue.submit([enc.finish()])); if let Ok(mut p) = self.g.pool.lock() { p.close(id); } @@ -1701,6 +1857,31 @@ impl<'g> Enc<'g> { self.pending = 0; } + /// Submits and waits on this encoder's own submission when the device allocator is over the + /// memory cap and enough of that memory is reclaimable, so wgpu frees textures dropped so far. + pub fn mem_gate(&mut self) { + if !self.g.memcap.due() { + return; + } + let allocated = self.g.allocated_bytes(); + if allocated <= self.g.memcap.cap() { + let _ = self.g.memcap.should_wait(allocated, 0); + return; + } + let closing = self.enc.as_ref().map(|(_, id)| *id); + let kept = self.g.kept_bytes(self.owner, closing); + if !self.g.memcap.should_wait(allocated, kept) { + return; + } + self.submit(); + if let Ok(mut p) = self.g.pool.lock() { + p.flush_owned(self.owner); + } + let idx = self.last_submit.take().unwrap_or_else(|| self.g.queue.submit([])); + self.g.poll_wait(idx, self.g.memcap.wait_timeout()); + self.g.memcap.record_wait(self.g.allocated_bytes(), self.g.kept_bytes(self.owner, None)); + } + /// Read an image back to the CPU (submits and waits). `None` where readback is impossible. pub fn download(&mut self, img: &GpuImage) -> Option { self.uses(img); diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 5a2b80f1c..b33c15e99 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -101,6 +101,7 @@ pub(crate) fn run_chain(e: &mut Enc, chain: &[FxStep], buf: &Buf, levels: Option e.take_missing(); for step in chain { b = apply(e, step.spec.id, &step.ctx, b)?; + e.mem_gate(); if e.take_missing() { return None; } @@ -158,6 +159,7 @@ impl effectcraft_render::FxTarget for GpuFx<'_, '_> { self.e.take_missing(); for step in steps { let Some(next) = apply(self.e, step.spec.id, &step.ctx, cur) else { return false }; + self.e.mem_gate(); // A kernel the device couldn't build: the effects render on the CPU (#613). if self.e.take_missing() { return false; diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index d0a688e49..1be095c3f 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -79,6 +79,7 @@ mod fx_tone; mod fx_transition; mod fx_vr; mod fx_warp; +mod memcap; mod ops; mod particles; mod readback; diff --git a/crates/gpu/src/memcap.rs b/crates/gpu/src/memcap.rs new file mode 100644 index 000000000..1c2ce6456 --- /dev/null +++ b/crates/gpu/src/memcap.rs @@ -0,0 +1,236 @@ +//! Memory back-pressure for frames in flight. +//! +//! wgpu destroys a texture or buffer we drop only once the submissions that used it have +//! completed, and pooled textures released while other frames' encoders are open stay pending +//! until then. With several 4K frames in flight, the frames record work faster than the GPU +//! retires it, and freed-but-not-destroyed memory runs the device out of memory (#879). Once the +//! allocator holds more than the cap and enough of that total is reclaimable (not held by the +//! upload cache, the texture pool, or live images), a frame submits its encoder and waits for its +//! own submission so wgpu frees what was dropped. Frames are not held back for memory that cannot +//! be reclaimed; a warning is logged once. +//! +//! wgpu has no safe query for the adapter's memory size (`max_buffer_size` is capped at `i32::MAX` +//! on Direct3D 12 and Linux/Mesa Vulkan), so the default cap is conservative per device type; +//! `EFFECTCRAFT_GPU_MEMCAP_MB` overrides it (0 = off). Backends without an allocator report +//! (Metal, WebGPU, GL) are never gated. + +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering::Relaxed}; +use std::time::Duration; + +/// Check the allocator only every this many gate calls (effect calls and layers): the report +/// lists every allocation, and between checks a frame adds at most a few textures. +const EVERY: u64 = 8; + +const GIB: u64 = 1 << 30; +const MIB: u64 = 1 << 20; + +/// Overrides the default cap: whole MiB, 0 turns the gate off. +const MEMCAP_ENV: &str = "EFFECTCRAFT_GPU_MEMCAP_MB"; + +pub(crate) struct MemCap { + cap: u64, + hysteresis: u64, + calls: AtomicU64, + waits: AtomicU64, + /// Reclaimable bytes remaining after the last wait: another wait fires only once reclaimable + /// memory grows by `hysteresis` above this floor (or drops below it first). + floor: AtomicU64, + /// Set after the first over-cap sample where non-reclaimable memory alone exceeds the cap. + warned: AtomicBool, + #[cfg(test)] + timeout_ns: AtomicU64, +} + +impl MemCap { + pub(crate) fn new(info: &wgpu::AdapterInfo) -> MemCap { + let env = std::env::var(MEMCAP_ENV).ok(); + Self::from_env(env.as_deref(), info.device_type) + } + + fn from_env(var: Option<&str>, device_type: wgpu::DeviceType) -> MemCap { + let cap = cap_from_env(var, device_type); + MemCap { + cap, + hysteresis: default_hysteresis(cap), + calls: AtomicU64::new(0), + waits: AtomicU64::new(0), + floor: AtomicU64::new(0), + warned: AtomicBool::new(false), + #[cfg(test)] + timeout_ns: AtomicU64::new(crate::readback::TIMEOUT.as_nanos() as u64), + } + } + + /// The same cap with fresh counters (a test context sharing another's device). + #[cfg(test)] + pub(crate) fn fresh(&self) -> MemCap { + MemCap { + cap: self.cap, + hysteresis: self.hysteresis, + calls: AtomicU64::new(0), + waits: AtomicU64::new(0), + floor: AtomicU64::new(0), + warned: AtomicBool::new(false), + timeout_ns: AtomicU64::new(self.timeout_ns.load(Relaxed)), + } + } + + pub(crate) fn cap(&self) -> u64 { + self.cap + } + + /// Whether to sample the allocator on this call. + pub(crate) fn due(&self) -> bool { + self.cap != 0 && self.calls.fetch_add(1, Relaxed).is_multiple_of(EVERY) + } + + /// Whether `allocated` bytes in the allocator with `kept` non-reclaimable bytes (upload + /// cache, pool free list, live frames) should trigger a submit and wait. + pub(crate) fn should_wait(&self, allocated: u64, kept: u64) -> bool { + if self.cap == 0 || allocated <= self.cap { + self.floor.store(0, Relaxed); + return false; + } + if kept > self.cap && !self.warned.swap(true, Relaxed) { + log::warn!( + target: "effectcraft_gpu", + "gpu: {} MiB held by live frames, the upload cache and the texture pool is over the {} MiB memory cap: waiting cannot free it, so frames are not held back for it ({MEMCAP_ENV} sets the cap)", + kept >> 20, + self.cap >> 20, + ); + } + let reclaimable = allocated.saturating_sub(kept); + let floor = self.floor.load(Relaxed); + if reclaimable < floor { + self.floor.store(reclaimable, Relaxed); + return false; + } + reclaimable >= floor.saturating_add(self.hysteresis) + } + + pub(crate) fn record_wait(&self, allocated_after: u64, kept_after: u64) { + self.floor.store(allocated_after.saturating_sub(kept_after), Relaxed); + let n = self.waits.fetch_add(1, Relaxed); + if n == 0 { + log::info!("gpu: allocator over the {} MiB memory cap, frames wait for the GPU to catch up", self.cap >> 20); + } + } + + pub(crate) fn wait_timeout(&self) -> Duration { + #[cfg(test)] + { + Duration::from_nanos(self.timeout_ns.load(Relaxed)) + } + #[cfg(not(test))] + { + crate::readback::TIMEOUT + } + } + + #[cfg(test)] + pub(crate) fn waits(&self) -> u64 { + self.waits.load(Relaxed) + } + + #[cfg(test)] + pub(crate) fn warned(&self) -> bool { + self.warned.load(Relaxed) + } + + #[cfg(test)] + pub(crate) fn set_wait_timeout(&self, timeout: Duration) { + self.timeout_ns.store(timeout.as_nanos() as u64, Relaxed); + } +} + +/// The cap from the `EFFECTCRAFT_GPU_MEMCAP_MB` value: whole MiB (0 turns the gate off), or the +/// default for `device_type` when unset, or (with a warning) when not a number or too large. +fn cap_from_env(var: Option<&str>, device_type: wgpu::DeviceType) -> u64 { + let Some(v) = var else { return default_cap(device_type) }; + match v.trim().parse::().ok().and_then(|mb| mb.checked_mul(MIB)) { + Some(cap) => cap, + None => { + let cap = default_cap(device_type); + log::warn!(target: "effectcraft_gpu", "{MEMCAP_ENV}={v:?} is not a whole number of MiB that fits in 64 bits: using {} MiB", cap / MIB); + cap + } + } +} + +/// Discrete cards: 3 GiB, low enough to fire on 4–8 GB cards with headroom for the driver and +/// desktop while leaving ~1 GiB above the default 1.5 GiB upload cache + 256 MB pool (larger +/// cards can raise it with `EFFECTCRAFT_GPU_MEMCAP_MB`). Integrated and software adapters share +/// system memory: 2 GiB. +fn default_cap(t: wgpu::DeviceType) -> u64 { + match t { + wgpu::DeviceType::DiscreteGpu => 3 * GIB, + _ => 2 * GIB, + } +} + +/// Minimum reclaimable bytes before a wait is worth draining an encoder (1/8 of the cap, +/// clamped between 1 MiB and 256 MiB, or 1/4 of caps smaller than 4 MiB). +fn default_hysteresis(cap: u64) -> u64 { + if cap == 0 { 0 } else { (cap / 8).clamp(MIB.min(cap / 4), 256 * MIB) } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_and_cadence() { + assert_eq!(default_cap(wgpu::DeviceType::DiscreteGpu), 3 * GIB); + assert_eq!(default_cap(wgpu::DeviceType::IntegratedGpu), 2 * GIB); + let m = MemCap::from_env(None, wgpu::DeviceType::IntegratedGpu); + assert_eq!(m.cap(), 2 * GIB); + let due = (0..2 * EVERY).filter(|_| m.due()).count(); + assert_eq!(due, 2); + let off = MemCap::from_env(Some("0"), wgpu::DeviceType::DiscreteGpu); + assert_eq!(off.cap(), 0); + assert!((0..EVERY).all(|_| !off.due())); + } + + #[test] + fn override_is_whole_mib_or_the_default() { + let cap = |v| MemCap::from_env(Some(v), wgpu::DeviceType::DiscreteGpu).cap(); + assert_eq!(cap("2048"), 2 * GIB); + assert_eq!(cap(" 8 "), 8 * MIB); + assert_eq!(cap("17592186044415"), 17592186044415 * MIB, "the largest value that fits"); + for bad in ["", "lots", "-1", "1.5", "17592186044416", "99999999999999999999"] { + assert_eq!(cap(bad), 3 * GIB, "{bad:?} falls back to the default"); + } + } + + #[test] + fn hysteresis_skips_non_reclaimable_and_unfreed_memory() { + let m = MemCap::from_env(Some("2048"), wgpu::DeviceType::IntegratedGpu); + // Upload cache + pool + live frames (2.15 GiB) sit over the 2 GiB cap with only 50 MiB + // reclaimable: waiting would free almost nothing, so the gate must stay quiet. + let kept = 2150 * MIB; + assert!(!m.should_wait(kept + 50 * MIB, kept)); + assert!(!m.should_wait(kept + 200 * MIB, kept)); + // Once 256 MiB of dropped textures pile up, the gate fires. + assert!(m.should_wait(kept + 256 * MIB, kept)); + // If a wait freed nothing (e.g. held by another thread's open encoder or timed out), + // the floor prevents firing again until another 256 MiB of reclaimable memory accumulates. + m.record_wait(kept + 256 * MIB, kept); + assert!(!m.should_wait(kept + 256 * MIB, kept)); + assert!(!m.should_wait(kept + 400 * MIB, kept)); + assert!(m.should_wait(kept + 512 * MIB, kept)); + // Once the other thread finishes and reclaimable memory drops, the floor drops with it. + assert!(!m.should_wait(kept + 16 * MIB, kept)); + assert!(m.should_wait(kept + (16 + 256) * MIB, kept)); + } + + #[test] + fn kept_memory_over_the_cap_warns_once_and_never_waits() { + let m = MemCap::from_env(Some("8"), wgpu::DeviceType::IntegratedGpu); + // Cap 8 MiB: kept alone over the cap with reclaimable under the hysteresis → no wait, warn once. + assert!(!m.should_wait(20 * MIB, 20 * MIB)); + assert!(m.warned()); + assert!(!m.should_wait(20 * MIB, 20 * MIB)); + assert!(!m.should_wait(20 * MIB, 19 * MIB + MIB / 2)); + assert!(m.should_wait(20 * MIB, 4 * MIB), "16 MiB reclaimable is over the hysteresis"); + } +} diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 28c27b5c7..ef8673b5f 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -1586,3 +1586,214 @@ fn frames_outside_the_export_pipeline_age_the_pool() { assert!(ctx.pool_retained_bytes() <= budget); assert!(ctx.pool_evictions() > evicted0, "rendered frames did not age the pool: stale sizes were never evicted"); } + +/// #1550: with a small `EFFECTCRAFT_GPU_MEMCAP_MB` on a backend that reports allocator usage +/// (Vulkan, DX12), `Enc::mem_gate` stays quiet when the bytes over the cap are non-reclaimable +/// (live uploads), submits and waits when dropped working textures push reclaimable bytes over +/// the cap, carries on without retiring the context if a wait times out, warns once when a +/// frame's kept memory alone exceeds the cap, waits between two frames that fit only one at a +/// time, and recovers budget when a frame is dropped unfinished. +#[test] +fn mem_gate_reclaims_over_cap_and_tolerates_timeout() { + const ENV: &str = "EFFECTCRAFT_GPU_MEMCAP_TEST_CHILD"; + const SKIP: &str = "mem_gate_reclaims_over_cap_and_tolerates_timeout: skipped"; + if std::env::var_os(ENV).is_none() { + let exe = std::env::current_exe().expect("test binary path"); + let out = std::process::Command::new(exe) + .env(ENV, "1") + .env("EFFECTCRAFT_GPU_MEMCAP_MB", "8") + .args(["--exact", "tests::mem_gate_reclaims_over_cap_and_tolerates_timeout", "--nocapture"]) + .output() + .expect("spawn child test process"); + assert!( + out.status.success(), + "child test failed (status {:?}):\nstdout:\n{}\nstderr:\n{}", + out.status.code(), + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + // The child's output is captured: pass its skip line on. + String::from_utf8_lossy(&out.stderr).lines().filter(|l| l.starts_with(SKIP)).for_each(|l| eprintln!("{l}")); + return; + } + + // EFFECTCRAFT_REQUIRE_GPU=1 (inherited from the parent) fails on a GPU that didn't start. + let g = Gpu::try_headless(); + let Some(g) = require_gpu("mem_gate child", g.as_ref()) else { + eprintln!("{SKIP}: no GPU ({})", g.as_ref().err().map_or("", String::as_str)); + return; + }; + let ctx = g.context(); + if ctx.allocated_bytes() == 0 { + // The backend has no allocator report (Metal, GL, WebGPU): the gate never engages. + eprintln!("{SKIP}: adapter {} reports no allocator usage", ctx.name); + return; + } + + // 1. Non-reclaimable memory (upload cache) over the 8 MiB cap must not trigger waits. + let img = pattern(7, 512, 512); // 4 MiB in RGBA f32 + let held: Vec<_> = (0..3) + .map(|i| { + let b = std::sync::Arc::new(effectcraft_effects::Buf { img: img.clone(), offset: [i as f64, 0.0], scale: 1.0 }); + ctx.upload_buf(&b).expect("upload"); + b + }) + .collect(); + let idx = ctx.queue.submit([]); + let _ = ctx.poll_readbacks(wgpu::PollType::Wait { submission_index: Some(idx), timeout: Some(std::time::Duration::from_secs(1)) }); + assert!(ctx.allocated_bytes() > (8 << 20), "uploads should put the allocator over the 8 MiB cap (got {} B)", ctx.allocated_bytes()); + { + let mut e = crate::context::Enc::new(ctx); + for _ in 0..16 { + e.mem_gate(); + } + } + assert_eq!(ctx.memcap_waits(), 0, "mem_gate must not wait when over-cap memory is held by live uploads"); + assert!(ctx.memcap_warned(), "uploads alone over the cap log the one-time warning"); + drop(held); + ctx.clear_uploads(); + ctx.pool_frame_end(); + + // 2. Dropped working textures over the 2 MiB pool budget and 8 MiB cap trigger a wait and + // free the dropped textures in wgpu's allocator. + let mut e = crate::context::Enc::new(ctx); + let mut peak_before_wait = 0u64; + for k in 0..16u32 { + let a = e.image(512 + k * 16, 256); // ~2 MiB each, distinct sizes + let b = e.image(512 + k * 16, 256); + e.copy_into(&a, &b, 0, 0); + drop((a, b)); + peak_before_wait = peak_before_wait.max(ctx.allocated_bytes()); + e.mem_gate(); + } + drop(e); + assert!(peak_before_wait > (8 << 20), "working textures should push allocator over 8 MiB before a wait (peak {} B)", peak_before_wait); + let waits = ctx.memcap_waits(); + assert!(waits > 0, "mem_gate should have waited once reclaimable bytes exceeded the cap"); + assert!( + ctx.allocated_bytes() < peak_before_wait, + "waiting should reclaim dropped textures (peak {} B, after {} B)", + peak_before_wait, + ctx.allocated_bytes() + ); + assert!(ctx.check_health().is_ok()); + ctx.pool_frame_end(); + + // 3. A frame bigger than the cap: live GpuImages count in Pool::leased (kept for every owner), + // so three live 512×512 RGBA f32 images (12 MiB) make kept alone exceed the 8 MiB cap. + // Waiting cannot free them; mem_gate must not add waits (warning already asserted in section 1). + let waits_before_big = ctx.memcap_waits(); + { + let mut e = crate::context::Enc::new(ctx); + let live = [e.image(512, 512), e.image(512, 512), e.image(512, 512)]; + for _ in 0..16 { + e.mem_gate(); + } + assert_eq!(ctx.memcap_waits(), waits_before_big, "a frame whose live images alone exceed the cap must not wait"); + assert!(ctx.check_health().is_ok()); + drop(live); + } + ctx.pool_frame_end(); + + // 4. Two frames that fit only one at a time: frame 1 creates ~6 MiB, copies, submits its + // work and drops its images, so wgpu holds them until the submission is done and polled. + // Frame 2 keeps ~6 MiB of new live images and waits for frame 1's reclaimable memory. + { + let mut e1 = crate::context::Enc::new(ctx); + let hold = [e1.image(512, 384), e1.image(512, 384)]; // 6 MiB + e1.copy_into(&hold[0], &hold[1], 0, 0); + e1.submit(); + drop((hold, e1)); + } + let allocated_after_f1 = ctx.allocated_bytes(); + let waits_before_pair = ctx.memcap_waits(); + let allocated_before_gate; + let downloaded; + { + let mut e2 = crate::context::Enc::new(ctx); + let a = e2.image(520, 384); + let b = e2.image(504, 384); // ~6 MiB together, sizes distinct from each other and frame 1 + e2.copy_into(&b, &a, 0, 0); + allocated_before_gate = ctx.allocated_bytes(); + assert!( + allocated_before_gate > (8 << 20), + "frame 1's unreclaimed memory ({allocated_after_f1} B) plus frame 2's live images should exceed the 8 MiB cap (got {allocated_before_gate} B)" + ); + for _ in 0..16 { + e2.mem_gate(); + } + assert!(ctx.memcap_waits() > waits_before_pair, "frame 2 should wait for frame 1's reclaimable memory (its own images stay live)"); + assert!( + ctx.allocated_bytes() < allocated_before_gate, + "waiting should reclaim frame 1's dropped textures (before {} B, after {} B)", + allocated_before_gate, + ctx.allocated_bytes() + ); + downloaded = e2.download(&a); + } + assert!(downloaded.is_some(), "frame 2 must finish with a correct download after waiting"); + ctx.pool_frame_end(); + + // 5. A failed / unfinished frame releases its budget: drop an Enc mid-flight, then a + // following frame must not wait once the pool has drained. + let baseline = ctx.allocated_bytes(); + { + let mut e = crate::context::Enc::new(ctx); + let _hold = [e.image(512, 512), e.image(512, 512), e.image(480, 480)]; + // Drop without finishing: same path as a frame that errors before submit. + } + ctx.pool_frame_end(); + let _ = ctx.poll_readbacks(wgpu::PollType::Poll); + assert!( + ctx.allocated_bytes() <= baseline + (2 << 20) + (1 << 20), + "dropped frame must release to baseline + pool budget (2 MiB) with slack (got {} B, baseline {} B)", + ctx.allocated_bytes(), + baseline + ); + let waits_after_drop = ctx.memcap_waits(); + { + let mut e = crate::context::Enc::lent(ctx); + for _ in 0..16 { + e.mem_gate(); + } + } + assert_eq!(ctx.memcap_waits(), waits_after_drop, "a following frame must not wait after a dropped frame released its memory"); + + // 6. A zero-timeout wait must not retire the context or break subsequent renders. + ctx.set_memcap_wait_timeout(std::time::Duration::ZERO); + { + let mut e = crate::context::Enc::lent(ctx); + for k in 0..16u32 { + let a = e.image(384 + k * 16, 256); + let b = e.image(384 + k * 16, 256); + e.copy_into(&a, &b, 0, 0); + drop((a, b)); + e.mem_gate(); + } + } + assert!(ctx.memcap_waits() > waits_after_drop, "mem_gate should have fired again under zero timeout"); + assert!(ctx.check_health().is_ok(), "a mem_gate wait timeout must carry on without retiring the context"); + let s = blend_scene(BitDepth::Bpc8, BlendMode::Screen); + let r = Renderer::new(&s.p, &Pattern, opts()); + assert!(g.render(&r, s.cid, Tick::ZERO).is_some(), "context renders normally after a zero-timeout mem_gate wait"); + + // 7. Non-blocking polls (`Gpu::poll`, `pool_frame_end`) skip when `poll_lock` is held by + // another thread, and `read_buffer` waits for `poll_lock` without consuming its deadline. + // Drain any dying pool textures first so the timed path takes Poll (try_lock) rather than Wait. + ctx.set_memcap_wait_timeout(crate::readback::TIMEOUT); + ctx.pool_frame_end(); + let (locked_tx, locked_rx) = std::sync::mpsc::channel(); + std::thread::scope(|sc| { + sc.spawn(|| { + let _guard = ctx.lock_poll_for_test(); + locked_tx.send(()).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(60)); + }); + locked_rx.recv().unwrap(); + let t0 = std::time::Instant::now(); + g.poll(); + ctx.pool_frame_end(); + assert!(t0.elapsed() < std::time::Duration::from_millis(30), "non-blocking polls must not wait on a contended poll_lock (took {:?})", t0.elapsed()); + assert!(g.render(&r, s.cid, Tick::ZERO).is_some(), "readback waits for poll_lock and succeeds without retiring the context"); + }); +} diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index fdcb58dd4..c7212dc84 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -213,6 +213,12 @@ fn draw_collapsed<'a>( } fn draw_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut GpuImage) -> Option<()> { + let out = draw_layer_on(e, r, ctx, layer, canvas); + e.mem_gate(); + out +} + +fn draw_layer_on(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut GpuImage) -> Option<()> { if layer.switches.adjustment { if draw_adjustment(e, r, ctx, layer, canvas).is_some() { return Some(()); diff --git a/crates/render/src/accel.rs b/crates/render/src/accel.rs index 9b7d29ec6..0d5fe1b6b 100644 --- a/crates/render/src/accel.rs +++ b/crates/render/src/accel.rs @@ -98,8 +98,9 @@ pub trait Accelerator: Send + Sync { fn pass_begin(&self) {} /// A rendered comp frame is done (the GPU pool ages sizes no frame asks for, and evicts /// only those once full). The multi-frame render paths call it once per comp frame: - /// [`crate::passes::comp_frame`] (exports; a field-rendered output frame is two), remote frames - /// and the preview workers. One-off renders (`Session::render`, single-frame export, + /// [`crate::passes::comp_frame`] (exports; a field-rendered output frame is two), the + /// export's 4:2:0 frames ([`Accelerator::comp_frame_yuv420`]), remote frames and the + /// preview workers. One-off renders (`Session::render`, single-frame export, /// thumbnails) don't, so they don't advance the pool's clock. Without it the pool never /// finds a stale size and keeps its first sizes, dropping newcomers. fn frame_end(&self) {} From 389894e69b665c63c8f240f12f064d3bc7e0fb39 Mon Sep 17 00:00:00 2001 From: gmrmad1 <86235830+gmrmad1@users.noreply.github.com> Date: Wed, 4 Nov 2026 02:54:42 +0530 Subject: [PATCH 68/71] gpu: run Content-Aware Fill's PatchMatch and flow on the GPU (#1856) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * gpu: run Content-Aware Fill's PatchMatch on the device Content-Aware Fill's synthesis is the stage that makes the tool slow: at 1920×1080 with a 300×200 hole, PatchMatch costs ≈ 1.1 s per frame on the CPU, and it is what Object, Surface and every refining frame spend their time in. Add a `FillBackend` trait to `effectcraft-raster` whose stages (the membrane solve, the flow fields, PatchMatch) default to the CPU reference, and `Accelerator::inpaint()` on the compositor, which is how the engine asks for a device backend. `effectcraft_gpu::inpaint` implements PatchMatch: the multi-scale pyramid, the EM rounds, the propagation (a two-pass checkerboard instead of the CPU's scan order), the random search, the SSD, the histogram-based σ² and the weighted vote, as compute kernels in `shaders/inpaint.wgsl`. The fill's frames are read back to the CPU before it runs (analyses composite on the CPU by design), so the backend is about the fill's own work; the orchestration, the flow-guided propagation and the per-frame flow control stay on the CPU. Only the hole's bounds cross back to the CPU, which is what makes the offload pay. Measured on an Intel Arc (MTL) iGPU, 1920×1080, 300×200 hole: CPU 1087 ms GPU 397 ms (2.7×, 41.0 dB PSNR against the CPU's synthesis) The membrane (harmonic) solve deliberately stays on the CPU: the same measurement has it at ≈ 90 ms on the device against ≈ 55 ms on the CPU, because a fill's full-frame data movement (upload, the known pyramid, a copy) does not shrink with the hole, while the relaxation does. The device takes only the stage where its parallel search pays; `membrane_tex` still runs on the device inside PatchMatch, where it is the coarsest level's start and never crosses back. Closes #1839. * gpu: fix the fill's vote histogram and run its flow on the device Review follow-ups on the Content-Aware Fill GPU port, plus the fill's flow. Fixes: * imp_hist kept about one sample in 256: each of the 256 threads scanned a strided subset of the pixels and retained only those whose bin selected it, so the vote's sigma² came from a sample correlated with the pixel index (and a small hole usually fell back to 1.0, making the weights near-uniform). One workgroup now accumulates the whole population through shared atomic bins, with a test that pins the sample count. * imp_pm_prop and imp_pm_rand were dispatched over grow(near, 1) but with near's origin, so the ring's left column and top row were never written (NaN under test, stale pooled data in production) while a strip past it was. `over` now derives the shader's origin from the box it sizes the dispatch from, so a dispatch's region and its origin can no longer disagree. * Gpu::inpaint() requires its kernels to have built. Otherwise every stage paid an upload and a failed dispatch to fall back to the CPU, while the fill still reported the GPU path. * imp_rnd's XOR chain parenthesises its multiplicative terms: WGSL's grammar makes each XOR operand a unary expression, so `a * b ^ c * d` is rejected by spec-compliant parsers (naga accepts it). * FillBackend documents that its stages may be called concurrently. * The coarsest membrane's sweep count (64, against the CPU's 200) is now justified: matching 200 measured ~6% slower over the fill with no change in the result. And the flow: Gpu::flow runs block_flow's coarse-to-fine search, sub-pixel fit and vector median on the device. The luma is the CPU's (flow::luma_pair), so the field matches block_flow's to a float rounding and the propagation sees the same motion; the hole completion stays on the CPU with the membrane. A four-frame 1920x1080 fill goes from 1.27x to 1.42x against the CPU, and the device path is covered for holes from 16x16 to 300x200. * gpu: keep the fill vote's histogram range on HDR input imp_hist binned every distance at a fixed 256 bins per unit and clamped to 1023, so the vote's sigma2 topped out at 3.998. A 32-bpc frame's channels can exceed 1, and masked_frame forwards them unclamped: a patch distance of 1600 then reported sigma2 ~ 4, exp(-1600/(2*sigma2)) rounded to zero in f32, and imp_pm_vote kept the prior image instead of synthesising. The histogram now reports the largest finite distance it saw (atomicMax on the float bits, carried in bin 0's unused .g channel). SDR input keeps the fixed 0..4 range with identical bins; HDR input is re-binned over the observed range and percentile() maps the bin centre back through it. Nothing clamps the input. Also group over/sweeps/pm_level's arguments into small structs: the workspace denies too_many_arguments and the lint policy (#2496) does not allow new #[allow]s. No behaviour change on those paths. * engine: don't report Edge Blend fills as device fills Edge Blend calls only the membrane, which the GPU backend leaves to the CPU reference, so run_plan's "Filling (GPU)" status was wrong for it whenever a backend was present. Object and Surface still run the flow and PatchMatch on the device and keep the label. CodeRabbit's #1856 finding. * gpu: run the fill's device stages inside error scopes An allocation or validation failure inside patch_image/flow_image left an invalid texture whose later use raised an uncaptured error, retiring the shared compositor instead of falling back to the CPU for that frame. Wrap both stages in Gpu::within_memory, whose scopes are native thread-local, so the fill's rayon workers do not share them. --------- Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- crates/engine/src/background/offload.rs | 2 + crates/engine/src/cmd/content_fill.rs | 42 +- crates/gpu/src/context.rs | 20 +- crates/gpu/src/inpaint.rs | 729 ++++++++++++++++++++++ crates/gpu/src/lib.rs | 12 + crates/gpu/src/shaders/inpaint.wgsl | 796 ++++++++++++++++++++++++ crates/gpu/src/tests.rs | 1 + crates/gpu/src/tests/inpaint.rs | 310 +++++++++ crates/raster/src/flow.rs | 15 + crates/raster/src/inpaint.rs | 82 ++- crates/render/src/accel.rs | 10 + docs/hardware-parity.md | 4 +- 12 files changed, 1999 insertions(+), 24 deletions(-) create mode 100644 crates/gpu/src/inpaint.rs create mode 100644 crates/gpu/src/shaders/inpaint.wgsl create mode 100644 crates/gpu/src/tests/inpaint.rs diff --git a/crates/engine/src/background/offload.rs b/crates/engine/src/background/offload.rs index 23cff4da6..f9038accb 100644 --- a/crates/engine/src/background/offload.rs +++ b/crates/engine/src/background/offload.rs @@ -558,6 +558,7 @@ async fn fill(s: &mut Session, plan: crate::commands::content_fill::FillPlan, po true }; let accel = job_accel(); + let fill = accel.as_deref().and_then(|a| a.inpaint()); let files = crate::commands::content_fill::run_plan( &plan, &s.project, @@ -566,6 +567,7 @@ async fn fill(s: &mut Session, plan: crate::commands::content_fill::FillPlan, po &s.layer_cache, s.services.as_ref(), accel.as_deref(), + fill, &mut progress, &mut |_| {}, ) diff --git a/crates/engine/src/cmd/content_fill.rs b/crates/engine/src/cmd/content_fill.rs index d96be073d..967651a37 100644 --- a/crates/engine/src/cmd/content_fill.rs +++ b/crates/engine/src/cmd/content_fill.rs @@ -13,7 +13,7 @@ use std::sync::Arc; use effectcraft_color::Label; use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, LayerId, LayerSource, build}; use effectcraft_raster::Image; -use effectcraft_raster::inpaint::{FillInput, FillMethod, FillOpts, dilate, fill_sequence}; +use effectcraft_raster::inpaint::{FillInput, FillMethod, FillOpts, dilate}; use effectcraft_render::{Accelerator, EvalCtx, ExprHost, FootageSource, LayerCache, Renderer}; use effectcraft_time::Tick; use serde::{Deserialize, Serialize}; @@ -185,8 +185,21 @@ fn memory_shortfall(frames: usize, w: u32, h: u32, available: u64) -> Option &'static str { + if on_device && method != FillMethod::EdgeBlend { "Filling (GPU)" } else { "Filling" } +} + /// Run a fill: the layer's frames (rendered in passes with a job worker's GPU), the fill, the /// PNG sequence written into `plan.dir` (`Services::store_file`). Returns the files. +/// +/// `accel` renders the frames (a job worker's GPU, as for every analysis: `None` on the +/// desktop, whose analysis threads render on the CPU by design); `fill` is the fill's own +/// backend ([`effectcraft_raster::inpaint::FillBackend`]), which is what makes Content-Aware +/// Fill itself run on the GPU. They are separate on purpose: a desktop fill can run on the GPU +/// while its input frames are composited on the CPU. #[allow(clippy::too_many_arguments)] pub(crate) async fn run_plan( plan: &FillPlan, @@ -196,6 +209,7 @@ pub(crate) async fn run_plan( cache: &LayerCache, services: &dyn crate::Services, accel: Option<&dyn Accelerator>, + fill: Option<&dyn effectcraft_raster::inpaint::FillBackend>, progress: &mut dyn FnMut(u64, u64) -> bool, message: &mut dyn FnMut(&str), ) -> std::result::Result, String> { @@ -233,10 +247,13 @@ pub(crate) async fn run_plan( references.push((k, img)); } } - message("Filling"); let opts = FillOpts { method: FillMethod::from_name(&plan.method).unwrap_or_default(), lighting: plan.lighting, ..Default::default() }; + message(filling_status(fill.is_some(), opts.method)); let input = FillInput { frames, holes, references }; - let out = fill_sequence(&input, &opts, &mut |d, t| progress(n + (d as u64 * n / t.max(1) as u64), n * 3)).ok_or(crate::render_queue::CANCELLED)?; + let cpu = effectcraft_raster::inpaint::CpuFill; + let backend: &dyn effectcraft_raster::inpaint::FillBackend = fill.unwrap_or(&cpu); + let out = effectcraft_raster::inpaint::fill_sequence_with(&input, &opts, backend, &mut |d, t| progress(n + (d as u64 * n / t.max(1) as u64), n * 3)) + .ok_or(crate::render_queue::CANCELLED)?; message("Writing the fill layer"); let dir = &plan.dir; let mut paths = vec![]; @@ -324,7 +341,12 @@ fn generate(s: &mut Session, p: &Value) -> Result { let services = s.services.clone(); let importer = s.importer.clone(); let name = layer.name.clone(); + // The fill's own GPU backend (Mercury GPU Acceleration; none under Software Only or + // without a compositor). The frames themselves still render on the CPU here, as for every + // desktop analysis (`offload::job_accel`): only the fill's own work moves to the device. + let accel = if s.project.settings.gpu_acceleration { s.accel.clone() } else { None }; s.spawn_task("contentFill", format!("Content-Aware Fill: {name}"), wait, move |ctl| { + let fill = accel.as_deref().and_then(|a| a.inpaint()); let paths = effectcraft_render::passes::block_on(run_plan( &plan, &lf.project, @@ -333,6 +355,7 @@ fn generate(s: &mut Session, p: &Value) -> Result { &lf.cache, services.as_ref(), None, + fill, &mut |d, t| ctl.progress(d, t), &mut |m| ctl.message(m), ))?; @@ -445,7 +468,18 @@ pub fn specs() -> Vec { #[cfg(test)] mod tests { - use super::memory_shortfall; + use super::{filling_status, memory_shortfall}; + + /// Edge Blend fills with the membrane, which the device backend leaves to the CPU reference: + /// it must not claim a GPU fill just because a backend is available. + #[test] + fn edge_blend_is_not_reported_as_a_device_fill() { + use effectcraft_raster::inpaint::FillMethod; + assert_eq!(filling_status(true, FillMethod::Object), "Filling (GPU)"); + assert_eq!(filling_status(true, FillMethod::Surface), "Filling (GPU)"); + assert_eq!(filling_status(true, FillMethod::EdgeBlend), "Filling"); + assert_eq!(filling_status(false, FillMethod::Object), "Filling"); + } /// #1789: 10 s of 1080p30 (300 frames) needs at least 20 GB, which 4 GB free can't hold. #[test] diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 543c79283..713f6f83d 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -803,6 +803,7 @@ impl GpuContext { include_str!("shaders/fx_time.wgsl"), include_str!("shaders/fx_pixel2.wgsl"), include_str!("shaders/fx_gen2.wgsl"), + include_str!("shaders/inpaint.wgsl"), include_str!("shaders/sky.wgsl"), ] .concat(); @@ -911,6 +912,7 @@ impl GpuContext { .chain(crate::fx_time::KERNELS) .chain(crate::fx_pixel2::KERNELS) .chain(crate::fx_gen2::KERNELS) + .chain(crate::inpaint::KERNELS) { match build(e, &layout) { Ok(p) => { @@ -1893,23 +1895,31 @@ impl<'g> Enc<'g> { data.resize(layout.len / 16, [0.0; 4]); let mut out = Image { width: img.width, height: img.height, data }; let dst: &mut [u8] = bytemuck::cast_slice_mut(&mut out.data); - self.read_texture_into(&img.texture, img.width, img.height, 16, dst)?; + self.read_texture_into(&img.texture, [0, 0, img.width, img.height], 16, dst)?; Some(out) } /// Tightly packed texel bytes of a texture (`bpp` bytes per pixel). pub fn read_texture(&mut self, texture: &wgpu::Texture, w: u32, h: u32, bpp: u32) -> Option> { + self.read_rect(texture, 0, 0, w, h, bpp) + } + + /// [`Enc::read_texture`] of a sub-rectangle at (`x`, `y`): a fill only changes its hole, so + /// only the hole's bounds need to cross back (`bytes_per_row` is still row-aligned inside). + pub fn read_rect(&mut self, texture: &wgpu::Texture, x: u32, y: u32, w: u32, h: u32, bpp: u32) -> Option> { let layout = crate::readback::Layout::new(w, h, bpp, self.g.device.limits().max_buffer_size).map_err(|e| log::error!("gpu texture readback: {e}")).ok()?; let mut out = Vec::new(); out.try_reserve_exact(layout.len).map_err(|e| log::error!("gpu readback allocation: {e}")).ok()?; out.resize(layout.len, 0); - self.read_texture_into(texture, w, h, bpp, &mut out)?; + self.read_texture_into(texture, [x, y, w, h], bpp, &mut out)?; Some(out) } - /// [`Enc::read_texture`] into `out` (`w × bpp × h` bytes), through a reused staging buffer. - fn read_texture_into(&mut self, texture: &wgpu::Texture, w: u32, h: u32, bpp: u32, out: &mut [u8]) -> Option<()> { + /// [`Enc::read_rect`] into `out` (`w × bpp × h` bytes), through a reused staging buffer. + /// `rect` is `[x, y, w, h]`. + fn read_texture_into(&mut self, texture: &wgpu::Texture, rect: [u32; 4], bpp: u32, out: &mut [u8]) -> Option<()> { + let [x, y, w, h] = rect; if !self.g.can_wait() { return None; } @@ -1932,7 +1942,7 @@ impl<'g> Enc<'g> { }) }); self.encoder().copy_texture_to_buffer( - wgpu::TexelCopyTextureInfo { texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + wgpu::TexelCopyTextureInfo { texture, mip_level: 0, origin: wgpu::Origin3d { x, y, z: 0 }, aspect: wgpu::TextureAspect::All }, wgpu::TexelCopyBufferInfo { buffer: &buf, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(padded), rows_per_image: Some(h) } }, wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, ); diff --git a/crates/gpu/src/inpaint.rs b/crates/gpu/src/inpaint.rs new file mode 100644 index 000000000..25cbc31b9 --- /dev/null +++ b/crates/gpu/src/inpaint.rs @@ -0,0 +1,729 @@ +//! Content-Aware Fill's per-frame synthesis and flow on the GPU: PatchMatch's multi-scale EM +//! loop, propagation, random search and vote of `effectcraft_raster::inpaint`, and the block +//! matching of `effectcraft_raster::flow::block_flow`, as compute kernels following the CPU's +//! steps (`shaders/inpaint.wgsl`). +//! +//! The fill's input frames are composited on the CPU by design (analyses do not use the job +//! worker's GPU: `effectcraft_engine::background::offload::job_accel`), so this module is about the fill +//! itself — the part that runs for minutes on a large mask, and whose parallel search is what a +//! device wins on. The membrane (harmonic) solve, the flow's hole completion, the orchestration +//! ([`effectcraft_raster::inpaint::fill_sequence_with`]), the flow-guided propagation and the +//! per-frame flow control stay on the CPU: the membrane is bandwidth-bound (see +//! [`FillBackend::patch`]), and the propagation's coupling is sequential, needing every frame of +//! the range resident at once. +//! +//! Parity with the CPU reference is a quality contract, not bit-exactness: the CPU's +//! scan-order propagation becomes a two-pass checkerboard, and its in-place relaxation becomes +//! Jacobi reads. Both solve the same problem to the same quality (`tests/inpaint.rs`). + +use effectcraft_raster::Image; +use effectcraft_raster::flow::{Flow, block_flow}; +use effectcraft_raster::inpaint::{FillBackend, complete_flow, dilate}; + +use crate::Gpu; +use crate::context::{Enc, GpuContext, GpuImage, KernelImages, Params}; + +/// The kernels this module adds to the shared pipeline registry (`context.rs`). +pub(crate) const KERNELS: &[&str] = &[ + "imp_down", + "imp_up", + "imp_jacobi", + "imp_pm_down", + "imp_pm_up", + "imp_nnf_up", + "imp_pm_init", + "imp_pm_prop", + "imp_pm_rand", + "imp_nnf_store", + "imp_pm_vote", + "imp_hist", + "imp_fl_half", + "imp_fl_search", + "imp_fl_subpix", + "imp_fl_median", +]; + +/// Distance histogram bins (`imp_hist`); bin 0's `.g` holds the largest finite distance the +/// dispatch saw, for the HDR re-bin. +const BINS: usize = 1024; + +/// The distance range `imp_hist`'s bins cover for SDR input. An image whose channels are all ≤ 1 +/// has `Σ_c (a − b)² ≤ 4`, so no patch distance exceeds it and the bins need no rescaling. +const SDR_DISTANCE: f32 = 4.0; + +/// `u`/`f` blocks of a kernel's parameter buffer (see `inpaint.wgsl`'s header). +fn params(u0: [u32; 4], u1: [u32; 4], u2: [u32; 4], f0: [f32; 4]) -> Params { + Params { u: [u0, u1, u2, [0; 4]], f: [f0, [0.0; 4], [0.0; 4], [0.0; 4], [0.0; 4], [0.0; 4], [0.0; 4], [0.0; 4], [0.0; 4], [0.0; 4], [0.0; 4], [0.0; 4]] } +} + +/// `p` with this level's mask offset in `u[3].x` (`inpaint.wgsl`'s `data`). +fn with_off(p: Params, off: u32) -> Params { + Params { u: [p.u[0], p.u[1], p.u[2], [off, 0, 0, 0]], f: p.f } +} + +/// A relaxation sweep count of `membrane_core`. The CPU uses 200 for the small levels and 40 for +/// the large ones; this uses 64 and 40. The small levels are where the difference lies: Jacobi +/// needs O(n²) sweeps to converge on an n-wide grid, so at the levels the coarse-to-fine schedule +/// actually reaches (n ≤ 16, where n² ≤ 256) 64 sweeps is already converged, and finer levels +/// start from a good guess. Matching the CPU's 200 measured ≈ 6% slower on the whole fill with no +/// change in the result to two decimals (67.0 dB either way), so the schedule is deliberately +/// not the CPU's — Jacobi and Gauss–Seidel do not converge alike, and the parity contract is +/// quality, not bit-exactness. +fn sweep_count(w: usize, h: usize) -> usize { + if w * h <= 4096 { 64 } else { 40 } +} + +/// PatchMatch's patch radius at a level (`pm_level`). +fn radius(patch: usize, w: u32, h: u32) -> u32 { + (patch / 2).min(w.min(h).saturating_sub(1) as usize / 2).max(1) as u32 +} + +/// The bounding box of `mask`'s set pixels, `[x0, y0, x1, y1)`, or `None` when it is empty. +fn bbox(mask: &[bool], w: usize, h: usize) -> Option<[u32; 4]> { + let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize); + for y in 0..h { + for x in 0..w { + if mask.get(y * w + x).copied().unwrap_or(false) { + x0 = x0.min(x); + y0 = y0.min(y); + x1 = x1.max(x + 1); + y1 = y1.max(y + 1); + } + } + } + if x0 == usize::MAX { None } else { Some([x0 as u32, y0 as u32, x1 as u32, y1 as u32]) } +} + +/// [`bbox`] grown by `n` pixels, clipped to the image. +fn grow(b: [u32; 4], n: u32, w: u32, h: u32) -> [u32; 4] { + [b[0].saturating_sub(n), b[1].saturating_sub(n), b[2].saturating_add(n).min(w), b[3].saturating_add(n).min(h)] +} + +/// The bounding box of the pixels a membrane level still has to solve (`known` false), or +/// `None` when every pixel is known. +fn unknown_bbox(known: &[f32], w: usize, h: usize) -> Option<[u32; 4]> { + let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize); + for y in 0..h { + for x in 0..w { + if known.get(y * w + x).copied().unwrap_or(1.0) <= 0.5 { + x0 = x0.min(x); + y0 = y0.min(y); + x1 = x1.max(x + 1); + y1 = y1.max(y + 1); + } + } + } + if x0 == usize::MAX { None } else { Some([x0 as u32, y0 as u32, x1 as u32, y1 as u32]) } +} + +/// One dispatch's images, its per-pixel `data` table and the region it covers. Grouped into a +/// struct because a kernel's bindings plus its bounds exceed clippy's argument limit, and the +/// workspace denies `too_many_arguments`. +struct Pass<'a> { + src: &'a GpuImage, + aux: Option<&'a GpuImage>, + out: &'a GpuImage, + data: Option<&'a wgpu::Buffer>, + bb: [u32; 4], +} + +/// Dispatch a kernel over `pass.bb`. The box's origin is written into `P.u[1].xy` (the kernels +/// place their other `u[1]` fields in `.zw`): deriving it from the box the dispatch is sized from +/// keeps a dispatch's covered region and the origin its shader reads from drifting apart, which +/// is the subtle way an edge of the region goes unwritten. +fn over(e: &mut Enc, entry: &str, p: &Params, pass: Pass<'_>) { + let bb = pass.bb; + let p = Params { u: [p.u[0], [bb[0], bb[1], p.u[1][2], p.u[1][3]], p.u[2], p.u[3]], f: p.f }; + let groups = ((bb[2] - bb[0]).div_ceil(16), (bb[3] - bb[1]).div_ceil(16)); + e.dispatch(entry, &p, KernelImages { src: pass.src, aux: pass.aux, out: pass.out }, pass.data, groups); +} + +// ------------------------------------------------------------------ membrane + +/// Relax `fine`'s unknown pixels — the harmonic interpolation of their border — with the CPU's +/// coarse-to-fine schedule (`membrane_core`). `spare` is a free texture of the same size; the +/// result is returned (it may be `fine`, `spare` or one of the coarse levels). +fn membrane_tex(e: &mut Enc, fine: &GpuImage, spare: &GpuImage, hole: &[bool], w: u32, h: u32) -> Option { + let (uw, uh) = (w as usize, h as usize); + let n = uw * uh; + if hole.len() < n || !hole.iter().take(n).any(|b| *b) || !hole.iter().take(n).any(|b| !*b) { + return Some(fine.clone()); + } + // The recursion's levels: halve while the level is wider than 8. + let mut dims = vec![(uw, uh)]; + while let Some(&(cw, ch)) = dims.last() { + if !(cw > 8 && ch > 8) || dims.len() >= 32 { + break; + } + dims.push((cw.div_ceil(2), ch.div_ceil(2))); + } + // The known mask of every level: a coarse pixel is known where any of its 2×2 is. + let mut knowns: Vec> = Vec::with_capacity(dims.len()); + knowns.push(hole.iter().take(n).map(|b| if *b { 0.0 } else { 1.0 }).collect()); + for k in 1..dims.len() { + let (pw, ph) = dims[k - 1]; + let (cw, ch) = dims[k]; + let prev = &knowns[k - 1]; + let mut cur = vec![0.0f32; cw * ch]; + for y in 0..ch { + for x in 0..cw { + for (dx, dy) in [(0usize, 0usize), (1, 0), (0, 1), (1, 1)] { + let (fx, fy) = (2 * x + dx, 2 * y + dy); + if fx < pw && fy < ph && prev[fy * pw + fx] > 0.5 { + cur[y * cw + x] = 1.0; + break; + } + } + } + } + knowns.push(cur); + } + // One buffer for every level's mask (a buffer per level costs a device allocation each). + let mut packed: Vec = Vec::with_capacity(knowns.iter().map(|k| k.len()).sum()); + let mut offs: Vec = Vec::with_capacity(knowns.len()); + for k in &knowns { + offs.push(packed.len() as u32); + packed.extend_from_slice(k); + } + let datas = e.data(&packed); + let moff = |k: usize| offs.get(k).copied().unwrap_or(0); + // The bounds of each level's unknown pixels: the relaxation only touches them (known pixels + // never move), which keeps a fill's cost proportional to the hole, not the frame. + let mut uns: Vec> = Vec::with_capacity(dims.len()); + for (k, &(cw, ch)) in dims.iter().enumerate() { + uns.push(unknown_bbox(knowns.get(k)?, cw, ch)); + } + // Two textures per coarse level, for the ping-pong sweeps. + let mut lv: Vec = Vec::with_capacity(2 * dims.len()); + for k in 1..dims.len() { + let (cw, ch) = dims[k]; + lv.push(e.scratch(cw as u32, ch as u32)); + lv.push(e.scratch(cw as u32, ch as u32)); + } + // Fine to coarse. + for k in 1..dims.len() { + let (pw, ph) = dims[k - 1]; + let (cw, ch) = dims[k]; + let p = with_off(params([pw as u32, ph as u32, cw as u32, ch as u32], [0; 4], [0; 4], [0.0; 4]), moff(k - 1)); + let src = if k == 1 { fine } else { lv.get(2 * (k - 2))? }; + e.pixels("imp_down", &p, src, None, lv.get(2 * (k - 1))?, Some(&datas)); + } + // Relax, coarse to fine. `first[k]` says whether level k's image is the first of its pair + // (level 0's pair is `fine`/`spare`). + let top = dims.len() - 1; + let mut first = vec![true; dims.len()]; + if top >= 1 { + let (a, b) = (lv.get(2 * (top - 1))?, lv.get(2 * (top - 1) + 1)?); + // Both textures must hold the level before the sweeps: they now write only the hole. + e.copy_into(a, b, 0, 0); + first[top] = sweeps(e, &datas, moff(top), a, b, Region { w: dims[top].0 as u32, h: dims[top].1 as u32, bb: uns.get(top).copied().flatten() }); + } + for k in (1..top).rev() { + let (cw, ch) = dims[k]; + let (pw, ph) = dims[k + 1]; + let coarse = lv.get(2 * k + usize::from(!first[k + 1]))?; + let (fa, fb) = (lv.get(2 * (k - 1))?, lv.get(2 * (k - 1) + 1)?); + e.copy_into(fa, fb, 0, 0); + if let Some(bb) = uns.get(k).copied().flatten() { + let p = with_off(params([cw as u32, ch as u32, pw as u32, ph as u32], [0; 4], [0; 4], [0.0; 4]), moff(k)); + over(e, "imp_up", &p, Pass { src: coarse, aux: None, out: fb, data: Some(&datas), bb }); + } + // `imp_up` wrote into `fb`, the pair's second. + first[k] = !sweeps(e, &datas, moff(k), fb, fa, Region { w: cw as u32, h: ch as u32, bb: uns.get(k).copied().flatten() }); + } + if top >= 1 { + let coarse = lv.get(usize::from(!first[1]))?; + e.copy_into(fine, spare, 0, 0); + let bb = uns.first().copied().flatten()?; + let p = with_off(params([w, h, dims[1].0 as u32, dims[1].1 as u32], [0; 4], [0; 4], [0.0; 4]), moff(0)); + over(e, "imp_up", &p, Pass { src: coarse, aux: None, out: spare, data: Some(&datas), bb }); + return Some(if sweeps(e, &datas, moff(0), spare, fine, Region { w, h, bb: Some(bb) }) { spare.clone() } else { fine.clone() }); + } + let bb = uns.first().copied().flatten()?; + e.copy_into(fine, spare, 0, 0); + Some(if sweeps(e, &datas, moff(0), fine, spare, Region { w, h, bb: Some(bb) }) { fine.clone() } else { spare.clone() }) +} + +/// A texture's dimensions and the region a dispatch covers (`None`: nothing to do). +struct Region { + w: u32, + h: u32, + bb: Option<[u32; 4]>, +} + +/// Jacobi relaxation sweeps of a texture pair, over the unknown pixels' bounds `region.bb` (the +/// state starts in `a`); returns whether the result is in `a`. +fn sweeps(e: &mut Enc, data: &wgpu::Buffer, off: u32, a: &GpuImage, b: &GpuImage, region: Region) -> bool { + let Some(bb) = region.bb else { return true }; + let p = with_off(params([region.w, region.h, 0, 0], [0; 4], [0; 4], [0.0; 4]), off); + let mut in_a = true; + for _ in 0..sweep_count(region.w as usize, region.h as usize) { + let (src, out) = if in_a { (a, b) } else { (b, a) }; + over(e, "imp_jacobi", &p, Pass { src, aux: None, out, data: Some(data), bb }); + in_a = !in_a; + } + in_a +} + +/// `img` with the pixels `mask` marks inside `bb` replaced by `rect` — the tightly packed RGBA f32 +/// rows of the rectangle that was read back. Pixels outside the mask stay the input's, bit for bit. +fn patch_back(img: &Image, mask: &[bool], bb: [u32; 4], rect: &[u8]) -> Image { + let (w, rw) = (img.width as usize, (bb[2] - bb[0]) as usize); + let mut out = img.clone(); + for y in bb[1]..bb[3] { + for x in bb[0]..bb[2] { + let i = y as usize * w + x as usize; + if !mask.get(i).copied().unwrap_or(false) { + continue; + } + let off = ((y - bb[1]) as usize * rw + (x - bb[0]) as usize) * 16; + let Some(b) = rect.get(off..off + 16) else { continue }; + let mut px = [0.0f32; 4]; + for (c, v) in px.iter_mut().enumerate() { + *v = f32::from_le_bytes([b[c * 4], b[c * 4 + 1], b[c * 4 + 2], b[c * 4 + 3]]); + } + out.data[i] = px; + } + } + out +} + +impl FillBackend for Gpu { + /// PatchMatch synthesis — the fill's dominant cost, and the stage a device wins on: two to + /// three times faster than the CPU's 18-rayon-thread loop at 1920×1080 with a 300×200 hole + /// on an Intel Arc iGPU, and far more on a discrete one. + /// + /// The membrane ([`FillBackend::membrane`]) deliberately stays on the CPU: a harmonic solve + /// over a hole is only a few tens of milliseconds, and on the same measurement it is *slower* + /// on the device (≈ 90 ms against ≈ 55 ms). Most of that is the fill's full-frame data + /// movement — uploading the frame, copying it and building the known pyramid — which does not + /// shrink with the hole, while the relaxation itself does. The CPU's in-place Gauss–Seidel + /// over many cores is already competitive there, so the device only takes the stage where its + /// parallel search actually pays. (`membrane_tex` still runs on the device *inside* this + /// stage, where it is the coarsest level's start and never crosses back to the CPU.) + fn patch(&self, img: &Image, hole: &[bool], init: Option<&Image>, patch: usize, seed: u32) -> Image { + // The error scopes make an allocation or validation failure local to this frame: the + // CPU fills it instead, rather than an uncaptured error retiring the shared context. + if self.ctx.can_wait() + && let Ok(Some(out)) = self.within_memory(|| patch_image(&self.ctx, img, hole, init, patch, seed)) + { + return out; + } + effectcraft_raster::inpaint::patch_fill(img, hole, init, patch, seed) + } + + /// The block matching that the sequence's flow-guided propagation starts from, which is the + /// flow's dominant cost. The field is a small `cols × rows` grid (8 px tiles, so 240 × 135 at + /// 1080p), and the search is texture-load-bound: at 1920×1080 the CPU's `block_flow` is + /// ≈ 65–70 ms against ≈ 55 ms here, most of it the coarsest level's window (`radius 6` over + /// up to six seeds) and the finest level's 8×8 blocks. That is a smaller margin than + /// [`patch`](Self::patch), but the flow runs twice per frame pair, so on a four-frame 1080p + /// sequence it is worth ≈ 0.15× of the fill's total (1.42× against 1.27× with the CPU flow). + /// + /// The field is exhaustive, not heuristic: it matches the CPU's to a float rounding, so the + /// propagation sees the same motion. Both A's block per candidate (hoisted into registers) + /// and the CPU's `Plane::luma` (computed here by [`effectcraft_raster::flow::luma_pair`] and + /// uploaded, because a device may contract `a·b + c` into a multiply-add and round the luma + /// differently) are why it is that close. + /// + /// The completion — marking the tiles that touch either hole and interpolating the surrounding + /// motion over them — stays on the CPU ([`complete_flow`]). It is a membrane solve over that + /// same tiny grid whose cost is the mask, not the solve, so there is little for the device to + /// take; keeping it here also keeps the completed field the CPU's to within the search's + /// rounding. + fn flow(&self, a: &Image, b: &Image, hole_a: &[bool], hole_b: &[bool]) -> Flow { + // Frame-local error scopes, as for the patch: a failed GPU search falls back to the CPU. + if self.ctx.can_wait() + && let Ok(Some(f)) = self.within_memory(|| flow_image(&self.ctx, a, b)) + { + return complete_flow(f, hole_a, hole_b, a.width as usize, a.height as usize); + } + complete_flow(block_flow(a, b, 8, 6), hole_a, hole_b, a.width as usize, a.height as usize) + } +} + +// ------------------------------------------------------------------ block flow + +/// `effectcraft_raster::flow::block_flow(a, b, 8, 6)` on the device: the two frames' luma +/// pyramids, the coarse-to-fine search, the sub-pixel fit and the 3×3 median, returning the raw +/// field ([`complete_flow`] does the hole completion). `None` where the device cannot run it and +/// the caller falls back to the CPU. +pub(crate) fn flow_image(g: &GpuContext, a: &Image, b: &Image) -> Option { + let (w, h) = (a.width, a.height); + if w == 0 || h == 0 || w != b.width || h != b.height || !g.fits(w, h) { + return None; + } + let (tile, win, radius) = (8u32, 8u32, 6u32); + let cols = (w as usize).div_ceil(tile as usize).max(1); + let rows = (h as usize).div_ceil(tile as usize).max(1); + // block_flow_window's early out: a frame too small to hold one block and window. + if w < tile || h < tile || w < win || h < win { + return Some(Flow { width: w, height: h, block: tile, cols, rows, v: vec![[0.0; 2]; cols * rows] }); + } + let mut e = Enc::new(g); + // The luma pyramid: `Plane::luma` (packed .x/.y), halved while both sides stay worth matching + // (`Plane::half`), at most four levels. + let mut dims = vec![(w, h)]; + while dims.len() < 4 { + let (cw, ch) = *dims.last()?; + if cw / 2 < 16 || ch / 2 < 16 { + break; + } + dims.push(((cw / 2).max(1), (ch / 2).max(1))); + } + // The luma is the CPU's (`luma_pair`), so the field matches `block_flow`'s bit for bit; the + // halves below are exact additions and a multiply by a power of two. + let pair = g.upload_image(&effectcraft_raster::flow::luma_pair(a, b))?; + let mut luma: Vec = Vec::with_capacity(dims.len()); + luma.push(pair); + for k in 1..dims.len() { + let (pw, ph) = dims[k - 1]; + let (cw, ch) = dims[k]; + let dst = e.scratch(cw, ch); + e.pixels("imp_fl_half", ¶ms([pw, ph, cw, ch], [0; 4], [0; 4], [0.0; 4]), luma.get(k - 1)?, None, &dst, None); + luma.push(dst); + } + // The coarse-to-fine search: the coarsest level from the zero vector, each finer level from + // the doubled vectors of its tile and its four neighbours. + let top = dims.len() - 1; + let groups = (cols.div_ceil(16) as u32, rows.div_ceil(16) as u32); + let mut field: Vec = Vec::with_capacity(dims.len()); + for _ in 0..dims.len() { + field.push(e.scratch(cols as u32, rows as u32)); + } + for k in (0..dims.len()).rev() { + let (pw, ph) = dims[k]; + let coarser = (k != top).then(|| field.get(k + 1)).flatten(); + let p = params([pw, ph, cols as u32, rows as u32], [tile, win, 1u32 << k, if k == top { radius } else { 1 }], [u32::from(k != top), 0, 0, 0], [0.0; 4]); + e.dispatch("imp_fl_search", &p, KernelImages { src: luma.get(k)?, aux: coarser, out: field.get(k)? }, None, groups); + } + // The full-resolution sub-pixel fit, then the vector median. + let fine = e.scratch(cols as u32, rows as u32); + e.dispatch( + "imp_fl_subpix", + ¶ms([w, h, cols as u32, rows as u32], [tile, win, 0, 0], [0; 4], [0.0; 4]), + KernelImages { src: luma.first()?, aux: Some(field.first()?), out: &fine }, + None, + groups, + ); + let out = e.scratch(cols as u32, rows as u32); + e.dispatch( + "imp_fl_median", + ¶ms([w, h, cols as u32, rows as u32], [0; 4], [0; 4], [0.0; 4]), + KernelImages { src: &fine, aux: None, out: &out }, + None, + groups, + ); + if e.take_missing() { + return None; + } + // Only the field crosses back to the CPU: `cols × rows` entries. + let rect = e.read_rect(&out.texture, 0, 0, cols as u32, rows as u32, 16)?; + let mut v = Vec::with_capacity(cols * rows); + for i in 0..cols * rows { + let b = rect.get(i * 16..i * 16 + 16)?; + let f = |o: usize| b.get(o).copied().unwrap_or(0); + v.push([f32::from_le_bytes([f(0), f(1), f(2), f(3)]), f32::from_le_bytes([f(4), f(5), f(6), f(7)])]); + } + Some(Flow { width: w, height: h, block: tile, cols, rows, v }) +} + +// ------------------------------------------------------------------ PatchMatch + +/// One PatchMatch level's plan. +struct Lv { + w: u32, + h: u32, + r: u32, + /// The target centres: pixels whose patch overlaps the hole (`near`). + near: [u32; 4], + /// The hole's own bounding box (the vote's dispatch). + holes: [u32; 4], + /// Where the nearest-neighbour field must be defined: the vote reads it `r` pixels out. + field: [u32; 4], + flags: wgpu::Buffer, +} + +/// The multi-scale PatchMatch synthesis of `patch_fill`: `init` is the starting guess (the +/// previous frame's fill warped onto this one), which refines the finest level only. +pub(crate) fn patch_image(g: &GpuContext, img: &Image, hole: &[bool], init: Option<&Image>, patch: usize, seed: u32) -> Option { + let (w, h) = (img.width, img.height); + if w == 0 || h == 0 || !g.fits(w, h) { + return None; + } + let (uw, uh) = (w as usize, h as usize); + let n = uw * uh; + if hole.len() < n || !hole.iter().take(n).any(|b| *b) { + return Some(img.clone()); + } + let hole: Vec = hole.iter().take(n).copied().collect(); + let patch = patch.max(3) | 1; + let mut e = Enc::new(g); + + // A refinement of one frame (`patch_fill`'s `init`): the previous frame's fill seeds the + // hole and only the finest level runs, from 2 EM rounds of 4 iterations. + if let Some(init) = init { + if init.width != w || init.height != h { + return None; + } + let mut start = img.clone(); + for (i, v) in start.data.iter_mut().enumerate() { + if hole.get(i).copied().unwrap_or(false) { + *v = *init.data.get(i)?; + } + } + let lv = plan_level(&mut e, &hole, w, h, patch)?; + let image = g.upload_image(&start)?; + let spare = e.scratch(w, h); + let nnf = e.image(w, h); + let (out, _) = pm_level(&mut e, &lv, PmRun { seed, em: 2, iters: 4 }, &image, &spare, &nnf)?; + if e.take_missing() { + return None; + } + let bb = bbox(&hole, uw, uh)?; + let rect = e.read_rect(&out.texture, bb[0], bb[1], bb[2] - bb[0], bb[3] - bb[1], 16)?; + return Some(patch_back(img, &hole, bb, &rect)); + } + + // The pyramid: halve while the level is still a few patches across. + let mut dims = vec![(w, h)]; + let mut holes = vec![hole.clone()]; + while dims.len() < 6 { + let &(lw, lh) = dims.last()?; + let hl = holes.last()?; + if lw.min(lh) / 2 < patch as u32 * 4 || !hl.iter().any(|b| *b) { + break; + } + let (cw, ch) = (lw.div_ceil(2) as usize, lh.div_ceil(2) as usize); + let mut nh = vec![false; cw * ch]; + for y in 0..ch { + for x in 0..cw { + for (dx, dy) in [(0usize, 0usize), (1, 0), (0, 1), (1, 1)] { + let (fx, fy) = ((2 * x + dx).min(lw as usize - 1), (2 * y + dy).min(lh as usize - 1)); + if hl[fy * lw as usize + fx] { + nh[y * cw + x] = true; + break; + } + } + } + } + if !nh.iter().any(|b| !*b) { + break; + } + dims.push((cw as u32, ch as u32)); + holes.push(nh); + } + let levels = dims.len(); + let top = levels - 1; + + let mut plan = Vec::with_capacity(levels); + for k in 0..levels { + plan.push(plan_level(&mut e, holes.get(k)?, dims[k].0, dims[k].1, patch)?); + } + + // One image per level (the coarse-to-fine steps and the vote ping-pong in fresh textures). + let mut ima: Vec = Vec::with_capacity(levels); + ima.push(g.upload_image(img)?); + for k in 1..levels { + let (cw, ch) = dims[k]; + ima.push(e.scratch(cw, ch)); + } + for k in 1..levels { + let (pw, ph) = dims[k - 1]; + let (cw, ch) = dims[k]; + e.pixels("imp_pm_down", ¶ms([pw, ph, cw, ch], [0; 4], [0; 4], [0.0; 4]), &ima[k - 1], None, &ima[k], Some(&plan[k - 1].flags)); + } + // The coarsest level starts as its membrane fill. + let (cw, ch) = dims[top]; + let spare = e.scratch(cw, ch); + ima[top] = membrane_tex(&mut e, &ima[top], &spare, &holes[top], cw, ch)?; + + // Coarse to fine: upsample, then the EM rounds of propagation and voting. + let mut nnf: Option = None; + for k in (0..levels).rev() { + let lv = &plan[k]; + nnf = Some(match nnf { + Some(coarse) => { + let (cw, ch) = dims[k + 1]; + let fine = e.scratch(lv.w, lv.h); + e.pixels("imp_nnf_up", ¶ms([lv.w, lv.h, cw, ch], [0; 4], [0; 4], [0.0; 4]), &coarse, None, &fine, None); + fine + } + None => e.image(lv.w, lv.h), + }); + if k < top { + let (cw, ch) = dims[k + 1]; + let dst = e.scratch(lv.w, lv.h); + e.copy_into(&ima[k], &dst, 0, 0); + e.pixels("imp_pm_up", ¶ms([lv.w, lv.h, cw, ch], [0; 4], [0; 4], [0.0; 4]), &ima[k + 1], None, &dst, Some(&lv.flags)); + ima[k] = dst; + } + let (em, iters) = if k == top { (8, 5) } else { (4, 5) }; + let spare = e.scratch(lv.w, lv.h); + let run = PmRun { seed: seed.wrapping_add(k as u32 * 101), em, iters }; + let (out, out_nnf) = pm_level(&mut e, lv, run, &ima[k], &spare, nnf.as_ref()?)?; + ima[k] = out; + nnf = Some(out_nnf); + } + if e.take_missing() { + return None; + } + // Only the hole changes, so only the hole's bounds cross back to the CPU. + let bb = bbox(&hole, uw, uh)?; + let rect = e.read_rect(&ima[0].texture, bb[0], bb[1], bb[2] - bb[0], bb[3] - bb[1], 16)?; + Some(patch_back(img, &hole, bb, &rect)) +} + +/// The plan of one level: its flags (`bit 0` hole, `bit 1` a target centre) and the dispatch +/// bounds of its targets, holes and field. +fn plan_level(e: &mut Enc, hole: &[bool], w: u32, h: u32, patch: usize) -> Option { + let (iw, ih) = (w as usize, h as usize); + let r = radius(patch, w, h); + let near = dilate(hole, iw, ih, r as usize); + let near_bb = bbox(&near, iw, ih)?; + let flags: Vec = (0..iw * ih).map(|i| (u32::from(hole[i]) + 2 * u32::from(near[i])) as f32).collect(); + Some(Lv { w, h, r, near: near_bb, holes: bbox(hole, iw, ih)?, field: grow(near_bb, r + 1, w, h), flags: e.data(&flags) }) +} + +/// The PatchMatch run at one level: the random stream's seed and the EM round and iteration +/// counts. +struct PmRun { + seed: u32, + em: usize, + iters: usize, +} + +/// `pm_level`: `run.em` EM rounds of propagation and voting at one level. `img` holds the level's +/// image and `img_spare` is a free texture of the same size; the nearest-neighbour field starts +/// at `nnf`. Returns the level's filled image and its field. +fn pm_level(e: &mut Enc, lv: &Lv, run: PmRun, img: &GpuImage, img_spare: &GpuImage, nnf: &GpuImage) -> Option<(GpuImage, GpuImage)> { + let (seed, em, iters) = (run.seed, run.em, run.iters); + let (r, w, h) = (lv.r, lv.w, lv.h); + let field_a = e.scratch(w, h); + let field_b = e.scratch(w, h); + let hist = e.scratch(BINS as u32, 1); + let (img_a, img_b) = (img.clone(), img_spare.clone()); + let (mut field_nnf, mut nnf_spare) = (nnf.clone(), e.scratch(w, h)); + // The propagation and random search write the targets and the one-pixel ring around them, so + // every neighbour they read has a defined alpha (`grow(near, 1)`). The dispatch origin must + // be the ring's, not the targets': `over` sizes the dispatch from the box it is given. + let ring = grow(lv.near, 1, w, h); + let mut in_a = true; + for round in 0..em { + // The round's initial field: the inherited source where it is still valid, else random. + // Every pixel of the field's bounds is written, so the previous round's field is free. + let img = if in_a { &img_a } else { &img_b }; + let p = params([w, h, r, 0], [0; 4], [seed, 0, 0, round as u32], [0.0; 4]); + over(e, "imp_pm_init", &p, Pass { src: &field_nnf, aux: Some(img), out: &field_a, data: Some(&lv.flags), bb: lv.field }); + let mut f_in_a = true; + for it in 0..iters { + for parity in 0..2u32 { + let (s, o) = if f_in_a { (&field_a, &field_b) } else { (&field_b, &field_a) }; + let p = params([w, h, r, parity], [0; 4], [seed, it as u32, 0, round as u32], [0.0; 4]); + over(e, "imp_pm_prop", &p, Pass { src: s, aux: Some(img), out: o, data: Some(&lv.flags), bb: ring }); + f_in_a = !f_in_a; + } + let (s, o) = if f_in_a { (&field_a, &field_b) } else { (&field_b, &field_a) }; + let p = params([w, h, r, 0], [0; 4], [seed, it as u32, 0, round as u32], [0.0; 4]); + over(e, "imp_pm_rand", &p, Pass { src: s, aux: Some(img), out: o, data: Some(&lv.flags), bb: ring }); + f_in_a = !f_in_a; + } + let field = if f_in_a { &field_a } else { &field_b }; + // The vote's sigma²: the 75th percentile of the targets' match distances (`pm_level`). + let sigma2 = sigma2(e, field, lv, &hist)?; + let next_img = if in_a { &img_b } else { &img_a }; + e.copy_into(img, next_img, 0, 0); + let p = params([w, h, r, 0], [0; 4], [0; 4], [sigma2, 0.0, 0.0, 0.0]); + over(e, "imp_pm_vote", &p, Pass { src: field, aux: Some(img), out: next_img, data: Some(&lv.flags), bb: lv.holes }); + // Carry the round's sources into the nearest-neighbour field (targets only). + e.pixels("imp_nnf_store", ¶ms([w, h, 0, 0], [0; 4], [0; 4], [0.0; 4]), field, Some(&field_nnf), &nnf_spare, Some(&lv.flags)); + std::mem::swap(&mut field_nnf, &mut nnf_spare); + in_a = !in_a; + } + Some((if in_a { img_a } else { img_b }, field_nnf)) +} + +/// The 75th percentile of a 1024-bin distance histogram (`imp_hist`'s readback) covering +/// `0..range`, and the histogram's total. The bin's centre is the estimate: within half a bin +/// (`range/2048`) of the CPU's exact percentile. +fn percentile(bytes: &[u8], range: f32) -> (f32, f64) { + let bin = |i: usize| -> f64 { + match bytes.get(i * 16..i * 16 + 4) { + Some(b) => f32::from_le_bytes([b[0], b[1], b[2], b[3]]) as f64, + None => 0.0, + } + }; + let total: f64 = (0..BINS).map(bin).sum(); + if total < 1.0 { + return (1.0, total); + } + let rank = total * 3.0 / 4.0; + let mut cum = 0.0f64; + let mut at = BINS - 1; + for i in 0..BINS { + cum += bin(i); + if cum > rank { + at = i; + break; + } + } + let centre = ((at as f32) + 0.5) * range / BINS as f32; + (centre.max(1e-5), total) +} + +/// The largest finite distance `imp_hist` saw, from bin 0's `.g` channel. +fn max_distance(bytes: &[u8]) -> f32 { + match bytes.get(4..8) { + Some(b) => f32::from_le_bytes([b[0], b[1], b[2], b[3]]), + None => 0.0, + } +} + +/// `imp_hist` over `bb`, the targets' bounds: the 75th percentile of the finite distances and the +/// sample count. SDR input is binned over the fixed [`SDR_DISTANCE`] range; a frame with values +/// above 1 (32-bpc HDR) has larger distances, so the second dispatch bins over the observed +/// maximum instead. Clamping those to the SDR range would report a sigma² of ≈ 4 for a distance +/// of 1600, and `imp_pm_vote`'s weights would round to zero and keep the prior image. +/// +/// One 16 KB readback per EM round (≈ 28 per 1080p patch) on the SDR path: a few percent of the +/// stage, and the obvious next thing to remove if the patch has to be faster (a device-side +/// percentile would need a reduction instead). HDR frames pay one more dispatch and readback. +fn histogram(e: &mut Enc, field: &GpuImage, flags: &wgpu::Buffer, w: u32, h: u32, bb: [u32; 4], hist: &GpuImage) -> Option<(f32, f64)> { + let (bw, bh) = (bb[2] - bb[0], bb[3] - bb[1]); + let mut p = params([w, h, 0, 0], [bb[0], bb[1], bw, bh], [0; 4], [0.0; 4]); + e.dispatch("imp_hist", &p, KernelImages { src: field, aux: None, out: hist }, Some(flags), (1, 1)); + let bytes = e.read_texture(&hist.texture, BINS as u32, 1, 16)?; + let max = max_distance(&bytes); + if max <= SDR_DISTANCE { + return Some(percentile(&bytes, SDR_DISTANCE)); + } + // The observed maximum itself has to be in the last bin, so widen the range by a hair rather + // than binning it to 1024. + let range = max * (1.0 + 1e-6); + p.f[0] = [range, 0.0, 0.0, 0.0]; + e.dispatch("imp_hist", &p, KernelImages { src: field, aux: None, out: hist }, Some(flags), (1, 1)); + let bytes = e.read_texture(&hist.texture, BINS as u32, 1, 16)?; + Some(percentile(&bytes, range)) +} + +/// The vote's sigma² at a level (`pm_level`): the 75th percentile of the targets' distances. +fn sigma2(e: &mut Enc, field: &GpuImage, lv: &Lv, hist: &GpuImage) -> Option { + Some(histogram(e, field, &lv.flags, lv.w, lv.h, lv.near, hist)?.0) +} + +/// [`imp_hist`] on its own: `field`'s match distance (`.b`, `.a` > 0.5 where set) over the +/// pixels `near` marks, returning the 75th percentile and the sample count. Used by the test that +/// checks the histogram counts the whole target population. +#[cfg(test)] +pub(crate) fn distance_histogram(g: &GpuContext, field: &Image, near: &[bool], w: u32, h: u32) -> Option<(f32, f64)> { + let flags: Vec = near.iter().map(|b| if *b { 2.0 } else { 0.0 }).collect(); + let mut e = Enc::new(g); + let data = e.data(&flags); + let img = g.upload_image(field)?; + let hist = e.scratch(BINS as u32, 1); + let out = histogram(&mut e, &img, &data, w, h, [0, 0, w, h], &hist); + if e.take_missing() { + return None; + } + out +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 1be095c3f..0844a4335 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -79,6 +79,7 @@ mod fx_tone; mod fx_transition; mod fx_vr; mod fx_warp; +mod inpaint; mod memcap; mod ops; mod particles; @@ -416,6 +417,17 @@ impl Accelerator for Gpu { self.ctx.can_readback().then_some(self as &dyn effectcraft_effects::psim::ParticleSim) } + fn inpaint(&self) -> Option<&dyn effectcraft_raster::inpaint::FillBackend> { + self.ctx.check_health().ok()?; + // Readback is synchronous-only, and a kernel that failed to build would make every stage + // fall back to the CPU *after* paying the upload and a failed dispatch (and the fill + // would still report the GPU path). Neither is a backend at all. + if !self.ctx.can_wait() || self.ctx.unavailable_kernels().iter().any(|(e, _)| crate::inpaint::KERNELS.contains(e)) { + return None; + } + Some(self as &dyn effectcraft_raster::inpaint::FillBackend) + } + fn auto_pick(&self) -> Option<&effectcraft_render::AutoPick> { Some(&self.auto) } diff --git a/crates/gpu/src/shaders/inpaint.wgsl b/crates/gpu/src/shaders/inpaint.wgsl new file mode 100644 index 000000000..56c468f85 --- /dev/null +++ b/crates/gpu/src/shaders/inpaint.wgsl @@ -0,0 +1,796 @@ +// GPU Content-Aware Fill: the membrane (harmonic) fill and PatchMatch synthesis of +// effectcraft_raster::inpaint, operation for operation (the CPU reference is the oracle; see +// crates/gpu/src/tests/inpaint.rs). +// +// Bindings are the shared ones (common.wgsl): `src` / `aux` are the input textures (an image, a +// coarser level, or the nearest-neighbour field), `out` is the storage texture and `data` is a +// per-pixel float table — the level's flags: +// * membrane kernels: data[i] = 1 where the pixel is known (the hole's complement); +// * PatchMatch kernels: data[i] = bit 0 hole, bit 1 near (a pixel whose patch touches the hole). +// +// Parameter block (P.u = u32, P.f = f32): +// membrane kernels: u[0] = (w, h, coarseW, coarseH), u[1] = (x0, y0, 0, 0) (dispatch +// origin), u[3].x = this level's offset into `data` +// down kernels: u[0] = (srcW, srcH, dstW, dstH) +// PatchMatch kernels: u[0] = (w, h, r, parity), u[1] = (x0, y0, 0, 0) (dispatch origin), +// u[2] = (seed, iteration, 0, EM round), f[0] = (sigma², 0, 0, 0) +// histogram: u[0] = (w, h, 0, 0), u[1] = (x0, y0, w, h) (target bounds), +// f[0].x = the distance range (0: the SDR range 0…4) +// The nearest-neighbour field texture holds one entry per pixel: r, g = source centre ("px"), +// b = SSD distance (inf where unset), a = 1 where the entry is set. + +const IMP_INF: f32 = 1e30; + +fn imp_flags(i: i32) -> u32 { + return u32(max(data[i], 0.0)); +} + +fn imp_hole(i: i32) -> bool { + return (imp_flags(i) & 1u) != 0u; +} + +fn imp_near(i: i32) -> bool { + return (imp_flags(i) & 2u) != 0u; +} + +// A source centre whose patch is fully known and inside the image (Pm::valid_source). +fn imp_valid_source(x: i32, y: i32, w: i32, h: i32, r: i32) -> bool { + if (x < r || y < r || x + r >= w || y + r >= h) { + return false; + } + return !imp_near(y * w + x); +} + +// PatchMatch's hashing rnd (raster::inpaint::rnd), bit for bit: the CPU's random stream is a +// contract of the algorithm, not an implementation detail. +fn imp_rnd(seed: u32, a: u32, b: u32) -> u32 { + var h = (seed * 0x9E3779B9u) ^ (a * 0x85EBCA6Bu) ^ (b * 0xC2B2AE35u); + h = h ^ (h >> 16u); + h = h * 0x7FEB352Du; + h = h ^ (h >> 15u); + h = h * 0x846CA68Bu; + return h ^ (h >> 16u); +} + +// SSD of the patch at target (tx, ty) (clipped to the image) against source (sx, sy) +// (Pm::dist). `cap` short-circuits a match that is already worse. +fn imp_dist(tx: i32, ty: i32, sx: i32, sy: i32, w: i32, h: i32, r: i32, cap: f32) -> f32 { + var d = 0.0; + var n = 0.0; + for (var dy = -r; dy <= r; dy = dy + 1) { + let ty2 = ty + dy; + if (ty2 < 0 || ty2 >= h) { + continue; + } + let sy2 = sy + dy; + for (var dx = -r; dx <= r; dx = dx + 1) { + let tx2 = tx + dx; + if (tx2 < 0 || tx2 >= w) { + continue; + } + let a = textureLoad(aux, vec2(tx2, ty2), 0); + let b = textureLoad(aux, vec2(sx + dx, sy2), 0); + let e = a - b; + d = d + e.x * e.x + e.y * e.y + e.z * e.z + e.w * e.w; + n = n + 1.0; + } + if (d > cap * max(n, 1.0)) { + return IMP_INF; + } + } + return d / max(n, 1.0); +} + +// ---------------------------------------------------------------- membrane + +// membrane_core's coarse level: the known-weighted 2×2 average. +@compute @workgroup_size(16, 16) +fn imp_down(@builtin(global_invocation_id) gid: vec3) { + let w = i32(P.u[0].x); + let h = i32(P.u[0].y); + let cw = i32(P.u[0].z); + let ch = i32(P.u[0].w); + let cx = i32(gid.x); + let cy = i32(gid.y); + if (cx >= cw || cy >= ch) { + return; + } + var s = vec4(0.0); + var n = 0.0; + for (var dy = 0; dy < 2; dy = dy + 1) { + for (var dx = 0; dx < 2; dx = dx + 1) { + let fx = 2 * cx + dx; + let fy = 2 * cy + dy; + if (fx < w && fy < h && data[i32(P.u[3].x) + fy * w + fx] > 0.5) { + s = s + textureLoad(src, vec2(fx, fy), 0); + n = n + 1.0; + } + } + } + var v = vec4(0.0); + if (n > 0.0) { + v = s / n; + } + textureStore(out, vec2(cx, cy), v); +} + +// membrane_core's coarse-to-fine step: unknown pixels take the nearest coarse value. The fine +// image is copied into `out` first, so writing only the unknown pixels keeps the known ones. +// Dispatched over the unknown pixels' bounds (`P.u[1]`); the coarse level is valid everywhere. +@compute @workgroup_size(16, 16) +fn imp_up(@builtin(global_invocation_id) gid: vec3) { + let w = i32(P.u[0].x); + let h = i32(P.u[0].y); + let cw = i32(P.u[0].z); + let ch = i32(P.u[0].w); + let x = i32(P.u[1].x) + i32(gid.x); + let y = i32(P.u[1].y) + i32(gid.y); + if (x >= w || y >= h || data[i32(P.u[3].x) + y * w + x] > 0.5) { + return; + } + let cx = min(x / 2, cw - 1); + let cy = min(y / 2, ch - 1); + textureStore(out, vec2(x, y), textureLoad(src, vec2(cx, cy), 0)); +} + +// One relaxation of the unknown pixels (membrane_core's Jacobi sweep). `src` is the current +// state and `aux` is unused. Dispatched over the unknown pixels' bounds (`P.u[1]`): known +// pixels never move, so nothing outside them needs writing. Both `src` and `out` are valid +// across the image (the fine level's, a copy of it, or the full downsample). +@compute @workgroup_size(16, 16) +fn imp_jacobi(@builtin(global_invocation_id) gid: vec3) { + let w = i32(P.u[0].x); + let h = i32(P.u[0].y); + let x = i32(P.u[1].x) + i32(gid.x); + let y = i32(P.u[1].y) + i32(gid.y); + if (x >= w || y >= h) { + return; + } + var v = textureLoad(src, vec2(x, y), 0); + if (data[i32(P.u[3].x) + y * w + x] <= 0.5) { + var s = vec4(0.0); + var n = 0.0; + if (x > 0) { + s = s + textureLoad(src, vec2(x - 1, y), 0); + n = n + 1.0; + } + if (x + 1 < w) { + s = s + textureLoad(src, vec2(x + 1, y), 0); + n = n + 1.0; + } + if (y > 0) { + s = s + textureLoad(src, vec2(x, y - 1), 0); + n = n + 1.0; + } + if (y + 1 < h) { + s = s + textureLoad(src, vec2(x, y + 1), 0); + n = n + 1.0; + } + if (n > 0.0) { + v = s / n; + } + } + textureStore(out, vec2(x, y), v); +} + +// ---------------------------------------------------------------- PatchMatch + +// PatchMatch's pyramid downsample (downsample): the level's image, hole pixels excluded from the +// average, over `out`'s size. +@compute @workgroup_size(16, 16) +fn imp_pm_down(@builtin(global_invocation_id) gid: vec3) { + let w = i32(P.u[0].x); + let h = i32(P.u[0].y); + let cw = i32(P.u[0].z); + let ch = i32(P.u[0].w); + let cx = i32(gid.x); + let cy = i32(gid.y); + if (cx >= cw || cy >= ch) { + return; + } + var s = vec4(0.0); + var n = 0.0; + for (var dy = 0; dy < 2; dy = dy + 1) { + for (var dx = 0; dx < 2; dx = dx + 1) { + let fx = min(2 * cx + dx, w - 1); + let fy = min(2 * cy + dy, h - 1); + if (!imp_hole(fy * w + fx)) { + s = s + textureLoad(src, vec2(fx, fy), 0); + n = n + 1.0; + } + } + } + var v = vec4(0.0); + if (n > 0.0) { + v = s / n; + } + textureStore(out, vec2(cx, cy), v); +} + +// PatchMatch's coarse-to-fine step: bilinear upsample of the coarse image into this level's hole +// (patch_fill's upsample loop). The fine image is copied into `out` first. +@compute @workgroup_size(16, 16) +fn imp_pm_up(@builtin(global_invocation_id) gid: vec3) { + let w = i32(P.u[0].x); + let h = i32(P.u[0].y); + let cw = i32(P.u[0].z); + let ch = i32(P.u[0].w); + let x = i32(P.u[1].x) + i32(gid.x); + let y = i32(P.u[1].y) + i32(gid.y); + if (x >= w || y >= h || !imp_hole(y * w + x)) { + return; + } + let fx = clamp((f32(x) + 0.5) / 2.0 - 0.5, 0.0, f32(cw - 1)); + let fy = clamp((f32(y) + 0.5) / 2.0 - 0.5, 0.0, f32(ch - 1)); + let x0 = i32(floor(fx)); + let y0 = i32(floor(fy)); + let x1 = min(x0 + 1, cw - 1); + let y1 = min(y0 + 1, ch - 1); + let tx = fx - f32(x0); + let ty = fy - f32(y0); + let a = textureLoad(src, vec2(x0, y0), 0); + let b = textureLoad(src, vec2(x1, y0), 0); + let c = textureLoad(src, vec2(x0, y1), 0); + let d = textureLoad(src, vec2(x1, y1), 0); + let top = a + (b - a) * tx; + let bot = c + (d - c) * tx; + textureStore(out, vec2(x, y), top + (bot - top) * ty); +} + +// The NNF's coarse-to-fine step: ×2 (patch_fill's nnf upsample). +@compute @workgroup_size(16, 16) +fn imp_nnf_up(@builtin(global_invocation_id) gid: vec3) { + let w = i32(P.u[0].x); + let h = i32(P.u[0].y); + let cw = i32(P.u[0].z); + let ch = i32(P.u[0].w); + let x = i32(gid.x); + let y = i32(gid.y); + if (x >= w || y >= h) { + return; + } + let cx = min(x / 2, cw - 1); + let cy = min(y / 2, ch - 1); + let c = textureLoad(src, vec2(cx, cy), 0); + var o = vec4(0.0); + if (c.a > 0.5) { + o = vec4(c.r * 2.0 + f32(x % 2), c.g * 2.0 + f32(y % 2), 0.0, 1.0); + } + textureStore(out, vec2(x, y), o); +} + +// A random valid source centre, by rejection (the CPU draws from its compacted source list; the +// distribution over the valid centres is the same, the stream differs from the list's order). +fn imp_any_source(seed: u32, i: u32, k: u32, w: i32, h: i32, r: i32) -> vec2 { + for (var n = 0u; n < 32u; n = n + 1u) { + let a = imp_rnd(seed, i, k + n); + let x = i32(a % u32(w)); + let y = i32((a >> 16u) % u32(h)); + if (imp_valid_source(x, y, w, h, r)) { + return vec2(x, y); + } + } + return vec2(-1, -1); +} + +// A round's initial field: the inherited source where it is still valid, a random one otherwise +// (pm_level's initialise/validate step). +@compute @workgroup_size(16, 16) +fn imp_pm_init(@builtin(global_invocation_id) gid: vec3) { + let w = i32(P.u[0].x); + let h = i32(P.u[0].y); + let r = i32(P.u[0].z); + let x = i32(P.u[1].x) + i32(gid.x); + let y = i32(P.u[1].y) + i32(gid.y); + if (x >= w || y >= h) { + return; + } + let i = y * w + x; + var o = vec4(0.0, 0.0, IMP_INF, 0.0); + if (imp_near(i)) { + let prev = textureLoad(src, vec2(x, y), 0); + var s = vec2(i32(prev.r), i32(prev.g)); + if (prev.a <= 0.5 || !imp_valid_source(s.x, s.y, w, h, r)) { + // rnd(seed ^ e, i, 0), the round's random source (see imp_any_source). + s = imp_any_source(P.u[2].x ^ P.u[2].w, u32(i), 0u, w, h, r); + } + if (s.x >= 0) { + let d = imp_dist(x, y, s.x, s.y, w, h, r, IMP_INF); + o = vec4(f32(s.x), f32(s.y), d, 1.0); + } + } + textureStore(out, vec2(x, y), o); +} + +// One propagation pass: every target pixel tries its four neighbours' sources shifted by the +// step that would move that neighbour's match onto this pixel. The checkerboard parity in P.u[0].w +// makes the pass race-free (a pixel of one parity only reads the other's). +@compute @workgroup_size(16, 16) +fn imp_pm_prop(@builtin(global_invocation_id) gid: vec3) { + let w = i32(P.u[0].x); + let h = i32(P.u[0].y); + let r = i32(P.u[0].z); + let x = i32(P.u[1].x) + i32(gid.x); + let y = i32(P.u[1].y) + i32(gid.y); + if (x >= w || y >= h) { + return; + } + let cur = textureLoad(src, vec2(x, y), 0); + let i = y * w + x; + if (!imp_near(i) || ((x + y) & 1) != i32(P.u[0].w)) { + textureStore(out, vec2(x, y), cur); + return; + } + var best = vec2(i32(cur.r), i32(cur.g)); + var bd = cur.b; + if (cur.a <= 0.5) { + bd = IMP_INF; + } + for (var k = 0; k < 4; k = k + 1) { + var nx = x; + var ny = y; + var sx = 0; + var sy = 0; + if (k == 0) { + nx = x - 1; + sx = 1; + } else if (k == 1) { + nx = x + 1; + sx = -1; + } else if (k == 2) { + ny = y - 1; + sy = 1; + } else { + ny = y + 1; + sy = -1; + } + if (nx < 0 || ny < 0 || nx >= w || ny >= h) { + continue; + } + let n = textureLoad(src, vec2(nx, ny), 0); + if (n.a <= 0.5) { + continue; + } + let c = vec2(i32(n.r) + sx, i32(n.g) + sy); + if (c.x == best.x && c.y == best.y) { + continue; + } + if (!imp_valid_source(c.x, c.y, w, h, r)) { + continue; + } + let d = imp_dist(x, y, c.x, c.y, w, h, r, bd); + if (d < bd) { + bd = d; + best = c; + } + } + // alpha = the entry is set: `bd` stays INF only if no match was found. + textureStore(out, vec2(x, y), vec4(f32(best.x), f32(best.y), bd, select(0.0, 1.0, bd < IMP_INF))); +} + +// The random search (halving radius) and one random source anywhere, after propagation. +@compute @workgroup_size(16, 16) +fn imp_pm_rand(@builtin(global_invocation_id) gid: vec3) { + let w = i32(P.u[0].x); + let h = i32(P.u[0].y); + let r = i32(P.u[0].z); + let x = i32(P.u[1].x) + i32(gid.x); + let y = i32(P.u[1].y) + i32(gid.y); + if (x >= w || y >= h) { + return; + } + let cur = textureLoad(src, vec2(x, y), 0); + let i = y * w + x; + if (!imp_near(i) || cur.a <= 0.5) { + textureStore(out, vec2(x, y), cur); + return; + } + var best = vec2(i32(cur.r), i32(cur.g)); + var bd = cur.b; + let seed = P.u[2].x; + let it = P.u[2].y; + let em = P.u[2].w; + let iu = u32(i); + var rad = max(w, h); + var k = 0u; + while (rad >= 1) { + let a = imp_rnd(seed + it * 977u + em * 31u, iu, k); + let ox = i32(a % u32(2 * rad + 1)) - rad; + let oy = i32((a >> 16u) % u32(2 * rad + 1)) - rad; + let c = vec2(best.x + ox, best.y + oy); + if (imp_valid_source(c.x, c.y, w, h, r)) { + let d = imp_dist(x, y, c.x, c.y, w, h, r, bd); + if (d < bd) { + bd = d; + best = c; + } + } + rad = rad / 2; + k = k + 1u; + } + let any = imp_any_source(seed ^ 0xABCDu, iu, it + em * 64u, w, h, r); + if (any.x >= 0 && (any.x != best.x || any.y != best.y)) { + let d = imp_dist(x, y, any.x, any.y, w, h, r, bd); + if (d < bd) { + bd = d; + best = any; + } + } + textureStore(out, vec2(x, y), vec4(f32(best.x), f32(best.y), bd, select(0.0, 1.0, bd < IMP_INF))); +} + +// Carry a round's field back into the nearest-neighbour field (only targets, as on the CPU). +@compute @workgroup_size(16, 16) +fn imp_nnf_store(@builtin(global_invocation_id) gid: vec3) { + let w = i32(P.u[0].x); + let h = i32(P.u[0].y); + let x = i32(gid.x); + let y = i32(gid.y); + if (x >= w || y >= h) { + return; + } + let i = y * w + x; + var o = textureLoad(aux, vec2(x, y), 0); + if (imp_near(i)) { + let f = textureLoad(src, vec2(x, y), 0); + o = vec4(f.r, f.g, 0.0, 1.0); + } + textureStore(out, vec2(x, y), o); +} + +// The vote: every hole pixel becomes the distance-weighted mean of the samples its overlapping +// patches carry (pm_level's vote step). `aux` is the current image; the image is copied into +// `out` first, so only hole pixels are written. +@compute @workgroup_size(16, 16) +fn imp_pm_vote(@builtin(global_invocation_id) gid: vec3) { + let w = i32(P.u[0].x); + let h = i32(P.u[0].y); + let r = i32(P.u[0].z); + let x = i32(P.u[1].x) + i32(gid.x); + let y = i32(P.u[1].y) + i32(gid.y); + if (x >= w || y >= h) { + return; + } + let i = y * w + x; + if (!imp_hole(i)) { + return; + } + let sigma2 = P.f[0].x; + var acc = vec4(0.0); + var wsum = 0.0; + for (var dy = -r; dy <= r; dy = dy + 1) { + let py = y + dy; + if (py < 0 || py >= h) { + continue; + } + for (var dx = -r; dx <= r; dx = dx + 1) { + let px = x + dx; + if (px < 0 || px >= w) { + continue; + } + let f = textureLoad(src, vec2(px, py), 0); + if (f.a <= 0.5 || f.b >= IMP_INF) { + continue; + } + let s = vec2(i32(f.r) - dx, i32(f.g) - dy); + let wt = exp(-f.b / (2.0 * sigma2)); + acc = acc + textureLoad(aux, vec2(s.x, s.y), 0) * wt; + wsum = wsum + wt; + } + } + if (wsum > 0.0) { + textureStore(out, vec2(x, y), acc / wsum); + } else { + textureStore(out, vec2(x, y), textureLoad(aux, vec2(x, y), 0)); + } +} + +// The workgroup's 1024 distance bins (`imp_hist`); the name is unique across the concatenated +// shader sources because `var` is module scope. +var imp_hist_bins: array, 1024>; +// The largest finite distance this dispatch saw (as its f32 bit pattern: positive floats are +// ordered by their bits). `sigma2` uses it to learn whether the frame needs a wider range. +var imp_hist_max: atomic; + +// One workgroup's histogram of the targets' match distances, for the vote's sigma² (the CPU takes +// the 75th percentile of the finite distances). The single workgroup accumulates the whole +// population through the shared atomic bins above. +// +// `P.f[0].x` is the range the bins cover; 0 selects the SDR range 0…4, where an image whose +// channels are all ≤ 1 lives: each channel's squared difference is ≤ 4, so the mean SSD is too. +// Frames with values above 1 (32-bpc HDR) have larger distances, so `sigma2` re-dispatches with +// the observed maximum as the range instead of clamping them to bin 1023, which would report a +// sigma² of ≈ 4 for a distance of 1600 and round the vote's weights to zero. The observed maximum +// rides in bin 0's unused `.g` channel on every dispatch (its `.x` stays the count). +@compute @workgroup_size(256, 1, 1) +fn imp_hist(@builtin(global_invocation_id) gid: vec3) { + let w = i32(P.u[0].x); + let h = i32(P.u[0].y); + let x0 = i32(P.u[1].x); + let y0 = i32(P.u[1].y); + let bw = i32(P.u[1].z); + let bh = i32(P.u[1].w); + let t = gid.x; + let range = select(4.0, P.f[0].x, P.f[0].x > 0.0); + let scale = 1024.0 / range; + if (t == 0u) { + atomicStore(&imp_hist_max, 0u); + } + // One workgroup, so the whole population is counted through shared atomics: a per-thread + // strided pass would keep only the pixels whose bin selected that thread (a biased ~1/256 + // subsample, correlated with the pixel index). + for (var i = t; i < 1024u; i = i + 256u) { + atomicStore(&imp_hist_bins[i], 0u); + } + workgroupBarrier(); + let total = u32(max(bw, 0) * max(bh, 0)); + for (var idx = t; idx < total; idx = idx + 256u) { + let x = x0 + i32(idx) % bw; + let y = y0 + i32(idx) / bw; + let i = y * w + x; + if (!imp_near(i)) { + continue; + } + let f = textureLoad(src, vec2(x, y), 0); + if (f.a <= 0.5 || f.b >= IMP_INF) { + continue; + } + atomicMax(&imp_hist_max, bitcast(f.b)); + // Clamp in f32 before the conversion: a distance far outside the range (a mismatched patch + // in an HDR frame) would overflow the i32 conversion, which is indeterminate in WGSL. + let b = u32(clamp(f.b * scale, 0.0, 1023.0)); + atomicAdd(&imp_hist_bins[b], 1u); + } + workgroupBarrier(); + // 256 threads × 4 bins cover the histogram. + for (var j = 0u; j < 4u; j = j + 1u) { + let bin = t * 4u + j; + textureStore(out, vec2(i32(bin), 0), vec4(f32(atomicLoad(&imp_hist_bins[bin])), 0.0, 0.0, 0.0)); + } + // Thread 0 is bin 0's only writer, so this store follows the one above. `.g` carries the + // observed maximum: host readbacks keep the row 1024 texels wide (a 16 KB, 256-aligned copy). + if (t == 0u) { + textureStore(out, vec2(0, 0), vec4(f32(atomicLoad(&imp_hist_bins[0])), bitcast(atomicLoad(&imp_hist_max)), 0.0, 0.0)); + } +} + +// ---------------------------------------------------------------- block flow (flow completion) + +// The block matcher of effectcraft_raster::flow::block_flow(a, b, 8, 6), which the fill uses to +// compute the motion between neighbouring membrane-filled frames before completing it inside the +// holes (`FillBackend::flow`). `src` packs the two frames' luma planes at the current level: +// .x is frame A's, .y is frame B's (`effectcraft_raster::flow::Plane::luma`). The kernels mirror +// the CPU operation for operation, so the field matches the CPU's (crates/gpu/src/tests/inpaint.rs). + +// Plane::half of the packed pair: the clamped 2×2 average of each channel. +@compute @workgroup_size(16, 16) +fn imp_fl_half(@builtin(global_invocation_id) gid: vec3) { + let cx = i32(gid.x); + let cy = i32(gid.y); + if (cx >= i32(P.u[0].z) || cy >= i32(P.u[0].w)) { + return; + } + let w = i32(P.u[0].x); + let h = i32(P.u[0].y); + var sx = 0.0; + var sy = 0.0; + for (var dy = 0; dy < 2; dy = dy + 1) { + for (var dx = 0; dx < 2; dx = dx + 1) { + let v = textureLoad(src, vec2(min(2 * cx + dx, w - 1), min(2 * cy + dy, h - 1)), 0); + sx = sx + v.x; + sy = sy + v.y; + } + } + textureStore(out, vec2(cx, cy), vec4(sx * 0.25, sy * 0.25, 0.0, 0.0)); +} + +// Plane::get: the packed luma at (x, y), clamped to the plane. +fn imp_fl_tex(x: i32, y: i32, w: i32, h: i32) -> vec2 { + let v = textureLoad(src, clamp(vec2(x, y), vec2(0, 0), vec2(w - 1, h - 1)), 0); + return vec2(v.x, v.y); +} + +// The block match's SAD with A's block already loaded (`la`, row-major): B's luma is the packed +// .y. The sub-pixel kernel's five fits share the window's block, so it is read once; the search +// hoists its own 4×4/8×8 block the same way. The loops' bounds are literals so the backend can +// unroll them and keep the loads in flight — a runtime-bound loop runs an order of magnitude +// slower. +fn imp_fl_sad8h(bx: i32, by: i32, dx: i32, dy: i32, w: i32, h: i32, la: array) -> f32 { + var s = 0.0; + for (var yy = 0; yy < 8; yy = yy + 1) { + for (var xx = 0; xx < 8; xx = xx + 1) { + s = s + abs(la[yy * 8 + xx] - imp_fl_tex(bx + xx + dx, by + yy + dy, w, h).y); + } + } + return s; +} + +// block_flow_window's coarse-to-fine search at one level: one integer vector per tile, seeded by +// the previous (coarser) level's vector at the tile and its four neighbours. `aux` is that field +// (`P.u[2].x` = 0 at the coarsest level, where the only seed is the zero vector). +@compute @workgroup_size(16, 16) +fn imp_fl_search(@builtin(global_invocation_id) gid: vec3) { + let cols = i32(P.u[0].z); + let rows = i32(P.u[0].w); + let c = i32(gid.x); + let r = i32(gid.y); + if (c >= cols || r >= rows) { + return; + } + let w = i32(P.u[0].x); + let h = i32(P.u[0].y); + let bs = i32(P.u[1].x); + let k = i32(P.u[1].z); + let rad = i32(P.u[1].w); + let lbs = max(i32(P.u[1].y) / k, 4); + let bx = c * bs / k; + let by = r * bs / k; + // The zero vector plus up to five neighbour seeds (the tile and its four neighbours). + var seeds: array, 6>; + var ns = 0; + seeds[ns] = vec2(0, 0); + ns = ns + 1; + if (P.u[2].x != 0u) { + var nbr = array, 5>(vec2(0, 0), vec2(-1, 0), vec2(1, 0), vec2(0, -1), vec2(0, 1)); + for (var j = 0; j < 5; j = j + 1) { + let nc = c + nbr[j].x; + let nr = r + nbr[j].y; + if (nc >= 0 && nr >= 0 && nc < cols && nr < rows) { + let pv = textureLoad(aux, vec2(nc, nr), 0); + seeds[ns] = vec2(i32(round(pv.x)) * 2, i32(round(pv.y)) * 2); + ns = ns + 1; + } + } + } + // sort_unstable + dedup: distinct seeds in lexicographic (x, then y) order. + for (var i = 1; i < ns; i = i + 1) { + let key = seeds[i]; + var j = i - 1; + while (j >= 0 && (seeds[j].x > key.x || (seeds[j].x == key.x && seeds[j].y > key.y))) { + seeds[j + 1] = seeds[j]; + j = j - 1; + } + seeds[j + 1] = key; + } + var m = 0; + for (var i = 0; i < ns; i = i + 1) { + if (m == 0 || seeds[i].x != seeds[m - 1].x || seeds[i].y != seeds[m - 1].y) { + seeds[m] = seeds[i]; + m = m + 1; + } + } + ns = m; + // A's block is the same for every candidate, so load it once (the unrolled loops keep it in + // registers) and the search only reads B's shifted block. + var la4: array; + var la8: array; + if (lbs >= 8) { + for (var yy = 0; yy < 8; yy = yy + 1) { + for (var xx = 0; xx < 8; xx = xx + 1) { + la8[yy * 8 + xx] = imp_fl_tex(bx + xx, by + yy, w, h).x; + } + } + } else { + for (var yy = 0; yy < 4; yy = yy + 1) { + for (var xx = 0; xx < 4; xx = xx + 1) { + la4[yy * 4 + xx] = imp_fl_tex(bx + xx, by + yy, w, h).x; + } + } + } + var best = vec2(0, 0); + var best_e = 1e30; + for (var si = 0; si < ns; si = si + 1) { + let s = seeds[si]; + for (var dy = -rad; dy <= rad; dy = dy + 1) { + for (var dx = -rad; dx <= rad; dx = dx + 1) { + let d0 = s.x + dx; + let d1 = s.y + dy; + var acc = 0.0; + if (lbs >= 8) { + for (var yy = 0; yy < 8; yy = yy + 1) { + for (var xx = 0; xx < 8; xx = xx + 1) { + acc = acc + abs(la8[yy * 8 + xx] - imp_fl_tex(bx + xx + d0, by + yy + d1, w, h).y); + } + } + } else { + for (var yy = 0; yy < 4; yy = yy + 1) { + for (var xx = 0; xx < 4; xx = xx + 1) { + acc = acc + abs(la4[yy * 4 + xx] - imp_fl_tex(bx + xx + d0, by + yy + d1, w, h).y); + } + } + } + let e = acc + 1e-4 * f32(abs(d0) + abs(d1)); + if (e < best_e) { + best_e = e; + best = vec2(d0, d1); + } + } + } + } + textureStore(out, vec2(c, r), vec4(f32(best.x), f32(best.y), 0.0, 0.0)); +} + +// The parabola fit of the SAD around the best vector (block_flow_window's sub-pixel step). +fn imp_fl_fit(em: f32, ep: f32, e0: f32) -> f32 { + let den = em - 2.0 * e0 + ep; + if (den > 1e-6) { + return clamp(0.5 * (em - ep) / den, -0.5, 0.5); + } + return 0.0; +} + +// Sub-pixel refinement at full resolution: `aux` is the integer field of level 0, `src` the +// level-0 packed luma (`P.u[1]` = (tile, window, 0, 0)). +@compute @workgroup_size(16, 16) +fn imp_fl_subpix(@builtin(global_invocation_id) gid: vec3) { + let cols = i32(P.u[0].z); + let rows = i32(P.u[0].w); + let c = i32(gid.x); + let r = i32(gid.y); + if (c >= cols || r >= rows) { + return; + } + let w = i32(P.u[0].x); + let h = i32(P.u[0].y); + let bs = i32(P.u[1].x); + let win = i32(P.u[1].y); + let bx = c * bs; + let by = r * bs; + let f = textureLoad(aux, vec2(c, r), 0); + let d = vec2(i32(round(f.x)), i32(round(f.y))); + // The window's A block is the same for the five SADs: load it once (the loop is unrolled). + var la: array; + for (var yy = 0; yy < 8; yy = yy + 1) { + for (var xx = 0; xx < 8; xx = xx + 1) { + la[yy * 8 + xx] = imp_fl_tex(bx + xx, by + yy, w, h).x; + } + } + let e0 = imp_fl_sad8h(bx, by, d.x, d.y, w, h, la); + let sx = imp_fl_fit(imp_fl_sad8h(bx, by, d.x - 1, d.y, w, h, la), imp_fl_sad8h(bx, by, d.x + 1, d.y, w, h, la), e0); + let sy = imp_fl_fit(imp_fl_sad8h(bx, by, d.x, d.y - 1, w, h, la), imp_fl_sad8h(bx, by, d.x, d.y + 1, w, h, la), e0); + textureStore(out, vec2(c, r), vec4(f32(d.x) + sx, f32(d.y) + sy, 0.0, 0.0)); +} + +// The 3×3 component-wise vector median against outliers (each component sorted on its own). +@compute @workgroup_size(16, 16) +fn imp_fl_median(@builtin(global_invocation_id) gid: vec3) { + let cols = i32(P.u[0].z); + let rows = i32(P.u[0].w); + let c = i32(gid.x); + let r = i32(gid.y); + if (c >= cols || r >= rows) { + return; + } + var xs: array; + var ys: array; + var n = 0; + for (var dr = -1; dr <= 1; dr = dr + 1) { + for (var dc = -1; dc <= 1; dc = dc + 1) { + let nc = c + dc; + let nr = r + dr; + if (nc >= 0 && nr >= 0 && nc < cols && nr < rows) { + let v = textureLoad(src, vec2(nc, nr), 0); + xs[n] = v.x; + ys[n] = v.y; + n = n + 1; + } + } + } + for (var i = 1; i < n; i = i + 1) { + let kx = xs[i]; + var j = i - 1; + while (j >= 0 && xs[j] > kx) { + xs[j + 1] = xs[j]; + j = j - 1; + } + xs[j + 1] = kx; + let ky = ys[i]; + j = i - 1; + while (j >= 0 && ys[j] > ky) { + ys[j + 1] = ys[j]; + j = j - 1; + } + ys[j + 1] = ky; + } + textureStore(out, vec2(c, r), vec4(xs[n / 2], ys[n / 2], 0.0, 0.0)); +} diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index ef8673b5f..3f8e158f6 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -52,6 +52,7 @@ mod fx_vr; mod fx_warp; #[cfg(all(test, not(target_arch = "wasm32")))] mod init; +mod inpaint; mod particles; mod three_d; mod wgsl_grammar; diff --git a/crates/gpu/src/tests/inpaint.rs b/crates/gpu/src/tests/inpaint.rs new file mode 100644 index 000000000..e00d3cc18 --- /dev/null +++ b/crates/gpu/src/tests/inpaint.rs @@ -0,0 +1,310 @@ +//! GPU Content-Aware Fill vs the CPU reference: the CPU's fill is the oracle. +//! +//! Parity here is a quality contract, not bit-exactness (see `inpaint.rs`): PatchMatch's +//! scan-order propagation becomes a two-pass checkerboard. So the tests compare both +//! syntheses against the ground truth they are reconstructing, and check the GPU is not the +//! worse of the two. + +use effectcraft_raster::Image; +use effectcraft_raster::inpaint::{FillBackend, FillInput, FillOpts, fill_sequence_with, membrane_fill, patch_fill, psnr}; + +use crate::tests::gpu; + +/// A textured background (periodic stripes + blocky noise), shifted by `dx` (the CPU tests' +/// scene, so the numbers here are comparable with theirs). +fn background(w: u32, h: u32, dx: f32) -> Image { + let mut img = Image::new(w, h); + for y in 0..h { + for x in 0..w { + let fx = x as f32 + dx; + let s = 0.5 + 0.3 * (fx * std::f32::consts::TAU / 10.0).sin(); + let n = effectcraft_raster::hash_noise(((fx.floor() as i64).rem_euclid(20) / 4) as u32, (y % 20) / 4, 3); + img.set(x, y, [s, 0.3 + 0.4 * n, 0.6 - 0.3 * s * n, 1.0]); + } + } + img +} + +fn cut(img: &Image, hole: &[bool]) -> Image { + let mut o = img.clone(); + for (p, h) in o.data.iter_mut().zip(hole) { + if *h { + *p = [0.0; 4]; + } + } + o +} + +fn rect_hole(w: u32, h: u32, x0: u32, y0: u32, s: u32) -> Vec { + (0..w * h).map(|i| (x0..x0 + s).contains(&(i % w)) && (y0..y0 + s).contains(&(i / w))).collect() +} + +/// The kernels must exist: a shader that fails to build falls back to the CPU silently, which +/// would make every quality test below pass while testing nothing. +#[test] +fn the_inpaint_kernels_are_available() { + let Some(g) = gpu() else { return }; + let bad: Vec<&str> = g.ctx.unavailable_kernels().iter().filter(|(e, _)| crate::inpaint::KERNELS.contains(e)).map(|(e, _)| *e).collect(); + assert!(bad.is_empty(), "inpaint kernels did not build on {}: {bad:?}", g.ctx.name); +} + +/// The device path (not the CPU fallback) is what the quality tests measure. +#[test] +fn the_gpu_runs_the_fill_itself() { + let Some(g) = gpu() else { return }; + let (w, h) = (96u32, 72u32); + let gt = background(w, h, 0.0); + let hole = rect_hole(w, h, 40, 28, 16); + let src = cut(>, &hole); + assert!(crate::inpaint::patch_image(&g.ctx, &src, &hole, None, 7, 1).is_some(), "PatchMatch fell back to the CPU"); +} + +#[test] +fn patchmatch_beats_the_membrane_on_texture() { + let Some(g) = gpu() else { return }; + let (w, h) = (96u32, 72u32); + let gt = background(w, h, 0.0); + let hole = rect_hole(w, h, 40, 28, 16); + let src = cut(>, &hole); + let mem = membrane_fill(&src, &hole); + let pm = g.patch(&src, &hole, None, 7, 1); + let (pm_db, mem_db) = (psnr(&pm, >, Some(&hole)), psnr(&mem, >, Some(&hole))); + assert!(pm_db > mem_db + 3.0 && pm_db > 20.0, "PatchMatch {pm_db:.1} dB vs membrane {mem_db:.1} dB"); + for i in 0..hole.len() { + if !hole[i] { + assert_eq!(pm.data[i], gt.data[i]); + } + } +} + +#[test] +fn patchmatch_agrees_with_the_cpu() { + let Some(g) = gpu() else { return }; + let (w, h) = (96u32, 72u32); + let gt = background(w, h, 0.0); + let hole = rect_hole(w, h, 40, 28, 16); + let src = cut(>, &hole); + let (cpu, gpu_pm) = (patch_fill(&src, &hole, None, 7, 1), g.patch(&src, &hole, None, 7, 1)); + // Both are syntheses of the same hole: neither is the other's ground truth, so they are + // compared as two reconstructions of the same thing. On this periodic background both are + // near-exact, so a relative dB check at machine precision says nothing; the device must be a + // good reconstruction *and* agree with the CPU (a wrong fill that still looks plausible would + // pass the first and fail the second). + let gpu_db = psnr(&gpu_pm, >, Some(&hole)); + assert!(gpu_db > 25.0, "GPU {gpu_db:.1} dB"); + assert!(psnr(&gpu_pm, &cpu, Some(&hole)) > 30.0, "the two syntheses disagree"); +} + +/// The `init` path (a frame refined from the previous frame's warped fill) is the finest level +/// only, from two rounds of four iterations (`patch_fill`). +#[test] +fn patchmatch_refines_an_init_frame() { + let Some(g) = gpu() else { return }; + let (w, h) = (96u32, 72u32); + let gt = background(w, h, 0.0); + let hole = rect_hole(w, h, 40, 28, 16); + let src = cut(>, &hole); + let init = gt.clone(); + let (cpu, gpu_pm) = (patch_fill(&src, &hole, Some(&init), 7, 1), g.patch(&src, &hole, Some(&init), 7, 1)); + assert!(psnr(&gpu_pm, >, Some(&hole)) > 25.0, "{:.1} dB", psnr(&gpu_pm, >, Some(&hole))); + assert!(psnr(&gpu_pm, &cpu, Some(&hole)) > 20.0, "the two syntheses disagree"); +} + +/// A hole across the whole multi-scale pyramid (the coarse levels' membrane start included): +/// every level must come back finite and the pyramid must beat a single-level solve. +#[test] +fn patchmatch_survives_a_large_hole_across_levels() { + let Some(g) = gpu() else { return }; + let (w, h) = (256u32, 192u32); + let gt = background(w, h, 0.0); + let hole = rect_hole(w, h, 90, 70, 80); + let src = cut(>, &hole); + let pm = g.patch(&src, &hole, None, 7, 1); + assert!(pm.data.iter().flatten().all(|v| v.is_finite()), "the synthesis left a non-finite value"); + let db = psnr(&pm, >, Some(&hole)); + assert!(db > 20.0, "{db:.1} dB"); + for i in 0..hole.len() { + if !hole[i] { + assert_eq!(pm.data[i], gt.data[i], "known pixel {i}"); + } + } +} + +/// A moving object removed from a (static or panning) textured background, filled frame by +/// frame through the device backend the sequence driver uses. +#[test] +fn object_fill_recovers_background_and_is_stable() { + let Some(g) = gpu() else { return }; + let (w, h) = (96u32, 64u32); + for pan in [0.0f32, 1.0] { + let mut frames = vec![]; + let mut holes = vec![]; + let mut gts = vec![]; + for t in 0..8u32 { + let gt = background(w, h, t as f32 * pan); + let hole = rect_hole(w, h, 10 + 8 * t, 22, 18); + frames.push(cut(>, &hole)); + holes.push(hole); + gts.push(gt); + } + let input = FillInput { frames, holes, references: vec![] }; + let out = fill_sequence_with(&input, &FillOpts::default(), g, &mut |_, _| true).expect("fill"); + for t in 0..out.len() { + let db = psnr(&out[t], >s[t], Some(&input.holes[t])); + assert!(db > 26.0, "frame {t} pan {pan}: {db:.1} dB"); + assert_eq!(out[t].data[0][3], 1.0); + } + if pan == 0.0 { + for t in 1..out.len() { + let both: Vec = input.holes[t].iter().zip(&input.holes[t - 1]).map(|(a, b)| *a || *b).collect(); + let db = psnr(&out[t], &out[t - 1], Some(&both)); + assert!(db > 30.0, "frames {}–{t} differ: {db:.1} dB", t - 1); + } + } + } +} + +/// The membrane stays on the CPU (see [`crate::inpaint`]): Edge Blend must still get the CPU's +/// result through the backend, not a device one. +#[test] +fn edge_blend_takes_the_cpu_membrane() { + let Some(g) = gpu() else { return }; + let (w, h) = (80u32, 60u32); + let gt = background(w, h, 0.0); + let hole = rect_hole(w, h, 30, 20, 14); + let input = FillInput { frames: vec![cut(>, &hole); 4], holes: vec![hole.clone(); 4], references: vec![] }; + let opts = FillOpts { method: effectcraft_raster::inpaint::FillMethod::EdgeBlend, ..Default::default() }; + let edge = fill_sequence_with(&input, &opts, g, &mut |_, _| true).expect("fill"); + for t in 1..4 { + assert!(psnr(&edge[t], &edge[0], Some(&hole)) > 40.0, "frame {t}"); + } + assert!(psnr(&edge[0], &membrane_fill(&input.frames[0], &hole), Some(&hole)) > 60.0); +} + +/// The dilation the level plans are built from (kept honest: the flags' target bit is `near`). +#[test] +fn the_target_band_is_the_dilated_hole() { + let (w, h) = (24u32, 18u32); + let hole = rect_hole(w, h, 8, 6, 4); + let near = effectcraft_raster::inpaint::dilate(&hole, w as usize, h as usize, 3); + assert!(near.iter().filter(|b| **b).count() > hole.iter().filter(|b| **b).count()); + assert!(near.iter().zip(&hole).all(|(n, h)| !*h || *n)); +} + +/// The flow's block matching runs on the device and finds the same motion the CPU does: the two +/// frames are the membrane-filled neighbours the sequence driver builds, with a hole in each. +#[test] +fn flow_agrees_with_the_cpu() { + let Some(g) = gpu() else { return }; + let (w, h) = (192u32, 128u32); + let a_gt = background(w, h, 0.0); + let b_gt = background(w, h, 5.0); + let hole_a = rect_hole(w, h, 30, 40, 40); + let mut hole_b = vec![false; (w * h) as usize]; + for y in 50..90 { + for x in 120..160 { + hole_b[(y * w + x) as usize] = true; + } + } + let a = membrane_fill(&cut(&a_gt, &hole_a), &hole_a); + let b = membrane_fill(&cut(&b_gt, &hole_b), &hole_b); + assert!(crate::inpaint::flow_image(&g.ctx, &a, &b).is_some(), "the flow fell back to the CPU"); + let cpu = effectcraft_raster::inpaint::CpuFill.flow(&a, &b, &hole_a, &hole_b); + let dev = g.flow(&a, &b, &hole_a, &hole_b); + assert_eq!((dev.cols, dev.rows, dev.block), (cpu.cols, cpu.rows, cpu.block)); + // The field is the CPU's to a float rounding: the luma is the CPU's (`luma_pair`), the + // pyramid is exact additions, and the search enumerates candidates in the CPU's order. + let diff = cpu.v.iter().zip(&dev.v).map(|(c, d)| (c[0] - d[0]).abs().max((c[1] - d[1]).abs())).fold(0.0f32, f32::max); + assert!(diff <= 1e-6, "the flow fields differ by {diff}"); + // The motion is real: the panning background's 5 px shift survives the completion. `b`'s + // content sits 5 px left of `a`'s (`background` adds `dx` inside the sample), so a → b is −5. + let m = dev.at(96.0, 64.0); + assert!((m[0] + 5.0).abs() < 0.5 && m[1].abs() < 0.5, "flow at centre: {m:?}"); +} + +/// The vote's σ² comes from a histogram that must count *every* target: the old per-thread +/// strided pass kept only the pixels whose bin selected that thread (a biased ~1/256 subsample), +/// which left small holes with a `total < 1` fallback and near-uniform vote weights. +#[test] +fn the_distance_histogram_counts_every_target() { + let Some(g) = gpu() else { return }; + let (w, h) = (48u32, 32u32); + // Every target has the same distance, so the histogram is one spike. 0.5 is the SDR range + // (bin 128); 1600 is above it, which is reachable from a 32-bpc frame (the reviewer's example: + // clamping it to bin 1023 reported σ² ≈ 4, and the vote's weights then rounded to zero). + for (d, want) in [(0.5f32, 128.5f32 / 256.0), (1600.0, 1600.0)] { + let (mut field, near) = (Image::new(w, h), vec![true; (w * h) as usize]); + for px in field.data.iter_mut() { + *px = [0.0, 0.0, d, 1.0]; + } + let (sigma2, total) = crate::inpaint::distance_histogram(&g.ctx, &field, &near, w, h).expect("histogram"); + assert_eq!(total as usize, (w * h) as usize, "distance {d}: the histogram kept {total} of {} samples", w * h); + let tol = (want * 1e-3).max(1e-3); + assert!((sigma2 - want).abs() <= tol, "distance {d}: sigma2 {sigma2}, want {want}"); + } +} + +/// HDR frames (channels above 1, as a 32-bpc image has) must still fill like the CPU. Their patch +/// distances are far past the SDR 0…4 range the histogram used to clamp at, so this covers the +/// rescaled binning end to end; `the_distance_histogram_counts_every_target` pins the estimator +/// itself (the old clamp read σ² ≈ 4 for a distance of 1600). +#[test] +fn hdr_frames_fill_like_the_cpu() { + let Some(g) = gpu() else { return }; + let (w, h) = (96u32, 72u32); + let mut gt = background(w, h, 0.0); + for px in &mut gt.data { + // A 32-bpc frame's values are unbounded: at 24× the patch distances run into the thousands. + // The old fixed 0…4 histogram reported σ² ≈ 4 for those, `exp(-d/(2σ²))` rounded to zero, + // and the vote kept the prior image instead of synthesising. + for c in 0..3 { + px[c] *= 24.0; + } + } + let hole = rect_hole(w, h, 40, 28, 16); + let src = cut(>, &hole); + let (cpu, gpu_pm) = (patch_fill(&src, &hole, None, 7, 1), g.patch(&src, &hole, None, 7, 1)); + let gpu_db = psnr(&gpu_pm, >, Some(&hole)); + assert!(gpu_db > 25.0, "GPU {gpu_db:.1} dB"); + assert!(psnr(&gpu_pm, &cpu, Some(&hole)) > 30.0, "the two syntheses disagree"); +} + +/// A frame too small for one block is the CPU's zero field, on the device path too. +#[test] +fn small_frames_get_a_zero_flow() { + let Some(g) = gpu() else { return }; + let (w, h) = (6u32, 6u32); + let a = background(w, h, 0.0); + let b = background(w, h, 2.0); + let none = vec![false; (w * h) as usize]; + let f = g.flow(&a, &b, &none, &none); + assert_eq!((f.cols, f.rows), (1, 1)); + assert_eq!(f.v, vec![[0.0, 0.0]]); +} + +/// The flow the sequence driver uses (through the `Gpu` backend) keeps the fill stable: the same +/// frames filled with the CPU backend and the device backend agree, so the propagation sees the +/// same motion. +#[test] +fn sequence_fill_with_the_device_flow_matches_the_cpu() { + let Some(g) = gpu() else { return }; + let (w, h) = (96u32, 64u32); + let cpu = effectcraft_raster::inpaint::CpuFill; + for pan in [0.0f32, 2.0] { + let mut frames = vec![]; + let mut holes = vec![]; + for t in 0..6u32 { + let gt = background(w, h, t as f32 * pan); + let hole = rect_hole(w, h, 12 + 6 * t, 22, 18); + frames.push(cut(>, &hole)); + holes.push(hole); + } + let input = FillInput { frames, holes, references: vec![] }; + let dev = fill_sequence_with(&input, &FillOpts::default(), g, &mut |_, _| true).expect("gpu fill"); + let ref_cpu = fill_sequence_with(&input, &FillOpts::default(), &cpu, &mut |_, _| true).expect("cpu fill"); + for t in 0..dev.len() { + let db = psnr(&dev[t], &ref_cpu[t], Some(&input.holes[t])); + assert!(db > 40.0, "frame {t} pan {pan}: device vs CPU {db:.1} dB"); + } + } +} diff --git a/crates/raster/src/flow.rs b/crates/raster/src/flow.rs index feb033d65..180b235bf 100644 --- a/crates/raster/src/flow.rs +++ b/crates/raster/src/flow.rs @@ -51,6 +51,21 @@ impl Flow { } } +/// The `Plane::luma` of `a` and `b` packed into one image (`a` in the red channel, `b` in the +/// green; the rest zero). A device block matcher takes this as its input rather than computing +/// the luma itself: the weighted sum is not reproducible on a device, which may contract +/// `a·b + c` into a fused multiply-add, and the search's near-tie choices would then differ. +pub fn luma_pair(a: &Image, b: &Image) -> Image { + let luma = |p: &[f32; 4]| 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2] + 0.25 * p[3]; + let mut out = Image::new(a.width, a.height); + out.data.par_iter_mut().enumerate().for_each(|(i, q)| { + let la = a.data.get(i).map(luma).unwrap_or(0.0); + let lb = b.data.get(i).map(luma).unwrap_or(0.0); + *q = [la, lb, 0.0, 0.0]; + }); + out +} + /// A single-channel plane. struct Plane { w: usize, diff --git a/crates/raster/src/inpaint.rs b/crates/raster/src/inpaint.rs index c64de3375..592ecd221 100644 --- a/crates/raster/src/inpaint.rs +++ b/crates/raster/src/inpaint.rs @@ -562,23 +562,31 @@ pub fn patch_fill(img: &Image, hole: &[bool], init: Option<&Image>, patch: usize // ------------------------------------------------------------------ flow-guided propagation -/// Block flow from `a` to `b` with the tiles that touch either hole replaced by the membrane -/// interpolation of the surrounding motion (flow completion). +/// Block flow `a` → `b` (tile 8, search radius 6) with the tiles that touch either hole replaced +/// by the membrane interpolation of the surrounding motion (flow completion). fn completed_flow(a: &Image, b: &Image, hole_a: &[bool], hole_b: &[bool]) -> Flow { - let mut f = block_flow(a, b, 8, 6); + let f = block_flow(a, b, 8, 6); + complete_flow(f, hole_a, hole_b, a.width as usize, a.height as usize) +} + +/// The completion half of `completed_flow`: the tiles of `f` (a `block_flow` of `w × h`) that +/// touch either hole become the harmonic interpolation of the surrounding motion. Split out so a +/// hardware backend can compute the block matching itself and hand the raw field here; the +/// completion is cheap (the field is a `cols × rows` grid) and stays on the CPU. +pub fn complete_flow(mut f: Flow, hole_a: &[bool], hole_b: &[bool], w: usize, h: usize) -> Flow { if f.v.is_empty() { return f; } - let (w, bs) = (a.width as usize, f.block as usize); + let bs = f.block as usize; let mut known = vec![true; f.v.len()]; for (i, k) in known.iter_mut().enumerate() { let (c, r) = (i % f.cols, i / f.cols); // A tile is unreliable when any pixel within it (and a block's margin) is a hole. let (x0, y0) = ((c * bs).saturating_sub(bs / 2), (r * bs).saturating_sub(bs / 2)); - let (x1, y1) = (((c + 1) * bs + bs / 2).min(w), ((r + 1) * bs + bs / 2).min(a.height as usize)); + let (x1, y1) = (((c + 1) * bs + bs / 2).min(w), ((r + 1) * bs + bs / 2).min(h)); 'tile: for y in y0..y1 { for x in x0..x1 { - if hole_a[y * w + x] || hole_b[y * w + x] { + if hole_a.get(y * w + x).copied().unwrap_or(true) || hole_b.get(y * w + x).copied().unwrap_or(true) { *k = false; break 'tile; } @@ -660,8 +668,54 @@ fn trace(context: &TraceContext<'_>, start: TraceStart) -> Option<([f32; 4], usi None } -/// Fill a range of frames. `progress(done, total)` returns `false` to cancel (→ `None`). +/// A hardware backend for the fill's stages (the GPU compositor implements it; see +/// `effectcraft_render::Accelerator::inpaint`). Every method has the CPU implementation as its +/// default, so a backend that cannot take a stage returns the CPU's work and the fill stays +/// correct — only slower. +/// +/// The orchestration ([`fill_sequence_with`]) stays on the CPU: the sequence's frame-to-frame +/// coupling is sequential, and only the per-frame and per-pair work is worth offloading. +/// +/// **A backend must be safe to call concurrently.** [`fill_sequence_with`] runs the stages of +/// independent frames and frame pairs in parallel (`rayon`), so `membrane`, `flow` and `patch` +/// may all be entered from several threads at once — a backend that owns a device must serialise +/// its use of it (the GPU compositor's per-call encoder does; a backend whose device cannot be +/// used from several threads must lock). +pub trait FillBackend: Send + Sync { + /// [`membrane_fill`]: the `hole` pixels take the harmonic interpolation of their border. + fn membrane(&self, img: &Image, hole: &[bool]) -> Image { + membrane_fill(img, hole) + } + /// `membrane_core`: harmonic interpolation of `data`'s unknown pixels in place (`known` is + /// true where the pixel is known). + fn membrane_known(&self, data: &mut [[f32; 4]], known: &[bool], w: usize, h: usize) { + membrane_core(data, known, w, h) + } + /// `completed_flow`: block flow `a` → `b` with the tiles touching either hole replaced by + /// the membrane interpolation of the surrounding motion. + fn flow(&self, a: &Image, b: &Image, hole_a: &[bool], hole_b: &[bool]) -> Flow { + completed_flow(a, b, hole_a, hole_b) + } + /// [`patch_fill`]: PatchMatch synthesis of `img`'s `hole` (`init`: a starting guess, e.g. the + /// previous frame's fill warped here). + fn patch(&self, img: &Image, hole: &[bool], init: Option<&Image>, patch: usize, seed: u32) -> Image { + patch_fill(img, hole, init, patch, seed) + } +} + +/// The CPU backend: every stage is the reference implementation. +pub struct CpuFill; + +impl FillBackend for CpuFill {} + +/// Fill a range of frames on the CPU. `progress(done, total)` returns `false` to cancel +/// (→ `None`). pub fn fill_sequence(input: &FillInput, o: &FillOpts, progress: &mut dyn FnMut(usize, usize) -> bool) -> Option> { + fill_sequence_with(input, o, &CpuFill, progress) +} + +/// [`fill_sequence`] with a hardware backend for the stages it can take (see [`FillBackend`]). +pub fn fill_sequence_with(input: &FillInput, o: &FillOpts, backend: &dyn FillBackend, progress: &mut dyn FnMut(usize, usize) -> bool) -> Option> { let n = input.frames.len(); if n == 0 { return Some(vec![]); @@ -672,7 +726,7 @@ pub fn fill_sequence(input: &FillInput, o: &FillOpts, progress: &mut dyn FnMut(u if o.method == FillMethod::EdgeBlend { let mut out = Vec::with_capacity(n); for (i, (f, hl)) in input.frames.iter().zip(&input.holes).enumerate() { - out.push(opaque_fill(membrane_fill(f, hl), hl)); + out.push(opaque_fill(backend.membrane(f, hl), hl)); if !progress(i + 1, n) { return None; } @@ -691,10 +745,10 @@ pub fn fill_sequence(input: &FillInput, o: &FillOpts, progress: &mut dyn FnMut(u } } // 1. Flows between membrane-filled neighbours, completed inside the holes. - let pre: Vec = frames.par_iter().zip(&holes).map(|(f, hl)| membrane_fill(f, hl)).collect(); + let pre: Vec = frames.par_iter().zip(&holes).map(|(f, hl)| backend.membrane(f, hl)).collect(); let pairs: Vec<(Flow, Flow)> = (0..n.saturating_sub(1)) .into_par_iter() - .map(|i| (completed_flow(&pre[i], &pre[i + 1], &holes[i], &holes[i + 1]), completed_flow(&pre[i + 1], &pre[i], &holes[i + 1], &holes[i]))) + .map(|i| (backend.flow(&pre[i], &pre[i + 1], &holes[i], &holes[i + 1]), backend.flow(&pre[i + 1], &pre[i], &holes[i + 1], &holes[i]))) .collect(); let (fwd, bwd): (Vec, Vec) = pairs.into_iter().unzip(); if !progress(1, total) { @@ -746,7 +800,7 @@ pub fn fill_sequence(input: &FillInput, o: &FillOpts, progress: &mut dyn FnMut(u diff[i] = std::array::from_fn(|c| if c < 3 { a[c] - v[c] } else { 0.0 }); } } - membrane_core(&mut diff, &dk, w, h); + backend.membrane_known(&mut diff, &dk, w, h); for (i, _) in &filled { for c in 0..3 { img.data[*i][c] += o.lighting * diff[*i][c]; @@ -770,7 +824,7 @@ pub fn fill_sequence(input: &FillInput, o: &FillOpts, progress: &mut dyn FnMut(u *slot = Some(propagated[t].0.clone()); } let mut done = 2; - out[t0] = Some(patch_fill(&propagated[t0].0, &propagated[t0].1, None, o.patch, o.seed)); + out[t0] = Some(backend.patch(&propagated[t0].0, &propagated[t0].1, None, o.patch, o.seed)); done += 1; if !progress(done, total) { return None; @@ -783,7 +837,7 @@ pub fn fill_sequence(input: &FillInput, o: &FillOpts, progress: &mut dyn FnMut(u // Warp the previous result here along the backward flow (t → t − 1). // Frames after `t0` are filled in order, so the previous one is set. let Some(prev) = out[t - 1].as_ref() else { - out[t] = Some(patch_fill(img, left, None, o.patch, o.seed)); + out[t] = Some(backend.patch(img, left, None, o.patch, o.seed)); continue; }; let mut warped = img.clone(); @@ -796,7 +850,7 @@ pub fn fill_sequence(input: &FillInput, o: &FillOpts, progress: &mut dyn FnMut(u } out[t] = Some(match o.method { FillMethod::Surface => warped, - _ => patch_fill(img, left, Some(&warped), o.patch, o.seed), + _ => backend.patch(img, left, Some(&warped), o.patch, o.seed), }); } done += 1; diff --git a/crates/render/src/accel.rs b/crates/render/src/accel.rs index 0d5fe1b6b..fe5a8f3fe 100644 --- a/crates/render/src/accel.rs +++ b/crates/render/src/accel.rs @@ -76,6 +76,16 @@ pub trait Accelerator: Send + Sync { fn particles(&self) -> Option<&dyn effectcraft_effects::psim::ParticleSim> { None } + /// A hardware backend for Content-Aware Fill's stages (the GPU compositor's kernels), with + /// the CPU fill's semantics. `None` = the fill runs on the CPU. + /// + /// The fill's input frames are read back to the CPU before it runs (analyses render on the + /// CPU by design, see `effectcraft_engine::background::offload::job_accel`), so a backend + /// here is about the fill's own work — PatchMatch and the flow's block matching today, one + /// stage at a time as each pays (`effectcraft_gpu::inpaint`) — not about compositing. + fn inpaint(&self) -> Option<&dyn effectcraft_raster::inpaint::FillBackend> { + None + } /// Timing history [`Backend::Auto`] uses to send each comp's top-level frames to the /// faster compositor. `None` = Auto always tries the accelerator first. fn auto_pick(&self) -> Option<&AutoPick> { diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index c25da4928..998fe4453 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (closed issue references re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-29 · **Last updated:** 2026-10-29 · **Change:** minor (Content-Aware Fill's PatchMatch and flow search on the GPU) · **Target:** Adobe After Effects 2026 Each piece of hardware After Effects uses, per platform, against EffectCraft. Ours comes from the code at `origin/main` 1446e27 (`crates/gpu`, `crates/render`, `crates/ui-egui`) and the issue @@ -14,6 +14,7 @@ monitors are missing, and two GPU start-up failures are open. | Feature | After Effects 2026 | EffectCraft macOS | Windows | Linux | Web | Notes / evidence | |---|---|---|---|---|---|---| | GPU compositing and effects | Mercury GPU (Metal; CUDA / OpenCL / DirectX on Windows) | yes, Metal via wgpu | Vulkan yes; **Direct3D 12 fails** (FXC can't compile the `pointwise` kernel, #613) | Vulkan | WebGPU, in frame workers | 280 of 306 effects on the GPU, checked against the CPU renderer; Fractal and the analysis/tool effects stay on the CPU ([effects-parity.md](effects-parity.md)) | +| GPU analysis | GPU-accelerated Content-Aware Fill | Content-Aware Fill's PatchMatch **and** flow block matching on the GPU (`Gpu::inpaint`); every other stage on the CPU | same | same | no (needs synchronous readback) | On an Intel Arc iGPU a four-frame 1920 × 1080 fill is ≈ 1.4× faster than the CPU's (PatchMatch is the dominant win, the flow search a smaller one), and even a 16 × 16 hole at 1920 × 1080 is ≈ 1.9× faster — the CPU's own stages are O(frame), not O(hole). The device's flow field matches `block_flow` to a float rounding. The membrane and the flow's hole completion stay on the CPU: both are bandwidth-bound, and the device's full-frame transfers cost more than they save — see `effectcraft_gpu::inpaint` | | CPU fallback and device-loss recovery | yes | yes | yes; Windows ARM64 Adreno panic after device loss (#591, closed) | yes | yes (#225 fixed) | Out of video memory falls back to the CPU (#106) | | GPU detection and choice | GPU info, renderer choice | Backend Auto per comp | "GPU isn't being detected" (#596, closed); Intel HD 5500 fix unverified (#198) | yes | yes | No settings page listing adapters yet | | Multi-core rendering | Multi-Frame Rendering | rayon pools; preview scheduler sized to the render pool | same | same | Web Workers, one engine per worker | No benchmark against Multi-Frame Rendering | @@ -39,5 +40,6 @@ the Adreno and Intel cases. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Content-Aware Fill's PatchMatch and flow block matching run on the GPU (`FillBackend`/`Accelerator::inpaint`, `effectcraft_gpu::inpaint`) | | 2026-10-12 | minor | #591, #596 and #554 marked closed; #498 answer and open PR #819 | | 2026-10-10 | major | First hardware parity document | From c4f386a22fce7a5d4eb0ca9eafdd1781f70962e8 Mon Sep 17 00:00:00 2001 From: howdeploy <118049707+howdeploy@users.noreply.github.com> Date: Wed, 4 Nov 2026 01:00:01 +0300 Subject: [PATCH 69/71] Preview: debounce GPU memory pressure and recover the frame budget (#3025) Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- crates/ui-egui/src/frames.rs | 62 +++++++++++++++++++++++++----- crates/ui-egui/src/frames/tests.rs | 20 +++++++++- 2 files changed, 72 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 9aaaedbc7..6a98abfe6 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -39,10 +39,13 @@ use rayon::prelude::*; const MAX_KEEPERS: usize = 64; /// GPU frames kept for RAM preview (bytes of video memory) at first. wgpu can't tell how much -/// video memory there is: each time the device runs out, the budget halves (to -/// [`MIN_GPU_BUDGET`]) and the frame renders on the CPU (#106). +/// video memory there is: allocation pressure halves the budget (to [`MIN_GPU_BUDGET`]) +/// and the frame renders on the CPU (#106). One burst is counted once; successful GPU +/// renders gradually restore the budget after pressure subsides. const GPU_BUDGET: usize = 1 << 30; const MIN_GPU_BUDGET: usize = 64 << 20; +const GPU_PRESSURE_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(5); +const GPU_RECOVERY_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30); /// A rendered viewer frame: CPU pixels or a GPU texture. #[derive(Clone)] @@ -151,6 +154,8 @@ struct Cache { budget: usize, /// Bytes of GPU frames to keep at most ([`GPU_BUDGET`]). gpu_budget: usize, + gpu_pressure: Option, + gpu_adjusted: Option, /// The playing preview, if any: evictions spare the frames it shows next. playhead: Option, /// Scale of the frame on screen (the newest urgent request): frames of other scales go first. @@ -207,11 +212,28 @@ impl Cache { } } } - /// The GPU ran out of memory: keep half as many GPU frames from now on. + /// Several workers failing in one burst must not halve the shared budget repeatedly. fn gpu_out_of_memory(&mut self) { - self.gpu_budget = (self.gpu_budget / 2).max(MIN_GPU_BUDGET); + let now = web_time::Instant::now(); + if self.gpu_pressure.is_none_or(|last| now.duration_since(last) >= GPU_PRESSURE_COOLDOWN) { + self.gpu_budget = (self.gpu_budget / 2).max(MIN_GPU_BUDGET); + self.gpu_adjusted = Some(now); + } + self.gpu_pressure = Some(now); self.evict_gpu(); } + fn gpu_succeeded(&mut self) { + let now = web_time::Instant::now(); + if self.gpu_pressure.is_some_and(|last| now.duration_since(last) >= GPU_RECOVERY_INTERVAL) + && self.gpu_adjusted.is_some_and(|last| now.duration_since(last) >= GPU_RECOVERY_INTERVAL) + { + self.gpu_budget = self.gpu_budget.saturating_add(MIN_GPU_BUDGET).min(GPU_BUDGET); + self.gpu_adjusted = Some(now); + } + } + fn gpu_ready(&self) -> bool { + self.gpu_pressure.is_none_or(|last| last.elapsed() >= GPU_PRESSURE_COOLDOWN) + } fn forget(&mut self, img: &FrameImage) { self.bytes -= img.bytes(); if img.is_gpu() { @@ -505,7 +527,7 @@ impl Default for Frames { #[cfg(target_arch = "wasm32")] let queue = Queue::default(); Frames { - cache: Arc::new(Mutex::new(Cache { map: HashMap::new(), order: VecDeque::new(), bytes: 0, gpu_bytes: 0, budget: 3 << 30, gpu_budget: GPU_BUDGET, playhead: None, scale: None })), + cache: Arc::new(Mutex::new(Cache { map: HashMap::new(), order: VecDeque::new(), bytes: 0, gpu_bytes: 0, budget: 3 << 30, gpu_budget: GPU_BUDGET, gpu_pressure: None, gpu_adjusted: None, playhead: None, scale: None })), inflight: Arc::new(Mutex::new(HashSet::new())), queue: Arc::new(Mutex::new(queue)), #[cfg(not(target_arch = "wasm32"))] @@ -914,6 +936,9 @@ impl Frames { c.order.clear(); c.bytes = 0; c.gpu_bytes = 0; + c.gpu_budget = GPU_BUDGET; + c.gpu_pressure = None; + c.gpu_adjusted = None; } // Disk keys hash the footage files' size and time, but are remembered per revision; a // file reloaded from disk without a new revision (a re-rendered image sequence) must be @@ -1411,9 +1436,19 @@ impl Worker { } } + fn gpu_succeeded(&self) { + if let Ok(mut c) = self.cache.lock() { + c.gpu_succeeded(); + } + } + + fn gpu_ready(&self) -> bool { + !self.gpu_retired.load(Ordering::Acquire) && self.cache.lock().is_ok_and(|c| c.gpu_ready()) + } + /// The frame on the GPU, kept there for the viewer, if the compositor can do it alone. fn render_gpu(&self, job: &Job) -> Option { - if self.gpu_retired.load(Ordering::Acquire) { + if !self.gpu_ready() { return None; } let g = job.src.gpu.as_ref()?; @@ -1426,7 +1461,12 @@ impl Worker { let f = g.within_memory(|| g.render_display(&r, job.comp, job.t)); effectcraft_engine::render::Accelerator::frame_end(g); match f { - Ok(f) => f.map(|f| FrameImage::Gpu(Arc::new(f))), + Ok(f) => { + if f.is_some() { + self.gpu_succeeded(); + } + f.map(|f| FrameImage::Gpu(Arc::new(f))) + } Err(e) => { self.gpu_attempt_failed(g, &e); None @@ -1439,7 +1479,7 @@ impl Worker { let footage = job.footage(); let mut r = Renderer::new(&job.src.project, &footage, job.opts); r.expr = job.src.expr.as_deref(); - let gpu_allowed = gpu && !self.gpu_retired.load(Ordering::Acquire); + let gpu_allowed = gpu && self.gpu_ready(); // A failed GPU attempt may have populated intermediate entries before its scope // reported failure. The CPU retry must compute its own pixels from the snapshot. r.cache = gpu_allowed.then_some(job.src.layer_cache.as_ref()); @@ -1459,6 +1499,7 @@ impl Worker { let t0 = web_time::Instant::now(); match g.within_memory(|| g.render_display(&r, job.comp, job.t)) { Ok(Some(f)) => { + self.gpu_succeeded(); if let Some((p, key)) = auto { // Time the GPU's work, not just its recording. g.wait(); @@ -1490,7 +1531,10 @@ impl Worker { if let Some(g) = gpu { // The GPU composites and the frame is read back. return match g.within_memory(|| r.comp_frame(job.comp, job.t)) { - Ok(img) => FrameImage::Cpu(Arc::new(to_color_image(&img))), + Ok(img) => { + self.gpu_succeeded(); + FrameImage::Cpu(Arc::new(to_color_image(&img))) + } Err(e) => { self.gpu_attempt_failed(g, &e); self.render_job(job, false) diff --git a/crates/ui-egui/src/frames/tests.rs b/crates/ui-egui/src/frames/tests.rs index 692df16f7..ace51b0bb 100644 --- a/crates/ui-egui/src/frames/tests.rs +++ b/crates/ui-egui/src/frames/tests.rs @@ -469,18 +469,36 @@ fn a_newer_viewer_frame_drops_the_ones_still_waiting() { assert_eq!(f.inflight(), 3, "dropped frames aren't in flight: the viewer can ask again"); } -/// Each time the GPU runs out of memory, half as many GPU frames are kept, down to a floor. +/// One OOM burst halves once; separate episodes back off to a floor and healthy GPU work recovers. #[test] fn running_out_of_video_memory_halves_the_gpu_frame_budget() { let f = Frames::default(); let mut c = f.cache.lock().unwrap(); assert_eq!(c.gpu_budget, GPU_BUDGET); + assert!(c.gpu_ready()); c.gpu_out_of_memory(); + assert!(!c.gpu_ready(), "allocation failures must not be retried for every frame"); assert_eq!(c.gpu_budget, GPU_BUDGET / 2); for _ in 0..20 { c.gpu_out_of_memory(); } + assert_eq!(c.gpu_budget, GPU_BUDGET / 2, "one burst must not collapse the shared budget"); + for _ in 0..20 { + c.gpu_pressure = None; + c.gpu_out_of_memory(); + } assert_eq!(c.gpu_budget, MIN_GPU_BUDGET); + let before = web_time::Instant::now() - GPU_RECOVERY_INTERVAL; + c.gpu_pressure = Some(before); + assert!(c.gpu_ready(), "GPU attempts resume after the cooldown"); + c.gpu_adjusted = Some(before); + c.gpu_succeeded(); + assert_eq!(c.gpu_budget, 2 * MIN_GPU_BUDGET); + c.gpu_succeeded(); + assert_eq!(c.gpu_budget, 2 * MIN_GPU_BUDGET, "recovery is paced"); + drop(c); + f.clear(); + assert_eq!(f.cache.lock().unwrap().gpu_budget, GPU_BUDGET); } #[test] From 8d20cf0c3a1fd8f81b394055719f457720c35401 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Tue, 3 Nov 2026 23:19:29 +0100 Subject: [PATCH 70/71] GPU.1: Preserve CPU parity in numerical effect kernels (#1490) Co-authored-by: @skylartaylor <26588250+skylartaylor@users.noreply.github.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- Cargo.lock | 1 + crates/effects/src/lib.rs | 4 +- crates/effects/src/stylize3.rs | 66 ++++++++---- crates/effects/src/transition.rs | 117 ++++++++++++++-------- crates/gpu/Cargo.toml | 1 + crates/gpu/src/context.rs | 4 + crates/gpu/src/fx_pixel2.rs | 30 ++++-- crates/gpu/src/fx_tone.rs | 62 +++++++++++- crates/gpu/src/fx_transition.rs | 37 ++++--- crates/gpu/src/shaders/common.wgsl | 85 ++++++++++++++++ crates/gpu/src/shaders/fx_pixel2.wgsl | 49 +-------- crates/gpu/src/shaders/fx_sim.wgsl | 18 ++-- crates/gpu/src/shaders/fx_stylize.wgsl | 36 ++++++- crates/gpu/src/shaders/fx_tone.wgsl | 25 +++-- crates/gpu/src/shaders/fx_transition.wgsl | 24 +---- crates/gpu/src/shaders/fx_vr.wgsl | 10 +- crates/gpu/src/shaders/particles.wgsl | 8 +- crates/gpu/src/tests.rs | 97 ++++++++++++++++++ crates/gpu/src/tests/fx_stylize.rs | 60 +++++++++++ crates/host/tests/it/plugins.rs | 20 ++-- crates/ui-egui/src/theme.rs | 10 +- 21 files changed, 558 insertions(+), 206 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 288cce1ad..232fa6b18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1612,6 +1612,7 @@ dependencies = [ "js-sys", "log", "pollster 1.0.1", + "rayon", "wasm-bindgen", "web-time", "wgpu", diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index c865d4f96..9130b061c 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -104,12 +104,12 @@ pub use generate3::{ }; pub use noise::GrainLook; pub use textfx::{BoltPlan, TextLook, TextPass, bolt_plan, text_passes}; -pub use transition::place_layer; +pub use transition::{GridWipe, place_layer}; // effectcraft-gpu fx_noise. pub use keying2::{InnerOuterPlan, inner_outer_plan}; pub use noise::remove_grain_level; pub use noise3::{FractalGpu, NOISE_CELL, fractal_gpu}; -pub use stylize3::{BumpLight, bump_layer_id}; +pub use stylize3::{BumpLight, HexTile, bump_layer_id}; pub use textfx::{TextLayer, text_layer}; pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time, time_frames, timewarp_crop, timewarp_plan}; // effectcraft-gpu fx_tone. diff --git a/crates/effects/src/stylize3.rs b/crates/effects/src/stylize3.rs index e28f9a4fa..e8a141cf4 100644 --- a/crates/effects/src/stylize3.rs +++ b/crates/effects/src/stylize3.rs @@ -364,32 +364,54 @@ fn hex_cell(x: f64, y: f64, r: f64) -> ((f64, f64), (i64, i64)) { ((px, py), (rx as i64, rz as i64)) } +/// Stable f64 hex-cell selection and source geometry for CPU and GPU renders. +pub struct HexTile { + r: f64, + c: (f64, f64), + sn: f64, + cs: f64, + smear: f64, + mode: u32, +} +impl HexTile { + /// No image pixels are read; only buffer geometry and parameters are used. + pub fn new(ctx: &EffectCtx, b: &Buf) -> Self { + let r = (ctx.params.f("radius") * b.scale).max(0.5); + let c = b.to_px(ctx.params.v2("center")); + let rot = ctx.params.f("rotate").to_radians(); + let (sn, cs) = rot.sin_cos(); + let smear = (ctx.params.f("smearing") / 100.0).clamp(0.0, 1.0); + let mode = ctx.params.e("render"); + Self { r, c, sn, cs, smear, mode } + } + /// Bilinear source position at a buffer pixel centre. + pub fn source_at(&self, x: u32, y: u32) -> (f64, f64) { + let Self { r, c: (cx, cy), sn, cs, smear, mode } = *self; + let (dx, dy) = (x as f64 + 0.5 - cx, y as f64 + 0.5 - cy); + // Into the rotated tiling frame. + let (u, v) = (dx * cs + dy * sn, -dx * sn + dy * cs); + let ((hx, hy), (qi, ri)) = hex_cell(u, v, r); + let (mut lu, lv) = (u - hx, v - hy); + let odd = (qi + ri).rem_euclid(2) == 1; + match mode { + 1 if odd => lu = -lu, + 2 if odd => lu = -lu, + _ => {} + } + let lv = if mode == 2 && odd { -lv } else { lv }; + // Every tile shows the hexagon around the centre point. + let (ox, oy) = (lu * cs - lv * sn, lu * sn + lv * cs); + let (tx, ty) = (cx + ox, cy + oy); + let (sx, sy) = (tx + (x as f64 + 0.5 - tx) * smear, ty + (y as f64 + 0.5 - ty) * smear); + (sx, sy) + } +} fn hextile(ctx: &EffectCtx, mut b: Buf) -> Buf { - let r = (ctx.params.f("radius") * b.scale).max(0.5); - let (cx, cy) = b.to_px(ctx.params.v2("center")); - let rot = ctx.params.f("rotate").to_radians(); - let (sn, cs) = rot.sin_cos(); - let smear = (ctx.params.f("smearing") / 100.0).clamp(0.0, 1.0); - let mode = ctx.params.e("render"); + let geometry = HexTile::new(ctx, &b); let src = b.img.clone(); b.img.rows_mut().for_each(|(y, row)| { for (x, px) in row.iter_mut().enumerate() { - let (dx, dy) = (x as f64 + 0.5 - cx, y as f64 + 0.5 - cy); - // Into the rotated tiling frame. - let (u, v) = (dx * cs + dy * sn, -dx * sn + dy * cs); - let ((hx, hy), (qi, ri)) = hex_cell(u, v, r); - let (mut lu, lv) = (u - hx, v - hy); - let odd = (qi + ri).rem_euclid(2) == 1; - match mode { - 1 if odd => lu = -lu, - 2 if odd => lu = -lu, - _ => {} - } - let lv = if mode == 2 && odd { -lv } else { lv }; - // Every tile shows the hexagon around the centre point. - let (ox, oy) = (lu * cs - lv * sn, lu * sn + lv * cs); - let (tx, ty) = (cx + ox, cy + oy); - let (sx, sy) = (tx + (x as f64 + 0.5 - tx) * smear, ty + (y as f64 + 0.5 - ty) * smear); + let (sx, sy) = geometry.source_at(x as u32, y as u32); *px = src.sample_bilinear_clamped(sx, sy); } }); diff --git a/crates/effects/src/transition.rs b/crates/effects/src/transition.rs index 23e54bb6b..e465ffd1b 100644 --- a/crates/effects/src/transition.rs +++ b/crates/effects/src/transition.rs @@ -314,53 +314,86 @@ pub(crate) fn card_wipe_plan(ctx: &EffectCtx, b: &Buf) -> Option { Some(PiecePlan { pieces, front: PieceTex::Layer, back }) } +/// Double-precision grid geometry, shared with the GPU mask upload. A tiny +/// rounding error at a tile boundary can otherwise flip a fully opaque pixel. +pub struct GridWipe { + c: (f64, f64), + s: f64, + co: f64, + cs: f64, + maxd: f64, + shape: u32, + reverse: bool, + done: f64, + border: f64, +} + +impl GridWipe { + /// Construct from buffer geometry; no pixel data is accessed. + pub fn new(ctx: &EffectCtx, b: &Buf) -> Self { + let done = completion(ctx); + let c = b.to_px(ctx.params.v2("center")); + let rot = ctx.params.f("rotation").to_radians(); + let border = ctx.params.f("border").max(0.0) * b.scale; + let tiles = ctx.params.f("tiles").round().clamp(1.0, 500.0); + let shape = ctx.params.e("shape"); + let reverse = ctx.params.b("reverse"); + let (_, _, w, _) = layer_rect(ctx, b); + let cs = (w / tiles).max(1.0); + let (s, co) = rot.sin_cos(); + let metric = |dx: f64, dy: f64| match shape { + 0 => dx.abs(), + 2 => dx.abs().max(dy.abs()), + _ => dx.hypot(dy), + }; + let maxd = max_corner_dist(b, c, |dx, dy| { + let (qx, qy) = (dx * co + dy * s, -dx * s + dy * co); + metric(qx, qy) + }); + GridWipe { c, s, co, cs, maxd, shape, reverse, done, border } + } + + /// Alpha multiplier at a buffer pixel centre. + pub fn coverage(&self, x: u32, y: u32) -> f32 { + let Self { c, s, co, cs, maxd, shape, reverse, done, border } = *self; + let metric = |dx: f64, dy: f64| match shape { + 0 => dx.abs(), + 2 => dx.abs().max(dy.abs()), + _ => dx.hypot(dy), + }; + let spread = 0.25; + let (dx, dy) = (x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1); + let (qx, qy) = (dx * co + dy * s, -dx * s + dy * co); + let (cx, cy) = (((qx / cs).floor() + 0.5) * cs, ((qy / cs).floor() + 0.5) * cs); + let mut o = (metric(cx, cy) / maxd).clamp(0.0, 1.0); + if reverse { + o = 1.0 - o; + } + let p = ((done * (1.0 + spread) - o) / spread).clamp(0.0, 1.0); + if p <= 0.0 { + return 1.0; + } + let half = cs * 0.5 * (1.0 - p); + let m = (qx - cx).abs().max((qy - cy).abs()); + let k = if border > 0.0 { + ((half - m) / border + 0.5).clamp(0.0, 1.0) + } else if m < half { + 1.0 + } else { + 0.0 + }; + k as f32 + } +} + fn grid_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { - let done = completion(ctx); - if done <= 0.0 { + if completion(ctx) <= 0.0 { return b; } - let c = b.to_px(ctx.params.v2("center")); - let rot = ctx.params.f("rotation").to_radians(); - let border = ctx.params.f("border").max(0.0) * b.scale; - let tiles = ctx.params.f("tiles").round().clamp(1.0, 500.0); - let shape = ctx.params.e("shape"); - let reverse = ctx.params.b("reverse"); - let (_, _, w, _) = layer_rect(ctx, &b); - let cs = (w / tiles).max(1.0); - let (s, co) = rot.sin_cos(); - let metric = |dx: f64, dy: f64| match shape { - 0 => dx.abs(), - 2 => dx.abs().max(dy.abs()), - _ => dx.hypot(dy), - }; - let maxd = max_corner_dist(&b, c, |dx, dy| { - let (qx, qy) = (dx * co + dy * s, -dx * s + dy * co); - metric(qx, qy) - }); - let spread = 0.25; + let grid = GridWipe::new(ctx, &b); b.img.rows_mut().for_each(|(y, row)| { for (x, px) in row.iter_mut().enumerate() { - let (dx, dy) = (x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1); - let (qx, qy) = (dx * co + dy * s, -dx * s + dy * co); - let (cx, cy) = (((qx / cs).floor() + 0.5) * cs, ((qy / cs).floor() + 0.5) * cs); - let mut o = (metric(cx, cy) / maxd).clamp(0.0, 1.0); - if reverse { - o = 1.0 - o; - } - let p = ((done * (1.0 + spread) - o) / spread).clamp(0.0, 1.0); - if p <= 0.0 { - continue; - } - let half = cs * 0.5 * (1.0 - p); - let m = (qx - cx).abs().max((qy - cy).abs()); - let k = if border > 0.0 { - ((half - m) / border + 0.5).clamp(0.0, 1.0) - } else if m < half { - 1.0 - } else { - 0.0 - }; - scale_px(px, k as f32); + scale_px(px, grid.coverage(x as u32, y as u32)); } }); b diff --git a/crates/gpu/Cargo.toml b/crates/gpu/Cargo.toml index 49fe51c16..01154dd9f 100644 --- a/crates/gpu/Cargo.toml +++ b/crates/gpu/Cargo.toml @@ -21,6 +21,7 @@ effectcraft-keyframe = { workspace = true } wgpu = { workspace = true } bytemuck = { workspace = true } log = { workspace = true } +rayon = { workspace = true } web-time = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 713f6f83d..cc861c85b 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -807,6 +807,8 @@ impl GpuContext { include_str!("shaders/sky.wgsl"), ] .concat(); + #[cfg(test)] + let src = src + crate::tests::MATH_PROBE_SHADER; let module = init_resource(&device, "kernels shader module", || { device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("effectcraft kernels"), source: wgpu::ShaderSource::Wgsl(src.into()) }) })?; @@ -890,6 +892,8 @@ impl GpuContext { } } } + #[cfg(test)] + pipelines.insert("math_probe", build("math_probe", &layout)?); for e in EFFECT_ENTRIES .iter() .chain(crate::fx_color::KERNELS) diff --git a/crates/gpu/src/fx_pixel2.rs b/crates/gpu/src/fx_pixel2.rs index acba936d0..a62cbaff0 100644 --- a/crates/gpu/src/fx_pixel2.rs +++ b/crates/gpu/src/fx_pixel2.rs @@ -12,9 +12,11 @@ //! Layer is chosen; the colouring runs here. use effectcraft_color::BlendMode; -use effectcraft_effects::EffectCtx; +use effectcraft_effects::{Buf, EffectCtx}; use effectcraft_raster::Image; +use rayon::prelude::*; + use crate::context::{Enc, GpuImage, Params}; use crate::effects::{GBuf, box_passes, gaussian_blur}; use crate::ops::mode_id; @@ -380,14 +382,24 @@ fn clamp_plane(e: &mut Enc, pl: &GpuImage, lo: f32, hi: f32) -> GpuImage { // ---------------------------------------------------------------- CC HexTile / CC Mr. Smoothie (stylize3) fn hextile(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { - let r = (ctx.params.f("radius") * b.scale).max(0.5); - let c = b.to_px(ctx.params.v2("center")); - let (sn, cs) = ctx.params.f("rotate").to_radians().sin_cos(); - let mut p = Params::default(); - p.u[0][0] = ctx.params.e("render"); - p.f[0] = [c.0 as f32, c.1 as f32, r as f32, (ctx.params.f("smearing") / 100.0).clamp(0.0, 1.0) as f32]; - p.f[1] = [sn as f32, cs as f32, 0.0, 0.0]; - run(e, "fp2_hextile", &p, b, None, None) + let count = (b.img.width as usize).checked_mul(b.img.height as usize)?; + if count.checked_mul(8)? > e.g.device.limits().max_storage_buffer_binding_size as usize { + return None; + } + let geometry = effectcraft_effects::HexTile::new( + ctx, + &Buf { img: Image { width: b.img.width, height: b.img.height, data: vec![] }, offset: b.offset, scale: b.scale }, + ); + let stride = (b.img.width as usize).checked_mul(2)?.max(2); + let mut coords = vec![0.0; count.checked_mul(2)?]; + coords.par_chunks_mut(stride).enumerate().for_each(|(y, row)| { + for (x, xy) in row.as_chunks_mut::<2>().0.iter_mut().enumerate() { + let (sx, sy) = geometry.source_at(x as u32, y as u32); + *xy = [sx as f32, sy as f32]; + } + }); + let data = e.data(&coords); + run(e, "fp2_hextile", &Params::default(), b, None, Some(&data)) } fn smoothie(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { diff --git a/crates/gpu/src/fx_tone.rs b/crates/gpu/src/fx_tone.rs index cb7144114..f6e7062e2 100644 --- a/crates/gpu/src/fx_tone.rs +++ b/crates/gpu/src/fx_tone.rs @@ -174,6 +174,62 @@ fn parse_map(s: &str) -> Vec { vals.iter().map(|v| (v * k).clamp(0.0, 1.0)).collect() } +/// Piecewise-linear lookup in the CPU effect's f64 phase domain. Store the +/// first representable f32 at each boundary, its value and the interval slope. +/// Computing floor/wrap in shader f32 can select the end of the map instead of +/// its beginning at a wrap (a full-scale colour error). +fn arbitrary_map_table(map: &[f32], phase: f64) -> Option> { + let n = map.len(); + if !(2..=4096).contains(&n) || !phase.is_finite() { + return None; + } + let scale = (n - 1) as f64; + let offset = phase * scale / 255.0; + if !offset.is_finite() || offset.abs() >= (1u64 << 52) as f64 { + return None; + } + let at = |v: f32| { + let x = (v as f64 * scale + offset).rem_euclid(n as f64); + let i = x.floor() as usize; + let a = *map.get(i).unwrap_or(&0.0); + let b = *map.get((i + 1).min(n - 1)).unwrap_or(&a); + (a + (b - a) * (x - x.floor()) as f32, (b - a) * scale as f32) + }; + let mut points = vec![0.0f32]; + // Search ordered positive f32 encodings using the CPU's actual f64 sum. + // Merely rounding a mathematical boundary upward misses the half-ulp + // region where f64 addition itself rounds across a wrap. + let first = offset.floor() + 1.0; + for i in 0..=n { + let boundary = first + i as f64; + if boundary > scale + offset { + break; + } + let (mut lo, mut hi) = (0u32, 1.0f32.to_bits()); + while lo < hi { + let mid = lo + (hi - lo) / 2; + if f32::from_bits(mid) as f64 * scale + offset < boundary { + lo = mid + 1; + } else { + hi = mid; + } + } + let start = f32::from_bits(lo); + if points.last().is_none_or(|last| start > *last) { + points.push(start); + } + } + if points.last() != Some(&1.0) { + points.push(1.0); + } + let mut table = Vec::with_capacity(points.len() * 3); + for v in points { + let (value, slope) = at(v); + table.extend([v, value, slope]); + } + Some(table) +} + fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { let pr = ctx.params; let f = |k: &str| pr.f(k) as f32; @@ -308,9 +364,9 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { } "ec.color.psarbitrarymap" => { let map = parse_map(pr.s("map")); - p.u[0] = [12, pr.b("applyPhaseMapToAlpha") as u32, map.len() as u32, 0]; - p.f[0][0] = (pr.f("phase") * (map.len() - 1) as f64 / 255.0) as f32; - data = Some(map); + let table = arbitrary_map_table(&map, pr.f("phase"))?; + p.u[0] = [12, pr.b("applyPhaseMapToAlpha") as u32, (table.len() / 3) as u32, 0]; + data = Some(table); } "ec.color.videolimiter" => { const CLIP_LEVELS: [f32; 5] = [0.9, 0.95, 1.0, 1.05, 1.09]; diff --git a/crates/gpu/src/fx_transition.rs b/crates/gpu/src/fx_transition.rs index 60378491e..b5cb07012 100644 --- a/crates/gpu/src/fx_transition.rs +++ b/crates/gpu/src/fx_transition.rs @@ -15,6 +15,8 @@ use effectcraft_effects::{Buf, EffectCtx}; use effectcraft_raster::Image; +use rayon::prelude::*; + use crate::context::{Enc, GpuImage, Params}; use crate::effects::{GBuf, gaussian_blur}; @@ -203,25 +205,22 @@ fn grid_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { if done <= 0.0 { return Some(b); } - let pr = ctx.params; - let c = b.to_px(pr.v2("center")); - let rot = pr.f("rotation").to_radians(); - let border = pr.f("border").max(0.0) * b.scale; - let tiles = pr.f("tiles").round().clamp(1.0, 500.0); - let shape = pr.e("shape"); - let cs = (ctx.layer_size[0] * b.scale / tiles).max(1.0); - let (s, co) = rot.sin_cos(); - let metric = |dx: f64, dy: f64| match shape { - 0 => dx.abs(), - 2 => dx.abs().max(dy.abs()), - _ => dx.hypot(dy), - }; - let maxd = max_corner_dist(&b, c, |dx, dy| metric(dx * co + dy * s, -dx * s + dy * co)); - let mut p = Params::default(); - p.u[0] = [shape, pr.b("reverse") as u32, 0, 0]; - p.f[0] = f4([c.0, c.1, s, co]); - p.f[1] = f4([cs, maxd, done, border]); - run(e, "ftr_grid", &p, b, None, None) + // Decide discontinuous tile coverage in the CPU effect's f64 geometry. + // Only a scalar mask is uploaded; source pixels and multiplication stay resident. + let count = (b.img.width as usize).checked_mul(b.img.height as usize)?; + if count.checked_mul(4)? > e.g.device.limits().max_storage_buffer_binding_size as usize { + return None; + } + let geometry = shape(&b); + let grid = effectcraft_effects::GridWipe::new(ctx, &geometry); + let mut mask = vec![0.0; count]; + mask.par_chunks_mut((b.img.width as usize).max(1)).enumerate().for_each(|(y, row)| { + for (x, value) in row.iter_mut().enumerate() { + *value = grid.coverage(x as u32, y as u32); + } + }); + let data = e.data(&mask); + run(e, "ftr_grid", &Params::default(), b, None, Some(&data)) } fn radial_scale_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { diff --git a/crates/gpu/src/shaders/common.wgsl b/crates/gpu/src/shaders/common.wgsl index 2c65c9d4a..2ebd727b8 100644 --- a/crates/gpu/src/shaders/common.wgsl +++ b/crates/gpu/src/shaders/common.wgsl @@ -50,6 +50,91 @@ fn acos_p(x: f32) -> f32 { return 1.5707963267948966 - asin_p(x); } +// Some hardware's native trig instructions saturate cos near a full turn and +// have errors large enough to move particles and sphere samples visibly. Reduce +// to [-pi/4, pi/4] with a split pi/2, then evaluate the Taylor polynomials with +// explicit fused operations. The effect angles use this bounded range; retain +// the builtins for non-finite or very large arguments rather than cast them. +fn sincos_p(x: f32) -> vec2 { + if (!(abs(x) <= 8192.0)) { + return vec2(sin(x), cos(x)); + } + let k = round(x * 0.6366197723675814); + // Keep the coarse reduction in integers. Some drivers reassociate even + // explicit fma chains into x - k*pi/2, losing low bits for large angles. + // 3216/2048 is exact; the bounded scaled input and product fit in i32. + let scaled = x * 2048.0; + let whole = floor(scaled); + var r = f32(i32(whole) - i32(k) * 3216) / 2048.0; + r += (scaled - whole) / 2048.0; + r = fma(-k, 0.0004838267893064022, r); + r = fma(-k, 5.564431554416771e-12, r); + let z = r * r; + var sp = fma(z, -1.0 / 39916800.0, 1.0 / 362880.0); + sp = fma(z, sp, -1.0 / 5040.0); + sp = fma(z, sp, 1.0 / 120.0); + sp = fma(z, sp, -1.0 / 6.0); + let sn = fma(r * z, sp, r); + var cp = fma(z, -1.0 / 3628800.0, 1.0 / 40320.0); + cp = fma(z, cp, -1.0 / 720.0); + cp = fma(z, cp, 1.0 / 24.0); + cp = fma(z, cp, -0.5); + let cs = fma(z, cp, 1.0); + switch imod(i32(k), 4) { + case 1: { return vec2(cs, -sn); } + case 2: { return vec2(-sn, -cs); } + case 3: { return vec2(-cs, sn); } + default: { return vec2(sn, cs); } + } +} + +fn sin_p(x: f32) -> f32 { return sincos_p(x).x; } +fn cos_p(x: f32) -> f32 { return sincos_p(x).y; } + +// Correctly-rounded positive x/a, clamped to [0,1]. Native division and fma +// need not have IEEE precision on every shader backend. Discontinuous colour +// effects need the exact CPU f32 value, not just an approximation within an ulp. +// Long division uses only bounded 24-bit significands (at most 24 iterations). +fn unit_div(x: f32, a: f32) -> f32 { + if (x <= 0.0 || a <= 0.0) { return 0.0; } + if (x >= a) { return 1.0; } + if (a == 1.0) { return x; } + let xb = bitcast(x); + let ab = bitcast(a); + var nx = xb & 0x7fffffu; + var na = ab & 0x7fffffu; + var ex = i32((xb >> 23u) & 255u) - 127; + var ea = i32((ab >> 23u) & 255u) - 127; + if (ex == -127) { + let shift = 23u - firstLeadingBit(nx); + nx <<= shift; + ex = -126 - i32(shift); + } else { nx |= 0x800000u; } + if (ea == -127) { + let shift = 23u - firstLeadingBit(na); + na <<= shift; + ea = -126 - i32(shift); + } else { na |= 0x800000u; } + var exponent = ex - ea; + if (nx < na) { nx <<= 1u; exponent -= 1; } + var rem = nx - na; + if (exponent < -150) { return 0.0; } + if (exponent == -150) { return bitcast(select(0u, 1u, rem != 0u)); } + let fraction = u32(min(23, exponent + 149)); + var q = 1u; + for (var i = 0u; i <= fraction; i++) { + rem <<= 1u; + q <<= 1u; + if (rem >= na) { rem -= na; q |= 1u; } + } + let guard = q & 1u; + q >>= 1u; + if (guard != 0u && (rem != 0u || (q & 1u) != 0u)) { q += 1u; } + if (exponent < -126) { return bitcast(q); } + if (q == 0x1000000u) { q >>= 1u; exponent += 1; } + return bitcast((u32(exponent + 127) << 23u) | (q & 0x7fffffu)); +} + // ---------------------------------------------------------------- sampling (raster::Image) fn tex_get(t: texture_2d, x: i32, y: i32) -> vec4 { diff --git a/crates/gpu/src/shaders/fx_pixel2.wgsl b/crates/gpu/src/shaders/fx_pixel2.wgsl index 2c6f5e50c..583225299 100644 --- a/crates/gpu/src/shaders/fx_pixel2.wgsl +++ b/crates/gpu/src/shaders/fx_pixel2.wgsl @@ -633,52 +633,9 @@ fn fp2_hextile(@builtin(global_invocation_id) gid: vec3) { if (!fp2_inside(p)) { return; } - let cx = P.f[0].x; - let cy = P.f[0].y; - let r = P.f[0].z; - let smear = P.f[0].w; - let sn = P.f[1].x; - let cs = P.f[1].y; - let mode = P.u[0].x; - let dx = f32(p.x) + 0.5 - cx; - let dy = f32(p.y) + 0.5 - cy; - let u = dx * cs + dy * sn; - let v = -dx * sn + dy * cs; - // stylize3::hex_cell (cube rounding). - let q = (0.5773502691896257 * u - v / 3.0) / r; - let rr = (2.0 / 3.0 * v) / r; - // Evaluate the third cube coordinate symmetrically with q so the centerline - // tie is not broken by subtracting separately rounded coordinates. - let ccy = (-0.5773502691896257 * u - v / 3.0) / r; - var rx = round_away(q); - let ry = round_away(ccy); - var rz = round_away(rr); - let ddx = abs(rx - q); - let ddy = abs(ry - ccy); - let ddz = abs(rz - rr); - if (ddx > ddy && ddx > ddz) { - rx = -ry - rz; - } else if (ddy <= ddz) { - rz = -rx - ry; - } - let hx = r * 1.7320508075688772 * (rx + rz / 2.0); - let hy = r * 1.5 * rz; - var lu = u - hx; - var lv = v - hy; - let odd = imod(i32(rx) + i32(rz), 2) == 1; - if ((mode == 1u || mode == 2u) && odd) { - lu = -lu; - } - if (mode == 2u && odd) { - lv = -lv; - } - let ox = lu * cs - lv * sn; - let oy = lu * sn + lv * cs; - let tx = cx + ox; - let ty = cy + oy; - let sx = tx + (f32(p.x) + 0.5 - tx) * smear; - let sy = ty + (f32(p.y) + 0.5 - ty) * smear; - textureStore(out, p, sample_bilinear_clamped(src, sx, sy)); + // Cell membership and reflection were decided in the CPU effect's f64 geometry. + let i = (gid.y * u32(out_dims().x) + gid.x) * 2u; + textureStore(out, p, sample_bilinear_clamped(src, data[i], data[i + 1u])); } // ---------------------------------------------------------------- CC Mr. Smoothie (stylize3::smoothie) diff --git a/crates/gpu/src/shaders/fx_sim.wgsl b/crates/gpu/src/shaders/fx_sim.wgsl index 0c6023f32..7deeac048 100644 --- a/crates/gpu/src/shaders/fx_sim.wgsl +++ b/crates/gpu/src/shaders/fx_sim.wgsl @@ -114,8 +114,8 @@ fn fxm_sprite(at: u32, x: f32, y: f32) -> FxmShade { cov = min(0.12 * select(0.0, 1.0, d <= 1.0) + ring * 0.8 + hl, 1.0); } case 5u: { - let sn = sin(data[at + 8u]); - let cs = cos(data[at + 8u]); + let sn = sin_p(data[at + 8u]); + let cs = cos_p(data[at + 8u]); let ux = (dx * cs + dy * sn) / r; let uy = (-dx * sn + dy * cs) / r; let d = sqrt(ux * ux + uy * uy); @@ -125,23 +125,23 @@ fn fxm_sprite(at: u32, x: f32, y: f32) -> FxmShade { cov = min(core + stx + sty, 1.0); } case 6u: { - let sn = sin(data[at + 8u]); - let cs = cos(data[at + 8u]); + let sn = sin_p(data[at + 8u]); + let cs = cos_p(data[at + 8u]); let ux = dx * cs + dy * sn; let uy = -dx * sn + dy * cs; let e = r * 0.75; cov = fxm_aa(e - abs(ux)) * fxm_aa(e - abs(uy)); } case 7u: { - let sn = sin(data[at + 8u]); - let cs = cos(data[at + 8u]); + let sn = sin_p(data[at + 8u]); + let cs = cos_p(data[at + 8u]); let ux = dx * cs + dy * sn; let uy = -dx * sn + dy * cs; let e = r * 0.5; var m = 3.4e38; for (var k = 0; k < 3; k++) { let a = 1.5707964 + f32(k) * 6.2831855 / 3.0; - m = min(m, e - (ux * cos(a) + uy * sin(a))); + m = min(m, e - (ux * cos_p(a) + uy * sin_p(a))); } cov = fxm_aa(m); } @@ -431,8 +431,8 @@ fn fxm_ripples(x: f32, y: f32) -> f32 { let r = data[i * 4u + 2u]; let u = (d - r) / P.f[0].y; if (abs(u) < 1.0) { - let env = 0.5 + 0.5 * cos(u * 3.1415927); - v += data[i * 4u + 3u] * env * cos((d - r) / P.f[0].x * 6.2831855); + let env = 0.5 + 0.5 * cos_p(u * 3.1415927); + v += data[i * 4u + 3u] * env * cos_p((d - r) / P.f[0].x * 6.2831855); } } return v; diff --git a/crates/gpu/src/shaders/fx_stylize.wgsl b/crates/gpu/src/shaders/fx_stylize.wgsl index 7fe42df7a..1fa8134fc 100644 --- a/crates/gpu/src/shaders/fx_stylize.wgsl +++ b/crates/gpu/src/shaders/fx_stylize.wgsl @@ -42,7 +42,11 @@ fn fxs_qdiv(x: vec3, a: f32) -> vec3 { let r = fma(-q, vec3(a), x); // `q + r / a`, written as an fma so drivers (NVIDIA Vulkan) can't contract it another way // and round differently from the CPU. - return fma(r / a, vec3(1.0), q); + let corrected = fma(r / a, vec3(1.0), q); + return vec3( + select(corrected.x, unit_div(x.x, a), x.x >= 0.0 && x.x <= a), + select(corrected.y, unit_div(x.y, a), x.y >= 0.0 && x.y <= a), + select(corrected.z, unit_div(x.z, a), x.z >= 0.0 && x.z <= a)); } fn fxs_rem2(i: i32) -> i32 { @@ -63,6 +67,32 @@ fn fxs_bin(v: f32, t: f32, inv: bool) -> f32 { return select(0.0, 1.0, (v >= t) != inv); } +// Preserve the CPU's separately-rounded f32 multiply before rounding to a bin. +// A shader compiler may reassociate division and multiplication across a half +// threshold. The 24-bit mantissa times at most 254 fits in u32 exactly. +fn fxs_posterize_bin(v: f32, levels: u32) -> f32 { + let bits = bitcast(clamp(v, 0.0, 1.0)); + let exponent = (bits >> 23u) & 255u; + if (exponent == 0u) { return 0.0; } + var product = ((bits & 0x7fffffu) | 0x800000u) * levels; + let high = firstLeadingBit(product); + if (high > 23u) { + let lost = high - 23u; + let mask = (1u << lost) - 1u; + let remainder = product & mask; + let half = 1u << (lost - 1u); + var kept = product >> lost; + if (remainder > half || (remainder == half && (kept & 1u) != 0u)) { kept += 1u; } + product = kept << lost; + } + let shift = 150u - exponent; + if (shift > 32u) { return 0.0; } + if (shift == 32u) { return select(0.0, 1.0, product >= 0x80000000u); } + let mask = (1u << shift) - 1u; + let integer = (product >> shift) + select(0u, 1u, (product & mask) >= (1u << (shift - 1u))); + return f32(integer); +} + fn fxs_strobe_op(op: u32, v: f32, k: f32) -> f32 { switch op { case 1u: { return v + k; } @@ -96,8 +126,8 @@ fn fxs_point(@builtin(global_invocation_id) gid: vec3) { switch mode { case 1u: { let lv = P.f[0].x; - let c = clamp(fxs_qdiv(px.xyz, a), vec3(0.0), vec3(1.0)) * lv; - let o = vec3(round_away(c.x), round_away(c.y), round_away(c.z)) / lv; + let c = vec3(unit_div(px.x, a), unit_div(px.y, a), unit_div(px.z, a)); + let o = vec3(fxs_posterize_bin(c.x, u32(lv)), fxs_posterize_bin(c.y, u32(lv)), fxs_posterize_bin(c.z, u32(lv))) / lv; textureStore(out, p, vec4(o * a, a)); } case 2u: { diff --git a/crates/gpu/src/shaders/fx_tone.wgsl b/crates/gpu/src/shaders/fx_tone.wgsl index c99d199c5..bb206adbd 100644 --- a/crates/gpu/src/shaders/fx_tone.wgsl +++ b/crates/gpu/src/shaders/fx_tone.wgsl @@ -129,16 +129,17 @@ fn fxt_knee(v: f32, start: f32, limit: f32) -> f32 { return start + r * (1.0 - exp(-(v - start) / r)); } -// PS Arbitrary Map lookup (map in data, n entries; f[0].x = phase offset in entries). +// PS Arbitrary Map: CPU-rounded interval starts, values and slopes in data. fn fxt_lookup(v: f32, n: u32) -> f32 { - let nf = f32(n); - var x = clamp(v, 0.0, 1.0) * (nf - 1.0) + P.f[0].x; - x = x - nf * floor(x / nf); - let fl = floor(x); - let i0 = u32(fl) % n; - let i1 = select(i0, i0 + 1u, i0 + 1u < n); - let t = x - fl; - return data[i0] + (data[i1] - data[i0]) * t; + let x = clamp(v, 0.0, 1.0); + var lo = 0u; + var hi = n; + while (lo + 1u < hi) { + let mid = lo + (hi - lo) / 2u; + if (x < data[mid * 3u]) { hi = mid; } else { lo = mid; } + } + let i = lo * 3u; + return data[i + 1u] + (x - data[i]) * data[i + 2u]; } fn fxt_eq_look(table: u32, n: u32, v: f32) -> f32 { @@ -447,7 +448,11 @@ fn fxt_point(@builtin(global_invocation_id) gid: vec3) { case 12u: { let n = u0.z; let aa = max(a, 0.0); - let r = vec3(fxt_lookup(c.x, n), fxt_lookup(c.y, n), fxt_lookup(c.z, n)); + var straight = vec3(0.0); + if (a > 1e-6) { + straight = vec3(unit_div(px.x, a), unit_div(px.y, a), unit_div(px.z, a)); + } + let r = vec3(fxt_lookup(straight.x, n), fxt_lookup(straight.y, n), fxt_lookup(straight.z, n)); var na = aa; if (u0.y != 0u) { na = fxt_lookup(aa, n); diff --git a/crates/gpu/src/shaders/fx_transition.wgsl b/crates/gpu/src/shaders/fx_transition.wgsl index 5037ec9a6..0e9553d58 100644 --- a/crates/gpu/src/shaders/fx_transition.wgsl +++ b/crates/gpu/src/shaders/fx_transition.wgsl @@ -224,28 +224,8 @@ fn ftr_grid(@builtin(global_invocation_id) gid: vec3) { return; } let px = textureLoad(src, p, 0); - let d = ftr_centre(p) - P.f[0].xy; - let s = P.f[0].z; - let co = P.f[0].w; - let q = vec2(d.x * co + d.y * s, -d.x * s + d.y * co); - let cs = P.f[1].x; - let cc = (floor(q / cs) + 0.5) * cs; - var o = clamp(ftr_metric(P.u[0].x, cc) / P.f[1].y, 0.0, 1.0); - if (P.u[0].y != 0u) { - o = 1.0 - o; - } - let spread = 0.25; - let pr = clamp((P.f[1].z * (1.0 + spread) - o) / spread, 0.0, 1.0); - if (pr <= 0.0) { - textureStore(out, p, px); - return; - } - let half = cs * 0.5 * (1.0 - pr); - let m = max(abs(q.x - cc.x), abs(q.y - cc.y)); - var k = select(0.0, 1.0, m < half); - if (P.f[1].w > 0.0) { - k = clamp((half - m) / P.f[1].w + 0.5, 0.0, 1.0); - } + // CPU-generated coverage preserves exact f64 tile and hard-edge decisions. + let k = data[gid.y * u32(out_dims().x) + gid.x]; textureStore(out, p, px * k); } diff --git a/crates/gpu/src/shaders/fx_vr.wgsl b/crates/gpu/src/shaders/fx_vr.wgsl index a2bb364c8..9e2d4390e 100644 --- a/crates/gpu/src/shaders/fx_vr.wgsl +++ b/crates/gpu/src/shaders/fx_vr.wgsl @@ -16,7 +16,7 @@ fn fxv_unpremul(p: vec4) -> vec4 { fn fxv_equi_dir(x: f32, y: f32, w: f32, h: f32) -> vec3 { let lon = (x / w - 0.5) * 2.0 * FXV_PI; let lat = (0.5 - y / h) * FXV_PI; - return vec3(cos(lat) * sin(lon), sin(lat), cos(lat) * cos(lon)); + return vec3(cos_p(lat) * sin_p(lon), sin_p(lat), cos_p(lat) * cos_p(lon)); } // vr::equi_px. @@ -168,7 +168,7 @@ fn fxv_dir(kind: u32, fov: f32, x: f32, y: f32, w: f32, h: f32) -> vec4 { cu = u / r; cv = v / r; } - return vec4(sin(th) * cu, cos(th), sin(th) * cv, 1.0); + return vec4(sin_p(th) * cu, cos_p(th), sin_p(th) * cv, 1.0); } if (kind == 4u) { let half = radians(clamp(fov, 1.0, 360.0) / 2.0); @@ -186,7 +186,7 @@ fn fxv_dir(kind: u32, fov: f32, x: f32, y: f32, w: f32, h: f32) -> vec4 { cu = u / r; cv = v / r; } - return vec4(sin(th) * cu, sin(th) * cv, cos(th), 1.0); + return vec4(sin_p(th) * cu, sin_p(th) * cv, cos_p(th), 1.0); } let t = tan(radians(clamp(fov, 1.0, 179.0) / 2.0)); let u = (x / w * 2.0 - 1.0) * t; @@ -519,8 +519,8 @@ fn fxv_gen(@builtin(global_invocation_id) gid: vec3) { var s = px; if (al >= 1e-9) { let new_th = th * (1.0 - P.f[0][c] * weight); - let perp = normalize(d - center * cos(th)); - let nd = center * cos(new_th) + perp * sin(new_th); + let perp = normalize(d - center * cos_p(th)); + let nd = center * cos_p(new_th) + perp * sin_p(new_th); let q = fxv_equi_px(nd, w, h); s = fxv_sample_equi(q.x, q.y); } diff --git a/crates/gpu/src/shaders/particles.wgsl b/crates/gpu/src/shaders/particles.wgsl index 694d6fb67..ac12b8fc7 100644 --- a/crates/gpu/src/shaders/particles.wgsl +++ b/crates/gpu/src/shaders/particles.wgsl @@ -53,12 +53,12 @@ fn ps_norm3(v: vec3) -> vec3 { fn ps_rand_dir(i: u32, k: u32, flat: bool) -> vec3 { if (flat) { let a = ps_h(i, k) * PS_TAU; - return vec3(cos(a), sin(a), 0.0); + return vec3(cos_p(a), sin_p(a), 0.0); } let z = ps_hs(i, k); let a = ps_h(i, k + 1u) * PS_TAU; let r = sqrt(max(1.0 - z * z, 0.0)); - return vec3(r * cos(a), r * sin(a), z); + return vec3(r * cos_p(a), r * sin_p(a), z); } struct Part { @@ -189,7 +189,7 @@ fn ps_spawn_cannon(id: u32) -> Part { let vel = S.f[1].y; let vel_spread = S.f[1].z; let a = radians(angle + ps_hs(id, 1u) * spread * 0.5); - let d = vec2(sin(a), -cos(a)); + let d = vec2(sin_p(a), -cos_p(a)); let speed = max(vel + ps_hs(id, 3u) * vel_spread, 0.0); var off: vec2; if (r >= 0.0) { @@ -197,7 +197,7 @@ fn ps_spawn_cannon(id: u32) -> Part { } else { let aa = ps_h(id, 5u) * PS_TAU; let rr = sqrt(ps_h(id, 6u)) * -r; - off = vec2(cos(aa) * rr, sin(aa) * rr); + off = vec2(cos_p(aa) * rr, sin_p(aa) * rr); } let v = d * speed; let dt = S.f[2].z; diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 3f8e158f6..35ad402ba 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -57,6 +57,103 @@ mod particles; mod three_d; mod wgsl_grammar; +pub(crate) const MATH_PROBE_SHADER: &str = r#" +@compute @workgroup_size(16, 16) +fn math_probe(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (p.x >= out_dims().x || p.y >= out_dims().y) { return; } + let v = textureLoad(src, p, 0).x; + if (P.u[0].x == 1u) { + let a = textureLoad(src, p, 0).w; + let q = unit_div(v, a); + textureStore(out, p, vec4(q, fxs_posterize_bin(q, P.u[0].y), 0.0, 1.0)); + return; + } + textureStore(out, p, vec4(sin_p(v), cos_p(v), 0.0, 1.0)); +} +"#; + +/// Direction errors accumulate into visible particle offsets and spherical +/// remapping errors. Check the numerical primitive before rendering a scene. +#[test] +fn angular_math_preserves_particle_and_vr_directions() { + let Some(g) = gpu() else { return }; + for extent in [std::f32::consts::TAU, 100.0, 8192.0] { + let mut img = Image::new(4097, 1); + for (i, px) in img.data.iter_mut().enumerate() { + px[0] = (i as f32 - 2048.0) * extent / 2048.0; + } + let input = g.ctx.upload_image(&img).unwrap(); + let mut e = crate::context::Enc::new(&g.ctx); + let out = e.scratch(img.width, img.height); + e.pixels("math_probe", &Default::default(), &input, None, &out, None); + let result = e.download(&out).unwrap(); + for (i, (v, got)) in img.data.iter().zip(result.data).enumerate() { + let want = [(v[0] as f64).sin() as f32, (v[0] as f64).cos() as f32]; + for c in 0..2 { + assert!((want[c] - got[c]).abs() <= 2e-7, "sample {i}, angle {}: CPU {want:?}, GPU {got:?}", v[0]); + } + } + } +} + +#[test] +fn alpha_division_and_posterization_match_f32_rounding() { + let Some(g) = gpu() else { return }; + let mut values = vec![]; + for levels in [1u32, 3, 8, 254] { + values.clear(); + for a in [1.0f32, 0.1, 0.3, 1.0 / 255.0, 6.0 / 255.0] { + for i in 0..levels { + let half = (i as f32 + 0.5) / levels as f32; + for v in [0.0, 1e-6, half.next_down(), half, half.next_up(), 1.0] { + values.push([v * a, 0.0, 0.0, a]); + } + } + } + let img = Image { width: values.len() as u32, height: 1, data: values.clone() }; + let input = g.ctx.upload_image(&img).unwrap(); + let mut e = crate::context::Enc::new(&g.ctx); + let out = e.scratch(img.width, 1); + let mut params = crate::context::Params::default(); + params.u[0] = [1, levels, 0, 0]; + e.pixels("math_probe", ¶ms, &input, None, &out, None); + let result = e.download(&out).unwrap(); + for (i, (v, got)) in values.iter().zip(result.data).enumerate() { + let q = (v[0] / v[3]).clamp(0.0, 1.0); + assert_eq!(q.to_bits(), got[0].to_bits(), "levels {levels}, pixel {i}, input {v:?}"); + assert_eq!((q * levels as f32).round(), got[1], "levels {levels}, pixel {i}, input {v:?}"); + } + } +} + +/// Prove that the numerical fixes run on the device, without per-effect image +/// downloads or CPU fallback. Compiler-limited DX12 devices retain their tested +/// fallback contract; these kernels must compile on the Vulkan test adapters. +#[test] +fn numerical_effects_remain_resident_on_vulkan() { + let Some(g) = gpu() else { return }; + if !g.ctx.name.ends_with("(Vulkan)") { + return; + } + for entry in ["fxs_point", "fxt_point", "ftr_grid", "fp2_hextile"] { + assert!(!g.ctx.unavailable_kernels().iter().any(|(name, _)| *name == entry), "{entry}: {:?}", g.ctx.unavailable_kernels()); + } + for (id, vals) in [ + ("ec.stylize.posterize", vec![("level", n(4.0))]), + ("ec.color.psarbitrarymap", vec![("phase", n(40.0))]), + ("ec.transition.ccgridwipe", vec![("completion", n(40.0))]), + ("ec.stylize.cchextile", vec![("radius", n(9.3))]), + ] { + let before = g.ctx.transfer_stats(); + effect_direct(id, &vals, false); + let after = g.ctx.transfer_stats(); + // Full- and half-resolution runs each upload once and download once. + assert_eq!(after.uploads - before.uploads, 2, "{id}: uploads"); + assert_eq!(after.readbacks - before.readbacks, 2, "{id}: readbacks"); + } +} + /// One GPU test at a time in this process: the calling test thread holds a process-wide lock /// until it exits (libtest runs every test on a thread of its own). wgpu's OpenGL backend (Mesa /// llvmpipe on FreeBSD, Linux without Vulkan) has one context lock per adapter and panics when a diff --git a/crates/gpu/src/tests/fx_stylize.rs b/crates/gpu/src/tests/fx_stylize.rs index df99e1ced..d0d97ba31 100644 --- a/crates/gpu/src/tests/fx_stylize.rs +++ b/crates/gpu/src/tests/fx_stylize.rs @@ -46,6 +46,66 @@ fn posterize_thresholds_strobe_vignette() { effect_case("ec.stylize.ccvignette", &[("amount", n(-60.0)), ("pinHighlights", n(50.0)), ("angleOfView", n(120.0)), ("center", pt(20.0, 15.0))]); } +/// Premultiplied, quantized footage often lands exactly on a posterization +/// threshold after division by alpha. Exercise that input before any placement +/// or compositing can hide a wrong bin behind the normal image tolerance. +#[test] +fn posterize_quantized_premultiplied_boundaries() { + let Some(g) = crate::tests::gpu() else { return }; + let spec = effectcraft_effects::find("ec.stylize.posterize").unwrap(); + for level in [4.0, 9.0] { + let params = effectcraft_effects::Params { values: [("level".into(), n(level))].into() }; + let ctx = + || effectcraft_effects::EffectCtx { params: ¶ms, time: 0.0, layer_size: [70.0, 44.0], seed: 11, adjustment: false, env: Default::default() }; + for seed in 1..=7 { + let mut img = crate::tests::pattern(seed, 70, 44); + effectcraft_render::color::quantize(&mut img, 255.0); + let input = effectcraft_effects::Buf { img, offset: [0.0; 2], scale: 1.0 }; + let cpu = (spec.render)(&ctx(), input.clone()); + let gpu = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &input, None).unwrap(); + for (i, (want, got)) in cpu.img.data.iter().zip(&gpu.img.data).enumerate() { + for c in 0..4 { + assert!((want[c] - got[c]).abs() <= 1e-6, "level {level} seed {seed} pixel {i}: input {:?}, CPU {want:?}, GPU {got:?}", input.img.data[i]); + } + } + } + } +} + +/// Wrapped ramps have a large discontinuity at their end. Check signed and +/// fractional phases, including a wrap a few double-precision ulps above zero. +#[test] +fn arbitrary_map_signed_fractional_and_near_zero_wraps() { + let Some(g) = crate::tests::gpu() else { return }; + let spec = effectcraft_effects::find("ec.color.psarbitrarymap").unwrap(); + for phase in [-1000.0, -20.5, 0.0, 40.0, 1000.0, 255.99999999999997] { + let params = effectcraft_effects::Params { + values: [ + ("map".into(), Value::Str((0..256).map(|n| n.to_string()).collect::>().join(","))), + ("phase".into(), n(phase)), + ("applyPhaseMapToAlpha".into(), on()), + ] + .into(), + }; + let ctx = + || effectcraft_effects::EffectCtx { params: ¶ms, time: 0.0, layer_size: [1024.0, 1.0], seed: 7, adjustment: false, env: Default::default() }; + let mut img = effectcraft_raster::Image::new(1024, 1); + for (i, px) in img.data.iter_mut().enumerate() { + let v = if i < 512 { i as f32 * 1e-18 } else { (i - 512) as f32 / 511.0 }; + let a = [1.0f32, 0.1, 0.3, 6.0 / 255.0][i % 4]; + *px = [v * a, v * a, v * a, a]; + } + let input = effectcraft_effects::Buf { img, offset: [0.0; 2], scale: 1.0 }; + let cpu = (spec.render)(&ctx(), input.clone()); + let gpu = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &input, None).unwrap(); + for (i, (want, got)) in cpu.img.data.iter().zip(&gpu.img.data).enumerate() { + for c in 0..4 { + assert!((want[c] - got[c]).abs() <= 1e-6, "phase {phase}, pixel {i}: CPU {want:?}, GPU {got:?}"); + } + } + } +} + #[test] fn scatter_brush_strokes() { effect_case("ec.stylize.scatter", &[("amount", n(5.0))]); diff --git a/crates/host/tests/it/plugins.rs b/crates/host/tests/it/plugins.rs index 83891148d..593e1ffcd 100644 --- a/crates/host/tests/it/plugins.rs +++ b/crates/host/tests/it/plugins.rs @@ -69,14 +69,18 @@ fn rust_plugins_apply_by_id() { /// Build the example plug-in (skipped when the wasm32 target isn't installed). fn build_example() -> Option { let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/plugins/posterize-bands"); - let target = std::env::var_os("CARGO_TARGET_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| root.join("../../../target")).join("plugin-example"); - let target = if target.is_absolute() { target } else { root.join("../../..").join(target) }; - let st = std::process::Command::new(env!("CARGO")) - .current_dir(&root) - .args(["build", "--release", "--target", "wasm32-unknown-unknown", "--target-dir"]) - .arg(&target) - .status() - .ok()?; + let workspace = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let assigned = std::env::var_os("CARGO_TARGET_DIR"); + let target = assigned.as_ref().map(std::path::PathBuf::from).unwrap_or_else(|| workspace.join("target")); + let target = if target.is_absolute() { target } else { workspace.join(target) }; + // The plug-in is part of this test's build. Inherit the assigned target and + // compiler guards rather than creating a second target at its root. + let mut command = std::process::Command::new(env!("CARGO")); + command.current_dir(&workspace).args(["build", "--release", "--target", "wasm32-unknown-unknown", "--manifest-path"]).arg(root.join("Cargo.toml")); + if assigned.is_none() { + command.env("CARGO_TARGET_DIR", &target); + } + let st = command.status().ok()?; if !st.success() { eprintln!("skipping: the example plug-in didn't build (rustup target add wasm32-unknown-unknown)"); return None; diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index aa1b30350..e67bd2d7d 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -352,6 +352,7 @@ static NOTO_SYMBOLS2: &[u8] = include_bytes!("../../../assets/fonts/NotoSansSymb /// frame loop), so the fonts do not need a restart. pub fn install(ctx: &egui::Context, t: &Tokens, language: &str) { let mut fonts = FontDefinitions::default(); + let proportional_fallbacks = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); fonts.font_data.insert("inter".into(), Arc::new(FontData::from_static(INTER_REGULAR))); fonts.font_data.insert("inter-medium".into(), Arc::new(FontData::from_static(INTER_MEDIUM))); fonts.font_data.insert("inter-semibold".into(), Arc::new(FontData::from_static(INTER_SEMIBOLD))); @@ -359,8 +360,13 @@ pub fn install(ctx: &egui::Context, t: &Tokens, language: &str) { fonts.font_data.insert("noto-symbols2".into(), Arc::new(FontData::from_static(NOTO_SYMBOLS2))); fonts.families.entry(FontFamily::Proportional).or_default().insert(0, "inter".into()); fonts.families.entry(FontFamily::Monospace).or_default().insert(0, "jbmono".into()); - fonts.families.insert(FontFamily::Name("semibold".into()), vec!["inter-semibold".into(), "inter".into()]); - fonts.families.insert(FontFamily::Name("medium".into()), vec!["inter-medium".into(), "inter".into()]); + // Named weights need the same symbol fallback chain as ordinary labels. + // The bundled Inter subset and JetBrains Mono do not cover every UI symbol. + for (family, face) in [("semibold", "inter-semibold"), ("medium", "inter-medium")] { + let mut stack = vec![face.into(), "inter".into()]; + stack.extend(proportional_fallbacks.iter().cloned()); + fonts.families.insert(FontFamily::Name(family.into()), stack); + } // Inter and egui's own fallbacks have no small triangles (the `▸` of "File ▸ Import"), which // would draw as missing-glyph boxes: JetBrains Mono fills in the symbols the others lack. // Noto Sans Symbols 2 follows it for the check/box glyphs (`✔`, `▣`) that JetBrains Mono From ff686265977b5c228786fc59dab8447c9e0a6909 Mon Sep 17 00:00:00 2001 From: Joseph <56056764+00xJS@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:56:37 -0700 Subject: [PATCH 71/71] #1605: Posterize and CC Grid Wipe match the CPU on AMD and Intel (#1836) Posterize: round_away decided half-way by subtracting, and drivers may fuse the product that feeds it into one fma (AMD does), skipping the product's rounding. A straight value of 5/6 at level 4 gives 5/6 * 3 = 2.5 exactly in f32 on the CPU but 2.49999994 fused, so the GPU rounded a level down. round_away now compares x with t +/- 0.5 (exact for integer t), which no driver can fuse, and the level divide uses an fma-corrected division (div_rn) like the CPU's. CC Grid Wipe: the cell lookup floor(q / cs) used WGSL's division, which may be off by 2.5 ULP; at q = 7, cs = 3.5 AMD and Intel return just under 2 and pick the neighbouring cell. It uses div_rn too. New test: posterize matches the CPU exactly on every 8 bpc premultiplied colour and alpha at three levels (59715 of 65280 pairs differed before at level 4). RX 6900 XT, Vulkan: effectcraft-gpu 222 passed; the one failure left is CC Hex Tile, fixed by #1776. DX12 + FXC (on #1571): both tests pass. Co-authored-by: 00xJ0 <56056764+00xJ0@users.noreply.github.com> Co-authored-by: Claude Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- crates/gpu/src/shaders/common.wgsl | 11 +++++++---- crates/gpu/src/tests/fx_stylize.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/crates/gpu/src/shaders/common.wgsl b/crates/gpu/src/shaders/common.wgsl index 2ebd727b8..1d8a701b6 100644 --- a/crates/gpu/src/shaders/common.wgsl +++ b/crates/gpu/src/shaders/common.wgsl @@ -242,13 +242,16 @@ fn powz(x: f32, y: f32) -> f32 { return pow(x, y); } -// Rust's f32::round (half away from zero; WGSL's round() is half to even). +// Rust's f32::round (half away from zero; WGSL's round() is half to even). Decided by comparing +// `x` with `t ± 0.5` (exact for integer `t`) rather than subtracting: drivers may fuse a product +// that feeds `x - t` into one fma (AMD does), skipping the product's rounding, so `5/6 · 3`, +// which rounds to exactly 2.5 on the CPU, came out 0.49999994 above 2 and rounded down (#1605). fn round_away(x: f32) -> f32 { let t = trunc(x); - if (abs(x - t) >= 0.5) { - return t + sign(x); + if (x >= 0.0) { + return select(t, t + 1.0, x >= t + 0.5); } - return t; + return select(t, t - 1.0, x <= t - 0.5); } // f32::rem_euclid(1.0). diff --git a/crates/gpu/src/tests/fx_stylize.rs b/crates/gpu/src/tests/fx_stylize.rs index d0d97ba31..d9ca95712 100644 --- a/crates/gpu/src/tests/fx_stylize.rs +++ b/crates/gpu/src/tests/fx_stylize.rs @@ -288,6 +288,36 @@ fn transform_motion_blur() { } } +/// #1605: Posterize matches the CPU exactly on every 8 bpc premultiplied colour and alpha. A +/// straight value of 5/6 at level 4 rounds to exactly 2.5 on the CPU; drivers that fused the +/// product into `round_away`'s subtraction (AMD) rounded it down a level. +#[test] +fn posterize_matches_the_cpu_on_every_8bpc_pair() { + let Some(g) = crate::tests::gpu() else { return }; + let spec = effectcraft_effects::find("ec.stylize.posterize").unwrap(); + let size = [256.0, 255.0]; + let mut params = + effectcraft_effects::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, size))).collect() }; + for level in [4.0, 7.0, 9.0] { + params.values.insert("level".into(), n(level)); + // Pixel (x, a - 1): premultiplied colour min(x, a)/255 on alpha a/255. + let mut img = effectcraft_raster::Image::new(256, 255); + for a in 1..=255u32 { + for x in 0..256u32 { + let v = x.min(a) as f32 / 255.0; + img.data[((a - 1) * 256 + x) as usize] = [v, v, v, a as f32 / 255.0]; + } + } + let buf = effectcraft_effects::Buf { img, offset: [0.0, 0.0], scale: 1.0 }; + let ctx = || effectcraft_effects::EffectCtx { params: ¶ms, time: 0.0, layer_size: size, seed: 1, adjustment: false, env: Default::default() }; + let cpu = (spec.render)(&ctx(), buf.clone()); + let out = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).expect("the GPU runs the effect"); + let bad: Vec<(usize, usize)> = + cpu.img.data.iter().zip(&out.img.data).enumerate().filter(|(_, (c, o))| c != o).map(|(i, _)| ((i % 256).min(i / 256 + 1), i / 256 + 1)).collect(); + assert!(bad.is_empty(), "level {level}: {} (x, a) pairs differ from the CPU, first {:?}", bad.len(), &bad[..bad.len().min(8)]); + } +} + /// Motion Tile's Output size on an adjustment layer with a Transform after it that scales the /// tiles into view, and cropping (Output under 100 %): the stack's grown or cropped frame goes /// back onto the canvas as on the CPU (#1727).