mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-06 02:20:50 +00:00
Merge remote-tracking branch 'upstream/main' into m2915-merge
This commit is contained in:
commit
4aaf9c4bb4
110 files changed
+7835
-425
No files matched your search
@@ -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::*;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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", ¶ms, "--project", &proj][..], save].concat();
|
||||
let mut child = start_waiting_run(&args);
|
||||
assert_eq!(std::fs::read(&proj).unwrap(), before, "{save:?} wrote while another run held the lock");
|
||||
// an edit made meanwhile by the holder must survive: the waiter reads only after the lock is free
|
||||
let held_params = json!({"name": format!("held-{}", save.len())}).to_string();
|
||||
ok_json(&["exec", "project.newFolder", &held_params, "--project", &proj, "--save-as", &format!("{proj}.tmp")]);
|
||||
std::fs::rename(format!("{proj}.tmp"), &proj).unwrap();
|
||||
drop(held);
|
||||
assert!(child.wait().unwrap().success(), "{save:?}");
|
||||
for want in [format!("waited-{}", save.len()), format!("held-{}", save.len())] {
|
||||
assert!(has_folder(&proj, &want), "{save:?} lost {want}");
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Runs through a symlink take the same lock before and after a save, or a run still waiting on
|
||||
/// the old one could overwrite a later run's save.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_symlinked_project_keeps_its_lock_after_a_save() {
|
||||
let (dir, proj) = lock_test_project("symlink");
|
||||
let link = dir.join("link.ecproj");
|
||||
std::os::unix::fs::symlink(&proj, &link).unwrap();
|
||||
for name in [r#"{"name":"first"}"#, r#"{"name":"second"}"#] {
|
||||
let held = hold_lock(&dir, "link.ecproj");
|
||||
let mut child = start_waiting_run(&["exec", "project.newFolder", name, "--project", link.to_str().unwrap(), "--save"]);
|
||||
drop(held);
|
||||
assert!(child.wait().unwrap().success(), "{name}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_that_do_not_save_in_place_ignore_the_lock() {
|
||||
let (dir, proj) = lock_test_project("ignore");
|
||||
let other = dir.join("other.ecproj").to_str().unwrap().to_string();
|
||||
let _held = hold_lock(&dir, "p.ecproj");
|
||||
let params = r#"{"name":"copy-only"}"#;
|
||||
let cases: [&[&str]; 3] = [
|
||||
&["info", "--project", &proj],
|
||||
&["exec", "project.newFolder", params, "--project", &proj],
|
||||
&["exec", "project.newFolder", params, "--project", &proj, "--save-as", &other],
|
||||
];
|
||||
for args in cases {
|
||||
let mut child = bin().args(args).stdout(Stdio::null()).stderr(Stdio::null()).spawn().unwrap();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait().unwrap() {
|
||||
break status;
|
||||
}
|
||||
if std::time::Instant::now() > deadline {
|
||||
let _ = child.kill();
|
||||
panic!("{args:?} is waiting for a lock it doesn't need");
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
};
|
||||
assert!(status.success(), "{args:?}");
|
||||
}
|
||||
assert!(!has_folder(&proj, "copy-only"));
|
||||
assert!(has_folder(&other, "copy-only"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_project_gets_no_lock_file_and_keeps_its_error() {
|
||||
let (dir, _) = lock_test_project("missing");
|
||||
let gone = dir.join("gone.ecproj").to_str().unwrap().to_string();
|
||||
let other = dir.join("other.ecproj").to_str().unwrap().to_string();
|
||||
// what opening it says, after the `effectcraft-cli <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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -190,15 +190,14 @@ fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b.img = img;
|
||||
return b;
|
||||
}
|
||||
let src_stats = grain_stats_boxes(&source, &src_boxes);
|
||||
// No box with an opaque surround (a small layer inside transparent pixels) means no clean
|
||||
// sample: no grain to match, as After Effects applies none when its samples hold transparent
|
||||
// areas. The whole-image fallback of `grain_stats_boxes` would measure the edge again.
|
||||
let clean_stats = |img: &Image, boxes: &[(usize, usize, usize)]| if boxes.is_empty() { GrainStats::default() } else { grain_stats_boxes(img, boxes) };
|
||||
let src_stats = clean_stats(&source, &src_boxes);
|
||||
let comp = (ctx.params.f("compensateForExistingNoise") / 100.0) as f32;
|
||||
// The own-layer boxes only matter for the compensation term; skip them when it is off.
|
||||
let own = if comp > 0.0 {
|
||||
let own_boxes = sampling_boxes(ctx, &b.img, b.scale);
|
||||
grain_stats_boxes(&b.img, &own_boxes)
|
||||
} else {
|
||||
GrainStats::default()
|
||||
};
|
||||
let own = if comp > 0.0 { clean_stats(&b.img, &sampling_boxes(ctx, &b.img, b.scale)) } else { GrainStats::default() };
|
||||
let orig = (view == 0).then(|| b.img.clone());
|
||||
let look = GrainLook::from_params(ctx);
|
||||
let amp: [f32; 3] = [0, 1, 2].map(|k| {
|
||||
@@ -464,6 +463,54 @@ mod tests {
|
||||
assert_eq!(out.img.data, again.img.data);
|
||||
}
|
||||
|
||||
/// #2919: a sample box beside transparent pixels measured the edge as grain. Its own
|
||||
/// pixels are opaque, but the high-pass blur reads 3 px around it, where transparent pixels
|
||||
/// are black once unpremultiplied. An earlier blur pads the layer with transparent pixels,
|
||||
/// so a clean plate got blocky colour grain on the frames whose flattest boxes sat at the
|
||||
/// edge, and fine grain on the others.
|
||||
#[test]
|
||||
fn match_grain_ignores_the_edge_of_a_transparent_border() {
|
||||
// A flat colour inside the 2 px transparent frame of a padded buffer.
|
||||
let framed = |w: u32, h: u32, c: [f32; 4]| {
|
||||
let mut img = Image::filled(w, h, c);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
if x < 2 || y < 2 || x >= w - 2 || y >= h - 2 {
|
||||
img.set(x, y, [0.0; 4]);
|
||||
}
|
||||
}
|
||||
}
|
||||
img
|
||||
};
|
||||
let most_change = |a: &Image, b: &Image| a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..3).map(move |k| (p[k] - q[k]).abs())).fold(0.0f32, f32::max);
|
||||
// A noiseless sky. At 16 px no 9 px box has an opaque 3 px surround, so there is no
|
||||
// clean sample at all (the whole-image fallback would measure the edge).
|
||||
for (w, h, clean_boxes) in [(64u32, 48u32, true), (16, 16, false)] {
|
||||
let sky = framed(w, h, [0.4, 0.6, 0.9, 1.0]);
|
||||
let boxes = crate::noise::sample_boxes(&sky, 80, 9);
|
||||
assert_eq!(!boxes.is_empty(), clean_boxes, "{w}x{h}: {boxes:?}");
|
||||
if clean_boxes {
|
||||
let st = grain_stats_boxes(&sky, &boxes);
|
||||
assert!(st.std.iter().all(|s| *s < 1e-5), "{w}x{h}: {st:?} from {boxes:?}");
|
||||
}
|
||||
// So Match Grain finds no grain to add.
|
||||
let host = FakeHost(sky);
|
||||
let env = EffectEnv { host: Some(&host), ..Default::default() };
|
||||
let target = Image::filled(w, h, [0.2, 0.3, 0.5, 1.0]);
|
||||
let out = run_fx("ec.noise.matchgrain", &[("noiseSourceLayer", Value::Layer(Some(7)))], target.clone(), 0.5, env);
|
||||
let most = most_change(&out.img, &target);
|
||||
assert!(most < 1e-5, "{w}x{h}: Match Grain added {most}");
|
||||
}
|
||||
// Compensate for Existing Noise has no clean sample of such a layer either, so its edge
|
||||
// doesn't cancel the source's grain.
|
||||
let host = FakeHost(noisy(16, 16, 0.1, 3));
|
||||
let env = EffectEnv { host: Some(&host), ..Default::default() };
|
||||
let target = framed(16, 16, [0.9, 0.9, 0.9, 1.0]);
|
||||
let params = [("noiseSourceLayer", Value::Layer(Some(7))), ("compensateForExistingNoise", crate::num(100.0))];
|
||||
let out = run_fx("ec.noise.matchgrain", ¶ms, target.clone(), 0.5, env);
|
||||
assert!(most_change(&out.img, &target) > 0.01, "the layer's own edge cancelled the grain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn median_legacy_radius_zero_identity_and_removes_speck() {
|
||||
let mut img = Image::filled(9, 9, [0.2, 0.2, 0.2, 1.0]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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(¶ms).is_none(), "this session has no model");
|
||||
// the frozen matte is the left half of the layer: no segmentation of the disk gives it
|
||||
let alpha: Vec<f32> = (0..100 * 80).map(|i| f32::from(u8::from(i % 100 < 50))).collect();
|
||||
let mut fc = FrozenCache { key: frozen_key_with(¶ms, size, 25.0, effectcraft_segment::SAM2_TINY.id), size: [100, 80], ..Default::default() };
|
||||
fc.frames.insert(0, freeze_frame(&alpha, &[0; 100 * 80]));
|
||||
assert_ne!(fc.key, frozen_key(¶ms, size, 25.0));
|
||||
params.values.insert(FROZEN.into(), Value::Str(fc.to_json()));
|
||||
let host = Seq;
|
||||
let ctx = EffectCtx {
|
||||
params: ¶ms,
|
||||
time: 0.001,
|
||||
layer_size: size,
|
||||
seed: 1,
|
||||
adjustment: false,
|
||||
env: EffectEnv { host: Some(&host), frame_rate: 25.0, ..Default::default() },
|
||||
};
|
||||
let out = crate::apply(&specs()[0], &ctx, Buf { img: frame(0.0), offset: [0.0; 2], scale: 1.0 }).img;
|
||||
let a = |x: usize, y: usize| out.data[y * 100 + x][3];
|
||||
assert!(a(10, 70) > 0.99 && a(90, 10) < 0.01, "the frozen matte is used: {} at (10, 70), {} at (90, 10)", a(10, 70), a(90, 10));
|
||||
// freezing again in this session keeps the frozen matte instead of segmenting with the classic engine
|
||||
let mut get = |g: i64| Some(Arc::new(frame(g as f64 / 25.0)));
|
||||
let kept = applying_frozen(¶ms, size, 25.0).expect("the frozen mattes apply");
|
||||
let (matte, band) = frame_matte(¶ms, size, 25.0, 0, Some(&kept), &mut get, &|| false).expect("frame 0's matte");
|
||||
assert_eq!((matte, band.len()), (alpha.clone(), 100 * 80), "a second Freeze keeps the frozen frame");
|
||||
let segmented = frame_matte(¶ms, size, 25.0, 1, Some(&kept), &mut get, &|| false);
|
||||
assert!(segmented.is_some_and(|(a, _)| a[40 * 100 + 10] < 0.01), "a frame with none is segmented");
|
||||
// a stroke edited after the freeze still makes it stale, whatever the model
|
||||
let mut moved = d.clone();
|
||||
moved.strokes[0].points[0] = [36.0, 40.0];
|
||||
let mut edited = params.clone();
|
||||
edited.values.insert(STROKES.into(), Value::Str(moved.to_json()));
|
||||
assert!(!frozen_applies(&fc, &edited, size, 25.0));
|
||||
assert!(applying_frozen(&edited, size, 25.0).is_none(), "a second Freeze then segments every frame");
|
||||
}
|
||||
|
||||
/// A stand-in tracker for two objects: object 0 is the moving disk, object 1 a fixed square. It records how many
|
||||
/// objects (and prompts per object) it was asked for.
|
||||
struct TwoObjects(std::sync::Mutex<Vec<Vec<usize>>>);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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(¶ms);
|
||||
out["solved"] = json!(solve.is_some());
|
||||
// The solver interpolates the frames it could not solve: only a solve of every frame is
|
||||
// `solved` (what `camera.exportBlender` needs); one with gaps is `partial`.
|
||||
let solved_frames = solve.as_ref().map_or(0, |sv| sv.frames.iter().filter(|c| c.solved).count());
|
||||
let complete = solve.as_ref().is_some_and(|sv| solved_frames == sv.frames.len());
|
||||
out["solved"] = json!(complete);
|
||||
out["partial"] = json!(solve.is_some() && !complete);
|
||||
if let Some(sv) = &solve {
|
||||
let f = sv.focal();
|
||||
out["methodUsed"] = json!(sv.method_used.label());
|
||||
out["averageError"] = json!(sv.average_error);
|
||||
out["points"] = json!(sv.points.len());
|
||||
out["solvedFrames"] = json!(sv.frames.iter().filter(|c| c.solved).count());
|
||||
out["solvedFrames"] = json!(solved_frames);
|
||||
out["focalLength"] = json!(f);
|
||||
out["horizontalAngleOfView"] = json!(sv.hfov(f));
|
||||
out["groundPlane"] = json!(sv.ground.is_some());
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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, ¶ms, at) || puppet::overlay(&buf, ¶ms).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, ¶ms, at) {
|
||||
return Ok(json!({"pin": null, "changed": false}));
|
||||
}
|
||||
}
|
||||
let opts = MeshOpts {
|
||||
density: f_p(p, "density").unwrap_or(s.state.puppet.density),
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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]);
|
||||
|
||||
@@ -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}`");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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", "分割してプリコンポーズ"),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! and Camera, Create Multiple …, Delete Selected Points; Delete removes the selection;
|
||||
//! - the "Analyzing in background (step 1 of 2)" / "Solving camera" banner;
|
||||
//! - the Effect Controls block with Analyze / Cancel / Create Camera, Method Used and Average
|
||||
//! Error.
|
||||
//! Error, and how many frames a partial solve solved.
|
||||
//!
|
||||
//! Everything is dispatched to the `camera.*` commands.
|
||||
|
||||
@@ -425,12 +425,14 @@ pub fn editor(
|
||||
}
|
||||
let params = effectcraft_engine::camera_track::static_params(g);
|
||||
let solve = ct::solve(¶ms);
|
||||
let partial = solve.as_deref().and_then(partial_frames);
|
||||
let dy = if partial.is_some() { PARTIAL_LINE } else { 0.0 };
|
||||
if solve.is_some() {
|
||||
if widgets::text_button(ui, b3, crate::i18n::tr("Create Camera"), false, &t, egui::Id::unique(("ct-camera", g.uid))).clicked() {
|
||||
actions.push(("camera.create".into(), me.clone()));
|
||||
}
|
||||
app.auto.add(&format!("effectControls.cameraTracker.{}.createCamera", g.uid), b3, "Create Camera");
|
||||
let export = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 70.0), vec2(218.0, 22.0));
|
||||
let export = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 70.0 + dy), vec2(218.0, 22.0));
|
||||
if widgets::text_button(ui, export, tr("Export Scene to Blender..."), false, &t, egui::Id::unique(("ct-blender", g.uid))).clicked() && !busy {
|
||||
actions.push(("camera.exportBlender".into(), me.clone()));
|
||||
}
|
||||
@@ -449,10 +451,19 @@ pub fn editor(
|
||||
},
|
||||
};
|
||||
p.text(pos2(r.min.x + 24.0, r.min.y + 38.0), Align2::LEFT_CENTER, status, Tokens::ui(11.5), t.text_dim);
|
||||
if let Some((solved, frames)) = partial {
|
||||
let text = tr_args("Partial solve: {} of {} frames solved", &[&solved, &frames]);
|
||||
p.text(pos2(r.min.x + 24.0, r.min.y + 56.0), Align2::LEFT_CENTER, &text, Tokens::ui(11.5), t.warning);
|
||||
app.auto.add(
|
||||
&format!("effectControls.cameraTracker.{}.partialSolve", g.uid),
|
||||
Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 48.0), vec2(r.width() - 24.0, 16.0)),
|
||||
&text,
|
||||
);
|
||||
}
|
||||
if let Some(sv) = &solve {
|
||||
let f = sv.focal();
|
||||
p.text(
|
||||
pos2(r.min.x + 24.0, r.min.y + 56.0),
|
||||
pos2(r.min.x + 24.0, r.min.y + 56.0 + dy),
|
||||
Align2::LEFT_CENTER,
|
||||
format!("{} solved points · Horizontal Angle of View {:.1}°", sv.points.len(), sv.hfov(f)),
|
||||
Tokens::ui(11.5),
|
||||
@@ -461,5 +472,19 @@ pub fn editor(
|
||||
}
|
||||
}
|
||||
|
||||
/// Height of [`editor`].
|
||||
pub const EDITOR_HEIGHT: f32 = 100.0;
|
||||
/// Height of [`editor`] without the partial-solve line.
|
||||
const EDITOR_HEIGHT: f32 = 100.0;
|
||||
const PARTIAL_LINE: f32 = 18.0;
|
||||
|
||||
/// Height of [`editor`] for tracker `g`.
|
||||
pub fn editor_height(g: &PropGroup) -> f32 {
|
||||
let partial = ct::solve(&effectcraft_engine::camera_track::static_params(g)).is_some_and(|sv| partial_frames(&sv).is_some());
|
||||
if partial { EDITOR_HEIGHT + PARTIAL_LINE } else { EDITOR_HEIGHT }
|
||||
}
|
||||
|
||||
/// `(solved, all)` frames of a solve with unsolved frames, where the solver interpolates the
|
||||
/// camera; `None` when every frame is solved.
|
||||
fn partial_frames(sv: &CameraSolve) -> Option<(usize, usize)> {
|
||||
let solved = sv.frames.iter().filter(|c| c.solved).count();
|
||||
(solved < sv.frames.len()).then_some((solved, sv.frames.len()))
|
||||
}
|
||||
@@ -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| {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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![];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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![];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() };
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
Reference in new issue
Block a user