diff --git a/Cargo.lock b/Cargo.lock index c43ba9d9c..232fa6b18 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", @@ -1611,6 +1612,7 @@ dependencies = [ "js-sys", "log", "pollster 1.0.1", + "rayon", "wasm-bindgen", "web-time", "wgpu", diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index 6ba352247..98bcbb598 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 @@ -690,7 +694,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)); } } } @@ -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}); @@ -1539,6 +1544,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/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..8eda44603 100644 --- a/apps/effectcraft-cli/tests/it/cli.rs +++ b/apps/effectcraft-cli/tests/it/cli.rs @@ -110,6 +110,197 @@ 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}"); +} + +/// 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] @@ -136,7 +327,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()) } 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/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/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..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| { @@ -464,6 +463,54 @@ 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 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}"); + } + // 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 = 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] fn median_legacy_radius_zero_identity_and_removes_speck() { let mut img = Image::filled(9, 9, [0.2, 0.2, 0.2, 1.0]); 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"); diff --git a/crates/effects/src/roto.rs b/crates/effects/src/roto.rs index fe6191714..f9c26f417 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 } } @@ -929,7 +935,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 { @@ -959,21 +966,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() @@ -1192,7 +1213,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; @@ -1295,17 +1316,23 @@ 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 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.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)?; let mp = matte_params(params); let (mut prev, mut next) = (None, None); @@ -1321,6 +1348,25 @@ pub fn frame_matte( Some((alpha, seg.refine.clone())) } +/// 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] { + 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::*; @@ -1411,6 +1457,50 @@ 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)); + // 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 = 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 /// objects (and prompts per object) it was asked for. struct TwoObjects(std::sync::Mutex>>); diff --git a/crates/effects/src/stylize2.rs b/crates/effects/src/stylize2.rs index e0354ea75..94eda6370 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 [(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); + 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/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/time_fx.rs b/crates/effects/src/time_fx.rs index 27d2264e5..cf7624d92 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,88 @@ 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 }) + } + /// 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") + } + } + + 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) + } + + /// #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 + // (`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); + } + + /// 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. + 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. 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/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]; 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/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/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/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/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/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/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/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/cmd/model3d.rs b/crates/engine/src/cmd/model3d.rs index 2cc4d34e6..37a70db00 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, 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( + "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/cmd/puppet.rs b/crates/engine/src/cmd/puppet.rs index 1d080e773..d894516c2 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, unless its art already has one (#2865). use effectcraft_effects::puppet::{self, MeshOpts, PinKind}; use effectcraft_effects::{Buf, Params}; @@ -62,6 +63,32 @@ 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 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 }; + !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 { let cmd = "puppet.addPin"; let (cid, lid) = layer_p(s, p, cmd)?; @@ -93,9 +120,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,6 +145,9 @@ fn add_pin(s: &mut Session, p: &Value) -> Result { { return Err(bad(cmd, format!("no mesh {w}"))); } + 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), diff --git a/crates/engine/src/cmd/text_anim.rs b/crates/engine/src/cmd/text_anim.rs index 296dc4c37..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(); @@ -59,7 +60,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 +459,7 @@ pub fn specs() -> Vec { "Add Property", [], None, - "{layer?, animator?: uid|index, 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 ), @@ -463,7 +468,7 @@ pub fn specs() -> Vec { "Add Text Selector", [], None, - "{layer?, animator?: uid|index, 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 ), @@ -482,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}", + "{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/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/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/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/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 + ); + } +} 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"]); +} 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. diff --git a/crates/engine/src/tests/paint.rs b/crates/engine/src/tests/paint.rs index 3659ce0c4..2d2e540a5 100644 --- a/crates/engine/src/tests/paint.rs +++ b/crates/engine/src/tests/paint.rs @@ -225,6 +225,71 @@ 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); + // 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: 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 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": [10, 30]})).unwrap(); + 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(); 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#"{ diff --git a/crates/engine/src/tests/text.rs b/crates/engine/src/tests/text.rs index 3980c7bb5..d24a09240 100644 --- a/crates/engine/src/tests/text.rs +++ b/crates/engine/src/tests/text.rs @@ -49,6 +49,38 @@ fn animate_text_adds_animators_with_companion_properties() { let _ = s.render(cid, s.time(), Default::default()); } +/// `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.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; a uid match wins over an index)"), "{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)); + // 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] fn add_property_and_selectors_with_undo_redo() { let (mut s, t) = session(); diff --git a/crates/engine/src/tests/viewer.rs b/crates/engine/src/tests/viewer.rs index ef2bd8acc..9a0b9cc58 100644 --- a/crates/engine/src/tests/viewer.rs +++ b/crates/engine/src/tests/viewer.rs @@ -60,6 +60,62 @@ 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]); +} + +/// #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/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/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/lib.rs b/crates/export/src/lib.rs index 9a9e66d0f..b93e46d2c 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); @@ -297,7 +311,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 +340,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/src/pipeline.rs b/crates/export/src/pipeline.rs index 01e3f3faf..e0c5cfc87 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -393,11 +393,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/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/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/export/tests/it/main.rs b/crates/export/tests/it/main.rs index 87548f9e5..07ee96c05 100644 --- a/crates/export/tests/it/main.rs +++ b/crates/export/tests/it/main.rs @@ -8,6 +8,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..1a5371d91 --- /dev/null +++ b/crates/export/tests/it/movie_color.rs @@ -0,0 +1,95 @@ +//! 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 frame of the bare elementary stream when +//! ffmpeg can copy it out and 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 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", "-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(); + 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, 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); + + 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 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"), + } + } + } +} 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/export/tests/it/roundtrip.rs b/crates/export/tests/it/roundtrip.rs index 477191f64..3ad174691 100644 --- a/crates/export/tests/it/roundtrip.rs +++ b/crates/export/tests/it/roundtrip.rs @@ -302,6 +302,23 @@ 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 (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); + 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(); 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/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..cc861c85b 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,15 @@ 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); + // 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 { + // Like `release_owned`, nothing taken before a failed frame comes back (#1319). + p.release(self.texture.clone()); + } } } } @@ -103,16 +139,42 @@ 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. 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, + /// 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 +296,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 +440,92 @@ 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); + } + } + } + + /// 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 { + 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); @@ -411,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 { @@ -571,9 +803,12 @@ 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(); + #[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()) }) })?; @@ -657,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) @@ -679,6 +916,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) => { @@ -739,6 +977,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 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, @@ -758,7 +999,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(), @@ -770,6 +1011,8 @@ impl GpuContext { particle_states: Default::default(), deferred: None, readbacks, + poll_lock: Default::default(), + memcap, }) } @@ -803,10 +1046,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}"); @@ -814,6 +1072,73 @@ impl GpuContext { }) } + /// 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; + } + 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, @@ -848,25 +1173,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, @@ -1005,6 +1357,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(), } } @@ -1051,10 +1405,19 @@ 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, 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 +1444,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() @@ -1192,11 +1622,14 @@ 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, /// 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 +1646,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 +1658,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, 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 + /// 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, 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 + /// (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 +1687,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 +1800,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 +1826,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 +1843,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 }, @@ -1383,7 +1855,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); } @@ -1391,8 +1863,34 @@ 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); 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()?; @@ -1401,23 +1899,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; } @@ -1440,7 +1946,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 }, ); @@ -1509,6 +2015,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 +2043,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 +2073,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 +2116,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/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/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/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 c58ccd59e..0844a4335 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -79,6 +79,8 @@ mod fx_tone; mod fx_transition; mod fx_vr; mod fx_warp; +mod inpaint; +mod memcap; mod ops; mod particles; mod readback; @@ -397,7 +399,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 { @@ -415,16 +417,41 @@ 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) } + 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/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/shaders/common.wgsl b/crates/gpu/src/shaders/common.wgsl index 2c65c9d4a..1d8a701b6 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 { @@ -157,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/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 97db06c52..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: { @@ -611,7 +641,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, 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/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/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 0d27da63d..17747e5c4 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -52,10 +52,108 @@ 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; +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 @@ -784,7 +882,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 +1018,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); @@ -1347,3 +1445,485 @@ fn skipping_the_hdr_step_on_the_gpu_matches_clip() { assert!(img.data.iter().any(|p| p[3] > 0.0 && p[0] / p[3] > 1.0), "{mode:?}: over-range values survive"); } } + +/// 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"); +} + +/// #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/tests/fx_stylize.rs b/crates/gpu/src/tests/fx_stylize.rs index df99e1ced..d9ca95712 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))]); @@ -228,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). 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/gpu/src/walk.rs b/crates/gpu/src/walk.rs index 1b6687395..f688a6375 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -214,6 +214,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/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/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 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/interchange/src/import.rs b/crates/interchange/src/import.rs index 28f638a90..b7095ae1f 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}; @@ -156,18 +156,79 @@ 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(); + 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() }; 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) } +/// 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. +/// 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). +fn rebase_to_file_timecode(project: &mut fp::Project, probe: &mut dyn FnMut(&str) -> Option) -> Vec { + 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)); + } + } + let files: Vec<(fp::ItemId, String, String, bool)> = project + .items + .values() + .filter_map(|it| match &it.kind { + fp::ItemKind::Media(m) => match &m.media { + fp::MediaRef::File { path } => Some((it.id, it.name.clone(), path.clone(), m.offline)), + _ => None, + }, + _ => None, + }) + .collect(); + let mut left = Vec::new(); + for (id, name, path, offline) in files { + match if offline { None } else { probe(&path) } { + // 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), + _ => {} + } + } + left.sort(); + left.dedup(); + left +} + 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..7fec3840b 100644 --- a/crates/interchange/src/tests.rs +++ b/crates/interchange/src/tests.rs @@ -365,6 +365,61 @@ 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()); + 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); +} + +/// 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(); diff --git a/crates/lottie/src/import.rs b/crates/lottie/src/import.rs index 6d7cc1f5a..53b1ba71c 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")).filter(|f| !f.trim().is_empty()) { + 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). @@ -305,7 +316,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..d26b3642d 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -520,6 +520,44 @@ 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", ""), 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] +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(); diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 6bae86f5b..1fc945aa1 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -198,16 +198,21 @@ 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() } } /// 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 +388,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() { 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/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); + } +} 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); 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/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![], } } diff --git a/crates/project/src/item.rs b/crates/project/src/item.rs index 1ef3d1f0a..ae66557a4 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.trim_end(), + }; + (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(); 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 52cd2be4a..f545a62ee 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> { @@ -96,6 +106,19 @@ 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), 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) {} + /// 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/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); diff --git a/crates/sam2/examples/gpu_matmul.rs b/crates/sam2/examples/gpu_matmul.rs index 1429ebf02..29d2bcee5 100644 --- a/crates/sam2/examples/gpu_matmul.rs +++ b/crates/sam2/examples/gpu_matmul.rs @@ -67,10 +67,10 @@ 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()); - let adapter = pollster::block_on( - instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }), - )?; + // 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 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()) }); 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/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..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. @@ -454,10 +456,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 +499,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..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 @@ -1382,10 +1407,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 @@ -1406,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()?; @@ -1418,8 +1458,15 @@ 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)) { - Ok(f) => f.map(|f| FrameImage::Gpu(Arc::new(f))), + let f = g.within_memory(|| g.render_display(&r, job.comp, job.t)); + effectcraft_engine::render::Accelerator::frame_end(g); + match 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 @@ -1432,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()); @@ -1452,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(); @@ -1483,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) @@ -1580,5 +1631,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 3716db953..9c85ebeba 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]); @@ -466,18 +477,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] diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index 5793225e3..ec952ccb0 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() }; @@ -98,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)] @@ -462,10 +476,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 +1122,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 +2231,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 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 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 { @@ -1966,13 +2321,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 +2382,7 @@ mod tests { } #[cfg(test)] -mod japanese_category_tests { +mod category_name_tests { use super::*; use std::collections::HashMap; @@ -2042,4 +2397,27 @@ mod japanese_category_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] + 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/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index dd00b4123..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. @@ -1034,7 +1036,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", "分割してプリコンポーズ"), 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/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..aa21823f5 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; @@ -323,12 +324,22 @@ 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"); + // 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)); - 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 +498,74 @@ 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:?}"); + } + + /// 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()); + 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] 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/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index 7e49d8a7d..b778a3999 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -469,13 +469,26 @@ 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) +} + +/// 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 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` @@ -490,14 +503,17 @@ 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: 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)); 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 +527,24 @@ 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)); + // 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/crypto_pick.rs b/crates/ui-egui/src/panels/crypto_pick.rs new file mode 100644 index 000000000..2310ba93b --- /dev/null +++ b/crates/ui-egui/src/panels/crypto_pick.rs @@ -0,0 +1,602 @@ +//! 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, 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, + /// 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, 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 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(); + 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/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index ac26bd0c1..d3ee74e58 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -446,7 +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; - modal_placed(ctx, "Composition Settings", vec2(600.0, 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/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/src/panels/effect_controls/panel.rs b/crates/ui-egui/src/panels/effect_controls/panel.rs index 122a6abf7..4b3306e3d 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( @@ -268,8 +266,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 { @@ -370,7 +376,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/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/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/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/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..a34009658 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; @@ -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) { @@ -1809,12 +1820,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()); @@ -1850,6 +1866,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; @@ -4215,6 +4232,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 +4266,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 +4692,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 +4732,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). @@ -4835,7 +4875,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 +4887,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 +4895,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/timeline/tests.rs b/crates/ui-egui/src/panels/timeline/tests.rs index ebcbfa8b9..5aa7cedc5 100644 --- a/crates/ui-egui/src/panels/timeline/tests.rs +++ b/crates/ui-egui/src/panels/timeline/tests.rs @@ -628,6 +628,52 @@ 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: a roving key's is its smaller dot. +#[test] +fn a_keys_glyph_rect_is_what_it_draws() { + 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.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, "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(); + 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(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/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index bb7b2d075..bf921535a 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])) @@ -216,11 +220,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); } @@ -826,9 +832,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); @@ -840,7 +846,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| { @@ -1064,6 +1072,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; @@ -1092,7 +1118,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 @@ -1100,7 +1128,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. @@ -1126,22 +1154,16 @@ 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 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, - 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, - } + 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)?; @@ -1166,8 +1188,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; @@ -1399,18 +1422,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, 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 = ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees(); - 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() })); + 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) 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, 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})); } } Gesture::Anchor { layer, start_anchor, start_pos, start, inv, l2p, own, anchor_only } => { @@ -1680,7 +1703,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 @@ -1901,12 +1926,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, project_segment, quad_intersects_box, 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}; use gesture::{Gesture, HANDLE, ROTO_HINT, pen_id, roi_drag, roi_handles, vertex_grab_radius}; use shapes::{create_shape, draw_shape_ghost, shape_drag}; diff --git a/crates/ui-egui/src/panels/viewer/geometry.rs b/crates/ui-egui/src/panels/viewer/geometry.rs index 847332bcf..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,6 +121,24 @@ pub(crate) fn layer_outline(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> { project_clipped(&l2c(ctx, layer).0, &box_corners(b)) } +/// 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 being flipped. +/// Otherwise 1. +pub(super) fn rotation_sign(ctx: &EvalCtx, layer: &Layer) -> f64 { + 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) /// 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..72ef22a84 100644 --- a/crates/ui-egui/src/panels/viewer/gesture.rs +++ b/crates/ui-egui/src/panels/viewer/gesture.rs @@ -34,15 +34,20 @@ 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). + /// 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 /// 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..066df8c3f 100644 --- a/crates/ui-egui/src/panels/viewer/tests.rs +++ b/crates/ui-egui/src/panels/viewer/tests.rs @@ -140,6 +140,41 @@ 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, s.active_comp_id().unwrap(), comp, Tick::ZERO)) +} + +/// #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() { + 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/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 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/main.rs b/crates/ui-egui/tests/it/main.rs index 2de7ebee7..67a394059 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; @@ -91,6 +93,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_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)); diff --git a/crates/ui-egui/tests/it/ui_comp_presets.rs b/crates/ui-egui/tests/it/ui_comp_presets.rs index 9e71f8fd7..262d2183b 100644 --- a/crates/ui-egui/tests/it/ui_comp_presets.rs +++ b/crates/ui-egui/tests/it/ui_comp_presets.rs @@ -103,3 +103,96 @@ 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:?}"); + } +} + +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()); + } +} 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..0d1767c7e --- /dev/null +++ b/crates/ui-egui/tests/it/ui_crypto_pick.rs @@ -0,0 +1,1942 @@ +//! 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. +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. +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)); + 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 +} + +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) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + h.run_steps(4); +} + +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). +pub(crate) 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); +} + +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) +} + +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. +/// 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. 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"); + 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); + 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); + 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 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 (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, &hold, &log); + 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 (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() { + 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)] { + 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), ""); + 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"); + 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). +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") { + 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) + } +} + +/// 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] +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..22aaeb9a5 --- /dev/null +++ b/crates/ui-egui/tests/it/ui_crypto_pick_undo.rs @@ -0,0 +1,1317 @@ +//! 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 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_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, 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"; +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: session / populate / harness (shared EXR helpers from ui_crypto_pick.rs). + +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, 1); + 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 highlight(h: &Harness<'_, EffectcraftApp>) -> bool { + h.state().auto.find("viewer.cryptoPick.highlight").is_some() +} + +/// 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(); + 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); +} + +/// 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"); + } + 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()); + 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 { + 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; 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 (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; + 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); + 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(); + 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_held_id_read(&mut h, &log); + 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"); + // 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 (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; + 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); + 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"); + 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); + 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"); + + // (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.) + // 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(); + 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); +} 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])) 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_scrolling.rs b/crates/ui-egui/tests/it/ui_scrolling.rs index 2e0f32ac8..2913a6770 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)) { + 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) { + assert!(!at.intersects(lane), "{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] 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)"); + } +} 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..7b32fa2ec 100644 --- a/crates/ui-egui/tests/it/ui_viewer.rs +++ b/crates/ui-egui/tests/it/ui_viewer.rs @@ -420,6 +420,42 @@ 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. +#[test] +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). + 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:?}"); + } +} + /// 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] @@ -1657,6 +1693,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) { @@ -1771,6 +1843,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() }); @@ -2890,6 +3022,133 @@ 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: 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] diff --git a/docs/agents.md b/docs/agents.md index 34162aacd..cf4bcf829 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 @@ -751,6 +753,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 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..3406f365d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -65,14 +65,14 @@ 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) | | 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/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 | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index d535f78d3..b0a87120d 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 @@ -68,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. @@ -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,18 +93,18 @@ 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,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 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,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). @@ -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 | 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 |