diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index c49ed26fc..2981519e9 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -48,6 +48,7 @@ use std::io::Write; +mod project_lock; mod template; use effectcraft_automation::tools::{self, Reply}; @@ -515,6 +516,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)?; @@ -618,6 +620,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) { @@ -661,6 +664,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 @@ -697,7 +701,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)); } } } @@ -715,6 +719,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}); @@ -1548,6 +1553,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 dbc27b5e7..363eb8e52 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/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/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/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/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/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/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/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/src/encode.rs b/crates/export/src/encode.rs index c9888a035..dd972d326 100644 --- a/crates/export/src/encode.rs +++ b/crates/export/src/encode.rs @@ -19,14 +19,41 @@ fn mux_err(e: impl std::fmt::Display) -> ExportError { ExportError::Io(e.to_string()) } -/// Tag a video [`SampleEntry`] with the BT.709 limited-range `colr` (nclx) box every video -/// encoder here outputs (`rgba_to_yuv420`, `rgba_to_yuv_10` and `rgba_to_yuv420_bits` all use -/// the 0.2126/0.7152/0.0722 luma and a 16–235 luma range). Without it a re-import falls back to -/// the container's resolution-based `default_matrix` — BT.601 below 1024×577 — and shifts the +/// What a render's frames are, as ITU-T H.273 code points, for the `colr` box and the encoders' +/// own colour description. The matrix is BT.709, the one every encoder here converts with +/// (`rgba_to_yuv420`, `rgba_to_yuv_10` and `rgba_to_yuv420_bits` all use the 0.2126/0.7152/0.0722 +/// luma). Primaries and transfer follow the render path (`Pipe::output`): only a working space +/// converts to the output space, so unmanaged frames pass through. sRGB output keeps transfer 1 +/// rather than 13 (#340), and an alpha matte is not in the output space. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Codes { + pub(crate) primaries: u8, + pub(crate) transfer: u8, + pub(crate) matrix: u8, +} + +pub(crate) fn output_codes(settings: &ProjectSettings, channels: Channels) -> Codes { + use effectcraft_project::ColorSpace as Space; + let output = settings.working_space.and(settings.output_space).filter(|_| channels != Channels::Alpha); + let (primaries, transfer) = match output { + None | Some(Space::Srgb | Space::Rec709) => (1, 1), + Some(Space::Rec2020) => (9, 1), + Some(Space::DisplayP3) => (12, 13), + Some(Space::Rec2100Pq) => (9, 16), + Some(Space::Rec2100Hlg) => (9, 18), + // H.273 has no code for the ACES primaries: unspecified, in linear light. + Some(Space::AcesCg | Space::Aces2065) => (2, 8), + }; + Codes { primaries, transfer, matrix: 1 } +} + +/// Tag a video [`SampleEntry`] with the limited-range (16–235 luma, as every encoder here +/// writes) `colr` (nclx) box for `c` (#2879). Without it a re-import falls back to the +/// container's resolution-based `default_matrix` — BT.601 below 1024×577 — and shifts the /// colours (pure red came back as [232,0,1]). -pub(crate) fn tag_bt709_limited(entry: &mut SampleEntry) { +pub(crate) fn tag_nclx_limited(entry: &mut SampleEntry, c: Codes) { if let Some(v) = entry.video.as_mut() { - v.color = Some(ColorInfo::Nclx { primaries: 1, transfer: 1, matrix: 1, full_range: false }); + v.color = Some(ColorInfo::Nclx { primaries: c.primaries.into(), transfer: c.transfer.into(), matrix: c.matrix.into(), full_range: false }); } } @@ -94,21 +121,23 @@ struct H264 { w: u32, h: u32, rate: FrameRate, + codes: Codes, y: Vec, u: Vec, v: Vec, } impl H264 { - fn new(w: u32, h: u32, rate: FrameRate, kbps: u32) -> Result { + fn new(w: u32, h: u32, rate: FrameRate, kbps: u32, codes: Codes) -> Result { let mut cfg = filmcraft_h264enc::EncoderConfig::new(w, h, rate.num as u32, rate.den as u32); cfg.format = filmcraft_h264enc::PacketFormat::LengthPrefixed; cfg.aud = false; cfg.keyint = (rate.as_f64() * 2.0).round().max(1.0) as u32; let kbps = kbps.max(100); cfg.rate = filmcraft_h264enc::RateControl::Vbr { target_kbps: kbps, max_kbps: kbps * 3 / 2 }; + cfg.color = filmcraft_h264enc::ColorConfig { primaries: codes.primaries, transfer: codes.transfer, matrix: codes.matrix, full_range: false }; let enc = filmcraft_h264enc::Encoder::new(cfg).map_err(enc)?; - Ok(H264 { enc, w, h, rate, y: vec![], u: vec![], v: vec![] }) + Ok(H264 { enc, w, h, rate, codes, y: vec![], u: vec![], v: vec![] }) } fn packets(&self, ps: Vec) -> Vec { ps.into_iter().map(|p| Packet { data: p.data, key: p.keyframe, cto: (p.pts - p.dts) as i32 }).collect() @@ -122,7 +151,7 @@ impl VideoEncoder for H264 { filmcraft_isobmff::AvcConfig::new(vec![sps], vec![pps], 4) }); let mut entry = SampleEntry::avc(cfg, self.w as u16, self.h as u16); - tag_bt709_limited(&mut entry); + tag_nclx_limited(&mut entry, self.codes); entry } fn encode(&mut self, rgba: &[u8], index: u64) -> Result> { @@ -167,25 +196,10 @@ struct ProRes { colr: ColorInfo, } -/// The `colr` atom of a ProRes render: what its frames are, in H.273 code points. MOV uses -/// QuickTime's `nclc`, which has no range flag; ProRes frames are limited range. The matrix is the -/// one [`rgba_to_yuv_10`] always uses. Primaries and transfer follow the render path -/// (`Pipe::output`): only a working space converts to the output space, so unmanaged frames pass -/// through. sRGB output keeps transfer 1 rather than 13 (#340), and an alpha matte is not in the -/// output space. -fn prores_colr(settings: &ProjectSettings, channels: Channels) -> ColorInfo { - use effectcraft_project::ColorSpace as Space; - let output = settings.working_space.and(settings.output_space).filter(|_| channels != Channels::Alpha); - let (primaries, transfer) = match output { - None | Some(Space::Srgb | Space::Rec709) => (1, 1), - Some(Space::Rec2020) => (9, 1), - Some(Space::DisplayP3) => (12, 13), - Some(Space::Rec2100Pq) => (9, 16), - Some(Space::Rec2100Hlg) => (9, 18), - // H.273 has no code for the ACES primaries: unspecified, in linear light. - Some(Space::AcesCg | Space::Aces2065) => (2, 8), - }; - ColorInfo::Nclc { primaries, transfer, matrix: 1 } +/// The `colr` atom of a ProRes render ([`output_codes`]). MOV uses QuickTime's `nclc`, which has +/// no range flag; ProRes frames are limited range. +fn prores_colr(c: Codes) -> ColorInfo { + ColorInfo::Nclc { primaries: c.primaries.into(), transfer: c.transfer.into(), matrix: c.matrix.into() } } fn prores_profile(p: ProResProfile) -> filmcraft_prores::Profile { @@ -328,15 +342,13 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St Channels::Rgba if !matches!(fmt, OutputFormat::ProRes | OutputFormat::Uncompressed) => Channels::Rgb, c => c, }; + let codes = output_codes(&job.project.settings, channels); let mut venc: Box = match fmt { - OutputFormat::H264 => Box::new(H264::new(w, h, rate, job.output.bitrate_kbps)?), - OutputFormat::Hevc => Box::new(crate::hevc_av1::Hevc::new(w, h, rate, job.output)?), - OutputFormat::Av1 => Box::new(crate::hevc_av1::Av1::new(w, h, rate, job.output)?), + OutputFormat::H264 => Box::new(H264::new(w, h, rate, job.output.bitrate_kbps, codes)?), + OutputFormat::Hevc => Box::new(crate::hevc_av1::Hevc::new(w, h, rate, job.output, codes)?), + OutputFormat::Av1 => Box::new(crate::hevc_av1::Av1::new(w, h, rate, job.output, codes)?), OutputFormat::Uncompressed => Box::new(crate::uncompressed::Raw::new(w, h, channels == Channels::Rgba)?), - _ => { - let colr = prores_colr(&job.project.settings, channels); - Box::new(ProRes::new(w, h, job.output.prores_profile, if channels == Channels::Alpha { Channels::Rgb } else { channels }, colr)) - } + _ => Box::new(ProRes::new(w, h, job.output.prores_profile, if channels == Channels::Alpha { Channels::Rgb } else { channels }, prores_colr(codes))), }; let brand = if matches!(fmt, OutputFormat::ProRes | OutputFormat::Uncompressed) { Brand::Mov } else { Brand::Mp4 }; let batch = batch_size(); diff --git a/crates/export/src/hevc_av1.rs b/crates/export/src/hevc_av1.rs index c363dff35..d43b1c5ed 100644 --- a/crates/export/src/hevc_av1.rs +++ b/crates/export/src/hevc_av1.rs @@ -7,7 +7,7 @@ use effectcraft_time::FrameRate; use filmcraft_isobmff::{Av1Config, CodecConfig, FourCc, HevcConfig, SampleEntry}; use rayon::prelude::*; -use crate::encode::{Packet, VideoEncoder}; +use crate::encode::{Codes, Packet, VideoEncoder}; use crate::{ExportError, Result}; fn enc_err(e: impl std::fmt::Display) -> ExportError { @@ -59,6 +59,7 @@ pub(crate) struct Hevc { w: u32, h: u32, bits: u8, + codes: Codes, planes: Planes, } @@ -81,11 +82,12 @@ pub(crate) fn hevc_config(w: u32, h: u32, rate: FrameRate, om: &OutputModule) -> } impl Hevc { - pub(crate) fn new(w: u32, h: u32, rate: FrameRate, om: &OutputModule) -> Result { - let cfg = hevc_config(w, h, rate, om); + pub(crate) fn new(w: u32, h: u32, rate: FrameRate, om: &OutputModule, codes: Codes) -> Result { + let mut cfg = hevc_config(w, h, rate, om); + (cfg.colour_primaries, cfg.transfer_characteristics, cfg.matrix_coeffs) = (codes.primaries, codes.transfer, codes.matrix); let bits = cfg.profile.bit_depth(); let enc = effectcraft_hevcenc::Encoder::new(cfg).map_err(enc_err)?; - Ok(Hevc { enc, w, h, bits, planes: Planes::default() }) + Ok(Hevc { enc, w, h, bits, codes, planes: Planes::default() }) } } @@ -93,7 +95,7 @@ impl VideoEncoder for Hevc { fn sample_entry(&self) -> SampleEntry { let cfg = HevcConfig::parse(&self.enc.hvcc()).unwrap_or_default(); let mut entry = SampleEntry::hevc(cfg, self.w as u16, self.h as u16); - crate::encode::tag_bt709_limited(&mut entry); + crate::encode::tag_nclx_limited(&mut entry, self.codes); entry } fn encode(&mut self, rgba: &[u8], _index: u64) -> Result> { @@ -116,6 +118,7 @@ pub(crate) struct Av1 { w: u32, h: u32, bits: u8, + codes: Codes, planes: Planes, } @@ -135,11 +138,12 @@ pub(crate) fn av1_config(w: u32, h: u32, rate: FrameRate, om: &OutputModule) -> } impl Av1 { - pub(crate) fn new(w: u32, h: u32, rate: FrameRate, om: &OutputModule) -> Result { - let cfg = av1_config(w, h, rate, om); + pub(crate) fn new(w: u32, h: u32, rate: FrameRate, om: &OutputModule, codes: Codes) -> Result { + let mut cfg = av1_config(w, h, rate, om); + (cfg.color_primaries, cfg.transfer_characteristics, cfg.matrix_coefficients) = (codes.primaries, codes.transfer, codes.matrix); let bits = cfg.bit_depth; let enc = effectcraft_av1enc::Encoder::new(cfg).map_err(enc_err)?; - Ok(Av1 { enc, w, h, bits, planes: Planes::default() }) + Ok(Av1 { enc, w, h, bits, codes, planes: Planes::default() }) } /// The `av1C` record (also the WebM CodecPrivate). pub(crate) fn av1c(&self) -> Vec { @@ -151,7 +155,7 @@ impl VideoEncoder for Av1 { fn sample_entry(&self) -> SampleEntry { let cfg = Av1Config::parse(&self.enc.av1c()).unwrap_or_default(); let mut entry = SampleEntry::video(FourCc(*b"av01"), CodecConfig::Av1(cfg), self.w as u16, self.h as u16); - crate::encode::tag_bt709_limited(&mut entry); + crate::encode::tag_nclx_limited(&mut entry, self.codes); entry } fn encode(&mut self, rgba: &[u8], _index: u64) -> Result> { diff --git a/crates/export/src/webm_av1.rs b/crates/export/src/webm_av1.rs index ca06a3e51..a85a5c9ec 100644 --- a/crates/export/src/webm_av1.rs +++ b/crates/export/src/webm_av1.rs @@ -42,7 +42,8 @@ fn cluster(blocks: &mut [Block]) -> Vec { pub(crate) async fn webm_av1(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut State<'_>) -> Result { let rate = job.settings.rate(comp); - let mut venc = crate::hevc_av1::Av1::new(w, h, rate, job.output)?; + let codes = crate::encode::output_codes(&job.project.settings, job.output.channels); + let mut venc = crate::hevc_av1::Av1::new(w, h, rate, job.output, codes)?; let with_audio = wants_audio(job); let opus_channels = if job.output.audio_channels == 1 { 1usize } else { 2 }; let mut opus = with_audio.then(|| opus_encoder(job)); diff --git a/crates/export/tests/it/main.rs b/crates/export/tests/it/main.rs index a5b9fa99a..f7bfa7a1c 100644 --- a/crates/export/tests/it/main.rs +++ b/crates/export/tests/it/main.rs @@ -7,6 +7,7 @@ mod hevc_av1_roundtrip; #[cfg(not(target_arch = "wasm32"))] mod mov_audio_sample_positions; mod movie_bytes; +mod movie_color; mod ms_rounded_footage; #[cfg(not(target_arch = "wasm32"))] mod native_audio_sample_positions; diff --git a/crates/export/tests/it/movie_color.rs b/crates/export/tests/it/movie_color.rs new file mode 100644 index 000000000..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 bba9fc2bd..7cec5e1e1 100644 --- a/crates/export/tests/it/roundtrip.rs +++ b/crates/export/tests/it/roundtrip.rs @@ -311,7 +311,8 @@ fn exr_sequence_writes_scan_lines() { let om = OutputModule::for_format(OutputFormat::ExrSequence); run(&p, cid, &NoFootage, &om, &d.join("f_[#####].exr")); let bytes = std::fs::read(d.join("f_00000.exr")).expect("exr"); - // Version field: 2, then the flags byte whose 0x02 bit marks a single-part tiled file. + // Version field (little-endian u32 at bytes 4..8): version 2, then the flags byte. Its 0x02 + // bit is the tiled bit, 0x200 of the field, so a scan-line file has 0 there. assert_eq!(&bytes[4..6], &[2, 0], "{:02x?}", &bytes[..8]); let meta = exr::meta::MetaData::read_from_buffered(std::io::Cursor::new(&bytes), false).expect("exr header"); assert_eq!(meta.headers[0].blocks, exr::meta::BlockDescription::ScanLines); diff --git a/crates/gpu/src/shaders/fx_stylize.wgsl b/crates/gpu/src/shaders/fx_stylize.wgsl index 97db06c52..7fe42df7a 100644 --- a/crates/gpu/src/shaders/fx_stylize.wgsl +++ b/crates/gpu/src/shaders/fx_stylize.wgsl @@ -611,7 +611,9 @@ fn fxs_motiontile(@builtin(global_invocation_id) gid: vec3) { fy = 1.0 - fy; } } - textureStore(out, p, sample_bilinear(src, fx * P.f[0].y + P.f[1].x, fy * P.f[0].z + P.f[1].y)); + // Clamped for the same reason as the CPU path: a fold on a pixel centre samples the + // source's geometric edge, where the plain sampler mixes in the transparent outside. + textureStore(out, p, sample_bilinear_clamped(src, fx * P.f[0].y + P.f[1].x, fy * P.f[0].z + P.f[1].y)); } // CC RepeTile (stylize2::repetile) into a new output size. data: per output column (tile index, 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/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/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..87cd9a17b 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -1580,5 +1580,13 @@ impl Queue { } } +/// The render scale a viewer frame's key stands for: it keeps thousandths, but the viewer +/// renders every n-th pixel, and 0.333 is not 1/3 (2 pixels off by row 1000 at Third). The +/// comp's own factor decides when two factors share the thousandths (from 1/34). +pub(crate) fn render_scale(thousandths: u32, factor: Option) -> f64 { + let n = factor.map(f64::from).filter(|n| (1000.0 / n).round() == f64::from(thousandths)); + 1.0 / n.unwrap_or_else(|| (1000.0 / f64::from(thousandths.max(1))).round()).max(1.0) +} + #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/frames/tests.rs b/crates/ui-egui/src/frames/tests.rs index d04793f50..692df16f7 100644 --- a/crates/ui-egui/src/frames/tests.rs +++ b/crates/ui-egui/src/frames/tests.rs @@ -1,5 +1,16 @@ use super::*; +#[test] +fn render_scales_are_the_factors_the_thousandths_stand_for() { + assert_eq!(render_scale(333, Some(3)), 1.0 / 3.0); + assert_eq!(render_scale(333, None), 1.0 / 3.0); + assert_eq!(render_scale(167, Some(6)), 1.0 / 6.0); + assert_eq!((render_scale(1000, None), render_scale(500, None), render_scale(250, Some(4))), (1.0, 0.5, 0.25)); + // 1/34 and 1/35 are both 29 thousandths: the comp's factor decides. + assert_eq!((render_scale(29, Some(35)), render_scale(29, Some(34))), (1.0 / 35.0, 1.0 / 34.0)); + assert_eq!(render_scale(29, Some(2)), 1.0 / 34.0); +} + #[test] fn converting_a_frame_with_nan_alpha_keeps_the_viewer_alive() { let image = effectcraft_engine::render::Image::filled(1, 1, [0.5, 0.25, 0.1, f32::NAN]); diff --git a/crates/ui-egui/src/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 cb8979b38..162fae813 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. @@ -1038,7 +1040,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 de26638f9..6c5963908 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -872,6 +872,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 @@ -883,7 +887,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; @@ -895,12 +899,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); @@ -1037,7 +1036,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/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 3bba23932..8a9356c02 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -483,7 +483,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 @@ -759,6 +761,14 @@ effectcraft-cli render main.ecproj --comp Main --out main.mov --format prores -- effectcraft-cli render main.ecproj --comp Main --out 'main_[#####].exr' --exr-compression piz --exr-depth half # half-float PIZ OpenEXR frames ``` +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/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 |