Merge remote-tracking branch 'upstream/main' into m2915-merge

This commit is contained in:
vangel76 committed 2026-11-03 19:08:37 +01:00
commit 4aaf9c4bb4
110 files changed
+7835 -425

No files matched your search

+16 -1
View File
@@ -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::*;
+150
View File
@@ -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<Option<&Path>, 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<File> {
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<Option<File>, 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));
}
}
+199 -1
View File
@@ -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<String> = (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", &params, "--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 <subcommand>: ` 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())
}
+21 -4
View File
@@ -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
+27 -5
View File
@@ -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<usize> = (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();
+54 -7
View File
@@ -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", &params, 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]);
+50 -13
View File
@@ -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);
+18
View File
@@ -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");
+100 -10
View File
@@ -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<Image>, 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<Arc<Image>>,
cancel: &dyn Fn() -> bool,
) -> Option<(Vec<f32>, Vec<u8>)> {
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<Arc<FrozenCache>> {
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<f32>, Vec<u8>)> {
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(&params).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<f32> = (0..100 * 80).map(|i| f32::from(u8::from(i % 100 < 50))).collect();
let mut fc = FrozenCache { key: frozen_key_with(&params, 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(&params, size, 25.0));
params.values.insert(FROZEN.into(), Value::Str(fc.to_json()));
let host = Seq;
let ctx = EffectCtx {
params: &params,
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(&params, size, 25.0).expect("the frozen mattes apply");
let (matte, band) = frame_matte(&params, 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(&params, 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<Vec<Vec<usize>>>);
+36 -1
View File
@@ -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));
+151 -3
View File
@@ -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>) -> 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::<f64>() * 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>, 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<TimewarpPlan> {
/// 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<LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn self_at(&self, t: f64, _: usize) -> Option<Buf> {
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<Value> {
(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.
+3 -1
View File
@@ -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];
+2 -2
View File
@@ -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;
+9 -1
View File
@@ -555,7 +555,15 @@ pub fn specs() -> Vec<CommandSpec> {
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),
]
+7 -2
View File
@@ -246,13 +246,18 @@ fn status(s: &mut Session, p: &Value) -> Result<Value> {
out["deleted"] = json!(settings.deleted.len());
out["lensDistortionSolved"] = json!(settings.lens_distortion);
let solve = ct::solve(&params);
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());
+33
View File
@@ -500,6 +500,39 @@ pub(crate) fn crypto_probe_job(s: &Session, p: &Value) -> Result<Option<CryptoPr
})))
}
/// [`Session::crypto_coverage`].
pub(crate) fn crypto_coverage(s: &Session, p: &Value, hash: u32, max_side: usize) -> Option<CryptoCoverage> {
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::<f32>().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<f32>,
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.
+18 -1
View File
@@ -201,6 +201,11 @@ pub(super) fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
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];
+74 -24
View File
@@ -171,9 +171,29 @@ fn kind_p(p: &Value, cmd: &str) -> Result<PrimitiveKind> {
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<usize> {
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<Vec<&'static str>> {
let mut set = Vec::new();
let tri = |v: &Value| -> Option<u32> {
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<usize>
_ => 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::<Vec<_>>().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<Value> {
@@ -273,19 +320,22 @@ fn material_set(s: &mut Session, p: &Value) -> Result<Value> {
}
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<CommandSpec> {
"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
),
+32 -4
View File
@@ -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<Tick> {
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<f32> = 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, &params, at) || puppet::overlay(&buf, &params).iter().any(|(_, mesh, def, _)| on_mesh(mesh, def, at))
}
fn add_pin(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
{
return Err(bad(cmd, format!("no mesh {w}")));
}
if target.is_none() && repeats_mesh(&buf, &params, at) {
return Ok(json!({"pin": null, "changed": false}));
}
}
let opts = MeshOpts {
density: f_p(p, "density").unwrap_or(s.state.puppet.density),
+11 -6
View File
@@ -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<u64> {
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<u64> {
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<CommandSpec> {
"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<CommandSpec> {
"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<CommandSpec> {
"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
),
+7
View File
@@ -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::CryptoCoverage> {
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) {
+17
View File
@@ -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);
+103 -1
View File
@@ -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<Arc<AuxChannels>>);
impl FootageSource for Ranked {
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
Some(Arc::new(Image::new(f.width, f.height)))
}
fn aux(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<AuxChannels>> {
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<f32> = (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::<f64>().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
);
}
}
+30
View File
@@ -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<String> = 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"]);
}
+37
View File
@@ -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.
+65
View File
@@ -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();
+32
View File
@@ -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();
+56
View File
@@ -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();
+12 -11
View File
@@ -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<Snap> {
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<Sn
}
}
}
let line_best = [best_x.map(|b| b.0.0), best_y.map(|b| b.0.0)].into_iter().flatten().fold(f64::INFINITY, f64::min);
let both_lines = best_x.zip(best_y).map_or(f64::INFINITY, |(x, y)| x.0.0.hypot(y.0.0));
if let Some((k, s, t)) = best_pt
&& k.0 <= line_best + 1e-9
&& k.0 <= both_lines + 1e-9
{
return Some(Snap { delta: [t.pos[0] - s[0], t.pos[1] - s[1]], hits: vec![t], at: t.pos });
}
@@ -366,8 +367,8 @@ fn visible_layers<'a>(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));
}
}
+47 -35
View File
@@ -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<u8>,
u: Vec<u8>,
v: Vec<u8>,
}
impl H264 {
fn new(w: u32, h: u32, rate: FrameRate, kbps: u32) -> Result<H264> {
fn new(w: u32, h: u32, rate: FrameRate, kbps: u32, codes: Codes) -> Result<H264> {
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<filmcraft_h264enc::Packet>) -> Vec<Packet> {
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<Vec<Packet>> {
@@ -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<dyn VideoEncoder> = 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();
+13 -9
View File
@@ -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<Hevc> {
let cfg = hevc_config(w, h, rate, om);
pub(crate) fn new(w: u32, h: u32, rate: FrameRate, om: &OutputModule, codes: Codes) -> Result<Hevc> {
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<Vec<Packet>> {
@@ -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<Av1> {
let cfg = av1_config(w, h, rate, om);
pub(crate) fn new(w: u32, h: u32, rate: FrameRate, om: &OutputModule, codes: Codes) -> Result<Av1> {
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<u8> {
@@ -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<Vec<Packet>> {
+2 -1
View File
@@ -42,7 +42,8 @@ fn cluster(blocks: &mut [Block]) -> Vec<u8> {
pub(crate) async fn webm_av1(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut State<'_>) -> Result<Report> {
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));
+1
View File
@@ -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;
+95
View File
@@ -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<ColorSpace>, [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<ColorSpace>, 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<Vec<String>> {
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"),
}
}
}
}
+48 -8
View File
@@ -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");
}
}
+2 -1
View File
@@ -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);
+3 -1
View File
@@ -611,7 +611,9 @@ fn fxs_motiontile(@builtin(global_invocation_id) gid: vec3<u32>) {
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,
+10
View File
@@ -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,
};
+6 -3
View File
@@ -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
+64 -3
View File
@@ -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<String, Option<Footage>> = 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<Footage>) -> Vec<String> {
let mut on_timecode: HashSet<fp::ItemId> = 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,
+55
View File
@@ -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();
+13 -1
View File
@@ -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);
+6 -3
View File
@@ -27,7 +27,9 @@ pub(crate) fn path_value(v: &Value) -> Json {
}
}
pub(crate) fn to_path(j: &Json) -> Option<Value> {
/// 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<Value> {
let o = match j {
Json::Array(a) => a.first()?,
o => o,
@@ -48,7 +50,7 @@ pub(crate) fn to_path(j: &Json) -> Option<Value> {
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<Pr
"sh" => {
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" => {
+38
View File
@@ -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();
+25 -1
View File
@@ -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<effectcraft_project::ColorSpace> {
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() {
+38
View File
@@ -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<u32>) -> Vec<u8> {
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);
}
+1
View File
@@ -3,6 +3,7 @@
mod aac_layout;
mod downmix;
mod exif_orientation;
mod file_timecode;
mod footage;
mod open_in_place;
mod tga;
+91 -23
View File
@@ -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<i32>);
/// 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<Seg>, tidy: &Tidy) -> Vec<Seg> {
@@ -284,7 +307,9 @@ fn tidy_segments(segs: Vec<Seg>, tidy: &Tidy) -> Vec<Seg> {
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<Seg> = (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);
}
}
+78 -1
View File
@@ -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<f64> {
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);
+4 -6
View File
@@ -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() }
}
}
+9 -2
View File
@@ -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<i64>,
/// 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<i64>,
/// 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![],
}
}
+17 -6
View File
@@ -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.
+27
View File
@@ -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();
+4 -1
View File
@@ -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);
+16
View File
@@ -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<f64> = 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);
+4 -4
View File
@@ -67,10 +67,10 @@ fn main(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_id) li:
"#;
fn main() -> Result<(), Box<dyn std::error::Error>> {
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()) });
+35 -9
View File
@@ -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<std::result::Result<Arc<Gpu>, 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<String> {
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<Arc<Gpu>> {
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<Gpu> {
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"),
+35
View File
@@ -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"));
}
+1
View File
@@ -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,
+20 -3
View File
@@ -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::<f64>(egui::Id::unique("status-since")));
ui.data_mut(|d| d.remove::<(f64, String)>(key));
}
}
}
+3 -2
View File
@@ -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);
+8
View File
@@ -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<u8>) -> 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;
+11
View File
@@ -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]);
+411 -33
View File
@@ -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<Item = char> {
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}`");
}
}
}
}
+6 -4
View File
@@ -145,11 +145,13 @@ fn string_literal(rest: &str) -> Option<String> {
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<char> {
catalog(language).into_iter().flat_map(|c| c.values()).flat_map(|s| s.chars()).collect::<std::collections::BTreeSet<_>>().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::<std::collections::BTreeSet<_>>().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", "分割してプリコンポーズ"),
+23
View File
@@ -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]
+2
View File
@@ -312,6 +312,8 @@ pub struct EffectcraftApp {
pub(crate) viewer_native: Option<(egui::TextureId, FrameKey, Arc<effectcraft_gpu::DisplayFrame>)>,
/// 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<std::sync::atomic::AtomicBool>,
/// Last displayed image (Info panel colour, eyedroppers, histograms). GPU frames fill it on
/// demand ([`EffectcraftApp::viewer_pixels`]).
pub(crate) viewer_image: Option<Arc<egui::ColorImage>>,
+83 -4
View File
@@ -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::<f32>(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::<OpenPanelMenu>(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::<OpenPanelMenu>(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]
+30 -5
View File
@@ -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(&params);
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()))
}
+39 -10
View File
@@ -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::<f32>(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<serde_json::Value> {
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<egui::Vec2> = [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| {
+602
View File
@@ -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<Value>,
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<Result<Value, String>>,
/// Clicks waiting for their probe, in order, and the project revision they were made at.
pending: Vec<Value>,
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<Press>,
/// 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<HighlightMask>,
}
/// Clears the busy flag and repaints when a build ends, a panic included.
struct Done(Arc<AtomicBool>, 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<bool, String> {
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::<u64>(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::<State>(sid)).is_some() {
ctx.data_mut(|d| d.remove::<State>(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::<State>(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<Value, String> {
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<Value>,
) {
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<Option<(egui::Pos2, bool, egui::Modifiers)>> = 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<Value>,
) -> 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::<Vec<_>>().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));
}
}
+1 -1
View File
@@ -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;
@@ -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,
@@ -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<PropGroup> = 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);
@@ -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);
@@ -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);
+2 -1
View File
@@ -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;
+2 -1
View File
@@ -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.
+2 -1
View File
@@ -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() {
+6 -6
View File
@@ -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::<usize>(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);
+19 -4
View File
@@ -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::<bool>(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<ItemId> = 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));
+3 -1
View File
@@ -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![];
+10 -2
View File
@@ -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<LayerId> {
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) {
+5 -7
View File
@@ -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 {
+5 -3
View File
@@ -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<serde_json::Value> = vec![];
+76 -34
View File
@@ -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<effectcraft_engine::KeyRef> {
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::<Rect>(egui::Id::unique("tl-box")))
{
ctx.data_mut(|d| d.remove::<Rect>(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<serde_json::Value> = 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);
}
}
}
@@ -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<Row> = (1..=6).map(|id| Row { layer: LayerId(id), depth: 0, kind: RowKind::Layer }).collect();
+64 -39
View File
@@ -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<Pos2> {
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::Pos2>(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::<Vec<(u64, String)>>(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::<Gesture>(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::<Gesture>(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};
@@ -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] {
+9 -4
View File
@@ -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,
+35
View File
@@ -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<T>(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() {
+92 -5
View File
@@ -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<f32>,
/// 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<f64> {
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::<Vec<_>>()
};
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::<Vec<_>>(), [full; 3]);
}
fn checkbox_frame(ctx: &egui::Context, values: &mut [Option<bool>; 5], events: Vec<egui::Event>) {
let t = Tokens::for_kind(Default::default());
let input = egui::RawInput { events, ..Default::default() };
+3
View File
@@ -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;
@@ -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));
@@ -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());
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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<u64> = 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::<std::collections::BTreeSet<_>>();
fx.iter().enumerate().filter(|(_, u)| picked.contains(u)).map(|(i, _)| i).collect::<Vec<_>>()
};
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]))
+41
View File
@@ -42,6 +42,47 @@ fn keys(h: &Harness<'_, EffectcraftApp>, uid: u64) -> Vec<Pos2> {
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));
+129
View File
@@ -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<u64> = 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<Rect>) -> Option<f32> {
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<f32>); 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]
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff. Show more