mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-06 18:41:26 +00:00
Merge upstream/main into fix/exr-scene-linear-output
Conflict: crates/gpu/src/tests.rs (both sides added a test; kept both). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
653157532b
155 files changed
+12031
-709
No files matched your search
Generated
+2
@@ -1555,6 +1555,7 @@ dependencies = [
|
||||
"effectcraft-render",
|
||||
"effectcraft-time",
|
||||
"effectcraft-vp9enc",
|
||||
"exr",
|
||||
"filmcraft-aac",
|
||||
"filmcraft-h264enc",
|
||||
"filmcraft-isobmff",
|
||||
@@ -1611,6 +1612,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"pollster 1.0.1",
|
||||
"rayon",
|
||||
"wasm-bindgen",
|
||||
"web-time",
|
||||
"wgpu",
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
mod project_lock;
|
||||
mod template;
|
||||
|
||||
use effectcraft_automation::tools::{self, Reply};
|
||||
@@ -508,6 +509,7 @@ fn script_cmd(args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
(None, Some(f)) => (std::fs::read_to_string(f).map_err(|e| Failure::Error(format!("cannot read {f}: {e}")))?, f.clone()),
|
||||
(None, None) => return usage_err("script needs a .jsx/.js file or --eval CODE"),
|
||||
};
|
||||
let _lock = project_lock::for_save(args)?;
|
||||
let mut b = backend(args, false)?;
|
||||
let r = b.exec("script.run", json!({"code": code, "name": name}))?;
|
||||
let saved = maybe_save(&mut b, args)?;
|
||||
@@ -611,6 +613,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
(Some(p), _) | (None, Some(p)) => serde_json::from_str(p).map_err(|e| Failure::Usage(format!("params are not valid JSON: {e}")))?,
|
||||
(None, None) => json!({}),
|
||||
};
|
||||
let _lock = project_lock::for_save(args)?;
|
||||
let mut b = backend(args, true)?;
|
||||
let r = b.exec(&id, params)?;
|
||||
if let Some(e) = failed_render(&id, &r) {
|
||||
@@ -654,6 +657,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
if steps.is_empty() {
|
||||
return Err(Failure::Usage(format!("--commands {}: no commands", sources.join(", "))));
|
||||
}
|
||||
let _lock = project_lock::for_save(args)?;
|
||||
let mut b = backend(args, true)?;
|
||||
let mut results = vec![];
|
||||
// The step's index closes the message: a generated list repeats ids, and callers match
|
||||
@@ -690,7 +694,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
if let Some(e) = p.get("expression").and_then(Value::as_str) {
|
||||
extra += &format!(" expr: {e}");
|
||||
}
|
||||
say!(" {:<44} {:<8} {}{extra}", p["path"].as_str().unwrap_or(""), p["type"].as_str().unwrap_or(""), p["value"]);
|
||||
say!(" {:<44} {:<8} {}{extra}", p["path"].as_str().unwrap_or(""), p["type"].as_str().unwrap_or(""), value_text(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -708,6 +712,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
}
|
||||
let (comp, layer, path) = (args.pos[0].clone(), args.pos[1].clone(), args.pos[2].clone());
|
||||
let value = args.pos.get(3).map(|v| value_arg(v));
|
||||
let _lock = project_lock::for_save(args)?;
|
||||
let mut b = backend(args, true)?;
|
||||
let a =
|
||||
json!({"comp": reference(&comp), "layer": reference(&layer), "path": path, "value": value, "time": args.num("--time")?, "expression": expr});
|
||||
@@ -1539,6 +1544,16 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A flat property's value for the text listing: an enum's number with the label `options` gives it
|
||||
/// (#2928), since the numbers are 0-based where After Effects scripting counts from 1.
|
||||
fn value_text(p: &Value) -> String {
|
||||
let label = p["options"].as_array().and_then(|o| o.iter().find(|o| o["value"] == p["value"])).and_then(|o| o["label"].as_str());
|
||||
match label {
|
||||
Some(l) => format!("{} ({l})", p["value"]),
|
||||
None => p["value"].to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -104,12 +104,12 @@ pub use generate3::{
|
||||
};
|
||||
pub use noise::GrainLook;
|
||||
pub use textfx::{BoltPlan, TextLook, TextPass, bolt_plan, text_passes};
|
||||
pub use transition::place_layer;
|
||||
pub use transition::{GridWipe, place_layer};
|
||||
// effectcraft-gpu fx_noise.
|
||||
pub use keying2::{InnerOuterPlan, inner_outer_plan};
|
||||
pub use noise::remove_grain_level;
|
||||
pub use noise3::{FractalGpu, NOISE_CELL, fractal_gpu};
|
||||
pub use stylize3::{BumpLight, bump_layer_id};
|
||||
pub use stylize3::{BumpLight, HexTile, bump_layer_id};
|
||||
pub use textfx::{TextLayer, text_layer};
|
||||
pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time, time_frames, timewarp_crop, timewarp_plan};
|
||||
// effectcraft-gpu fx_tone.
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -364,32 +364,54 @@ fn hex_cell(x: f64, y: f64, r: f64) -> ((f64, f64), (i64, i64)) {
|
||||
((px, py), (rx as i64, rz as i64))
|
||||
}
|
||||
|
||||
/// Stable f64 hex-cell selection and source geometry for CPU and GPU renders.
|
||||
pub struct HexTile {
|
||||
r: f64,
|
||||
c: (f64, f64),
|
||||
sn: f64,
|
||||
cs: f64,
|
||||
smear: f64,
|
||||
mode: u32,
|
||||
}
|
||||
impl HexTile {
|
||||
/// No image pixels are read; only buffer geometry and parameters are used.
|
||||
pub fn new(ctx: &EffectCtx, b: &Buf) -> Self {
|
||||
let r = (ctx.params.f("radius") * b.scale).max(0.5);
|
||||
let c = b.to_px(ctx.params.v2("center"));
|
||||
let rot = ctx.params.f("rotate").to_radians();
|
||||
let (sn, cs) = rot.sin_cos();
|
||||
let smear = (ctx.params.f("smearing") / 100.0).clamp(0.0, 1.0);
|
||||
let mode = ctx.params.e("render");
|
||||
Self { r, c, sn, cs, smear, mode }
|
||||
}
|
||||
/// Bilinear source position at a buffer pixel centre.
|
||||
pub fn source_at(&self, x: u32, y: u32) -> (f64, f64) {
|
||||
let Self { r, c: (cx, cy), sn, cs, smear, mode } = *self;
|
||||
let (dx, dy) = (x as f64 + 0.5 - cx, y as f64 + 0.5 - cy);
|
||||
// Into the rotated tiling frame.
|
||||
let (u, v) = (dx * cs + dy * sn, -dx * sn + dy * cs);
|
||||
let ((hx, hy), (qi, ri)) = hex_cell(u, v, r);
|
||||
let (mut lu, lv) = (u - hx, v - hy);
|
||||
let odd = (qi + ri).rem_euclid(2) == 1;
|
||||
match mode {
|
||||
1 if odd => lu = -lu,
|
||||
2 if odd => lu = -lu,
|
||||
_ => {}
|
||||
}
|
||||
let lv = if mode == 2 && odd { -lv } else { lv };
|
||||
// Every tile shows the hexagon around the centre point.
|
||||
let (ox, oy) = (lu * cs - lv * sn, lu * sn + lv * cs);
|
||||
let (tx, ty) = (cx + ox, cy + oy);
|
||||
let (sx, sy) = (tx + (x as f64 + 0.5 - tx) * smear, ty + (y as f64 + 0.5 - ty) * smear);
|
||||
(sx, sy)
|
||||
}
|
||||
}
|
||||
fn hextile(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let r = (ctx.params.f("radius") * b.scale).max(0.5);
|
||||
let (cx, cy) = b.to_px(ctx.params.v2("center"));
|
||||
let rot = ctx.params.f("rotate").to_radians();
|
||||
let (sn, cs) = rot.sin_cos();
|
||||
let smear = (ctx.params.f("smearing") / 100.0).clamp(0.0, 1.0);
|
||||
let mode = ctx.params.e("render");
|
||||
let geometry = HexTile::new(ctx, &b);
|
||||
let src = b.img.clone();
|
||||
b.img.rows_mut().for_each(|(y, row)| {
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let (dx, dy) = (x as f64 + 0.5 - cx, y as f64 + 0.5 - cy);
|
||||
// Into the rotated tiling frame.
|
||||
let (u, v) = (dx * cs + dy * sn, -dx * sn + dy * cs);
|
||||
let ((hx, hy), (qi, ri)) = hex_cell(u, v, r);
|
||||
let (mut lu, lv) = (u - hx, v - hy);
|
||||
let odd = (qi + ri).rem_euclid(2) == 1;
|
||||
match mode {
|
||||
1 if odd => lu = -lu,
|
||||
2 if odd => lu = -lu,
|
||||
_ => {}
|
||||
}
|
||||
let lv = if mode == 2 && odd { -lv } else { lv };
|
||||
// Every tile shows the hexagon around the centre point.
|
||||
let (ox, oy) = (lu * cs - lv * sn, lu * sn + lv * cs);
|
||||
let (tx, ty) = (cx + ox, cy + oy);
|
||||
let (sx, sy) = (tx + (x as f64 + 0.5 - tx) * smear, ty + (y as f64 + 0.5 - ty) * smear);
|
||||
let (sx, sy) = geometry.source_at(x as u32, y as u32);
|
||||
*px = src.sample_bilinear_clamped(sx, sy);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -314,53 +314,86 @@ pub(crate) fn card_wipe_plan(ctx: &EffectCtx, b: &Buf) -> Option<PiecePlan> {
|
||||
Some(PiecePlan { pieces, front: PieceTex::Layer, back })
|
||||
}
|
||||
|
||||
/// Double-precision grid geometry, shared with the GPU mask upload. A tiny
|
||||
/// rounding error at a tile boundary can otherwise flip a fully opaque pixel.
|
||||
pub struct GridWipe {
|
||||
c: (f64, f64),
|
||||
s: f64,
|
||||
co: f64,
|
||||
cs: f64,
|
||||
maxd: f64,
|
||||
shape: u32,
|
||||
reverse: bool,
|
||||
done: f64,
|
||||
border: f64,
|
||||
}
|
||||
|
||||
impl GridWipe {
|
||||
/// Construct from buffer geometry; no pixel data is accessed.
|
||||
pub fn new(ctx: &EffectCtx, b: &Buf) -> Self {
|
||||
let done = completion(ctx);
|
||||
let c = b.to_px(ctx.params.v2("center"));
|
||||
let rot = ctx.params.f("rotation").to_radians();
|
||||
let border = ctx.params.f("border").max(0.0) * b.scale;
|
||||
let tiles = ctx.params.f("tiles").round().clamp(1.0, 500.0);
|
||||
let shape = ctx.params.e("shape");
|
||||
let reverse = ctx.params.b("reverse");
|
||||
let (_, _, w, _) = layer_rect(ctx, b);
|
||||
let cs = (w / tiles).max(1.0);
|
||||
let (s, co) = rot.sin_cos();
|
||||
let metric = |dx: f64, dy: f64| match shape {
|
||||
0 => dx.abs(),
|
||||
2 => dx.abs().max(dy.abs()),
|
||||
_ => dx.hypot(dy),
|
||||
};
|
||||
let maxd = max_corner_dist(b, c, |dx, dy| {
|
||||
let (qx, qy) = (dx * co + dy * s, -dx * s + dy * co);
|
||||
metric(qx, qy)
|
||||
});
|
||||
GridWipe { c, s, co, cs, maxd, shape, reverse, done, border }
|
||||
}
|
||||
|
||||
/// Alpha multiplier at a buffer pixel centre.
|
||||
pub fn coverage(&self, x: u32, y: u32) -> f32 {
|
||||
let Self { c, s, co, cs, maxd, shape, reverse, done, border } = *self;
|
||||
let metric = |dx: f64, dy: f64| match shape {
|
||||
0 => dx.abs(),
|
||||
2 => dx.abs().max(dy.abs()),
|
||||
_ => dx.hypot(dy),
|
||||
};
|
||||
let spread = 0.25;
|
||||
let (dx, dy) = (x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1);
|
||||
let (qx, qy) = (dx * co + dy * s, -dx * s + dy * co);
|
||||
let (cx, cy) = (((qx / cs).floor() + 0.5) * cs, ((qy / cs).floor() + 0.5) * cs);
|
||||
let mut o = (metric(cx, cy) / maxd).clamp(0.0, 1.0);
|
||||
if reverse {
|
||||
o = 1.0 - o;
|
||||
}
|
||||
let p = ((done * (1.0 + spread) - o) / spread).clamp(0.0, 1.0);
|
||||
if p <= 0.0 {
|
||||
return 1.0;
|
||||
}
|
||||
let half = cs * 0.5 * (1.0 - p);
|
||||
let m = (qx - cx).abs().max((qy - cy).abs());
|
||||
let k = if border > 0.0 {
|
||||
((half - m) / border + 0.5).clamp(0.0, 1.0)
|
||||
} else if m < half {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
k as f32
|
||||
}
|
||||
}
|
||||
|
||||
fn grid_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let done = completion(ctx);
|
||||
if done <= 0.0 {
|
||||
if completion(ctx) <= 0.0 {
|
||||
return b;
|
||||
}
|
||||
let c = b.to_px(ctx.params.v2("center"));
|
||||
let rot = ctx.params.f("rotation").to_radians();
|
||||
let border = ctx.params.f("border").max(0.0) * b.scale;
|
||||
let tiles = ctx.params.f("tiles").round().clamp(1.0, 500.0);
|
||||
let shape = ctx.params.e("shape");
|
||||
let reverse = ctx.params.b("reverse");
|
||||
let (_, _, w, _) = layer_rect(ctx, &b);
|
||||
let cs = (w / tiles).max(1.0);
|
||||
let (s, co) = rot.sin_cos();
|
||||
let metric = |dx: f64, dy: f64| match shape {
|
||||
0 => dx.abs(),
|
||||
2 => dx.abs().max(dy.abs()),
|
||||
_ => dx.hypot(dy),
|
||||
};
|
||||
let maxd = max_corner_dist(&b, c, |dx, dy| {
|
||||
let (qx, qy) = (dx * co + dy * s, -dx * s + dy * co);
|
||||
metric(qx, qy)
|
||||
});
|
||||
let spread = 0.25;
|
||||
let grid = GridWipe::new(ctx, &b);
|
||||
b.img.rows_mut().for_each(|(y, row)| {
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let (dx, dy) = (x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1);
|
||||
let (qx, qy) = (dx * co + dy * s, -dx * s + dy * co);
|
||||
let (cx, cy) = (((qx / cs).floor() + 0.5) * cs, ((qy / cs).floor() + 0.5) * cs);
|
||||
let mut o = (metric(cx, cy) / maxd).clamp(0.0, 1.0);
|
||||
if reverse {
|
||||
o = 1.0 - o;
|
||||
}
|
||||
let p = ((done * (1.0 + spread) - o) / spread).clamp(0.0, 1.0);
|
||||
if p <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let half = cs * 0.5 * (1.0 - p);
|
||||
let m = (qx - cx).abs().max((qy - cy).abs());
|
||||
let k = if border > 0.0 {
|
||||
((half - m) / border + 0.5).clamp(0.0, 1.0)
|
||||
} else if m < half {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
scale_px(px, k as f32);
|
||||
scale_px(px, grid.coverage(x as u32, y as u32));
|
||||
}
|
||||
});
|
||||
b
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -558,6 +558,7 @@ async fn fill(s: &mut Session, plan: crate::commands::content_fill::FillPlan, po
|
||||
true
|
||||
};
|
||||
let accel = job_accel();
|
||||
let fill = accel.as_deref().and_then(|a| a.inpaint());
|
||||
let files = crate::commands::content_fill::run_plan(
|
||||
&plan,
|
||||
&s.project,
|
||||
@@ -566,6 +567,7 @@ async fn fill(s: &mut Session, plan: crate::commands::content_fill::FillPlan, po
|
||||
&s.layer_cache,
|
||||
s.services.as_ref(),
|
||||
accel.as_deref(),
|
||||
fill,
|
||||
&mut progress,
|
||||
&mut |_| {},
|
||||
)
|
||||
|
||||
@@ -417,6 +417,10 @@ impl FrameServer {
|
||||
auto = Some((key, gpu));
|
||||
}
|
||||
}
|
||||
// A frame still waiting for readbacks is replaced: end it, so every `frame_begin` has its `frame_end`.
|
||||
if self.pending.take().is_some() {
|
||||
a.frame_end();
|
||||
}
|
||||
a.frame_begin();
|
||||
}
|
||||
self.pending = Some(Pending { id, project: p, comp, time, opts, t0: web_time::Instant::now(), passes: 0, auto });
|
||||
@@ -457,6 +461,10 @@ impl FrameServer {
|
||||
{
|
||||
pick.record(key, chose, ms);
|
||||
}
|
||||
// The frame is done (its `frame_begin` ran in `handle`): the GPU pool ages its sizes.
|
||||
if let Some(a) = &self.accel {
|
||||
a.frame_end();
|
||||
}
|
||||
vec![(
|
||||
FrameReply::Frame { id: pr.id, width: img.width, height: img.height, ms, passes: pr.passes, gpu, layer_misses, layer_hits },
|
||||
Some(rgba8_premultiplied(&img)),
|
||||
|
||||
@@ -192,6 +192,8 @@ struct Deferred {
|
||||
asked: std::sync::Mutex<std::collections::HashSet<u64>>,
|
||||
gate: Arc<std::sync::atomic::AtomicBool>,
|
||||
chains: std::sync::atomic::AtomicUsize,
|
||||
begins: std::sync::atomic::AtomicUsize,
|
||||
ends: std::sync::atomic::AtomicUsize,
|
||||
auto: effectcraft_render::AutoPick,
|
||||
}
|
||||
|
||||
@@ -245,8 +247,12 @@ impl Accelerator for Deferred {
|
||||
Some(&self.auto)
|
||||
}
|
||||
fn frame_begin(&self) {
|
||||
self.begins.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
self.ready.lock().unwrap().clear();
|
||||
}
|
||||
fn frame_end(&self) {
|
||||
self.ends.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
fn pass_begin(&self) {
|
||||
self.gate.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
@@ -316,3 +322,56 @@ fn deferred_readbacks_render_in_passes() {
|
||||
let st = acc.auto.stats(effectcraft_render::AutoKey::new(comp, opts.scale, false)).unwrap();
|
||||
assert!(st.cpu_frames > 0 && st.gpu_frames > 0, "{st:?}");
|
||||
}
|
||||
|
||||
/// `frame_begin` and `frame_end` stay paired: early fails (before `frame_begin`), a pending frame replaced by a new render, a finished multi-pass frame, and a CPU frame.
|
||||
#[test]
|
||||
fn every_frame_begin_has_a_frame_end() {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
let comp = s.active_comp_id().unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#3388ff", "name": "Blue", "width": 200, "height": 120})).unwrap();
|
||||
s.execute("effect.apply", json!({"effect": "ec.blur.gaussian"})).unwrap();
|
||||
s.execute("effect.apply", json!({"effect": "ec.stylize.glow"})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#ffcc00", "name": "Yellow", "width": 80, "height": 80})).unwrap();
|
||||
s.execute("effect.apply", json!({"effect": "ec.color.levels"})).unwrap();
|
||||
let opts = RenderOpts { scale: 0.25, backend: Backend::Gpu, ..Default::default() };
|
||||
let t = Tick::from_seconds_f64(1.0);
|
||||
let mut w = server(&s);
|
||||
let acc = Arc::new(Deferred::default());
|
||||
w.set_accel(Some(acc.clone()));
|
||||
let mut m = Mirror::default();
|
||||
w.handle(round(&m.sync(s.revision, &s.project).unwrap()));
|
||||
let req = |id, revision, comp, opts: RenderOpts| {
|
||||
round(&FrameMsg::Render { id, revision, comp, time: t, opts: Box::new(opts), disk: None, layers: false, prefetch: vec![] })
|
||||
};
|
||||
let load = || (acc.begins.load(Relaxed), acc.ends.load(Relaxed));
|
||||
// Early fails never touch the frame clock.
|
||||
assert!(matches!(w.handle(req(1, s.revision + 1, comp, opts))[0].0, FrameReply::Failed { .. }));
|
||||
assert!(matches!(w.handle(req(2, s.revision, ItemId(999_999), opts))[0].0, FrameReply::Failed { .. }));
|
||||
assert_eq!(load(), (0, 0));
|
||||
// First GPU pass misses and leaves the frame pending.
|
||||
assert!(w.handle(req(3, s.revision, comp, opts)).is_empty() && w.waiting());
|
||||
assert_eq!(load(), (1, 0));
|
||||
// A fail while pending leaves the frame and the counters alone.
|
||||
assert!(matches!(w.handle(req(4, s.revision + 1, comp, opts))[0].0, FrameReply::Failed { .. }));
|
||||
assert!(w.waiting());
|
||||
assert_eq!(load(), (1, 0));
|
||||
// A new valid render replaces the pending frame (ends the old one).
|
||||
assert!(w.handle(req(5, s.revision, comp, opts)).is_empty() && w.waiting());
|
||||
assert_eq!(load(), (2, 1));
|
||||
let mut out = vec![];
|
||||
for _ in 0..10 {
|
||||
acc.settle();
|
||||
out = w.resume();
|
||||
if !w.waiting() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(matches!(out[0].0, FrameReply::Frame { id: 5, .. }));
|
||||
assert_eq!(load(), (2, 2));
|
||||
// CPU still pairs begin/end on the accelerator.
|
||||
let cpu = RenderOpts { backend: Backend::Cpu, ..opts };
|
||||
assert!(matches!(w.handle(req(6, s.revision, comp, cpu))[0].0, FrameReply::Frame { id: 6, .. }));
|
||||
assert_eq!(load(), (3, 3));
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -13,7 +13,7 @@ use std::sync::Arc;
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, LayerId, LayerSource, build};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_raster::inpaint::{FillInput, FillMethod, FillOpts, dilate, fill_sequence};
|
||||
use effectcraft_raster::inpaint::{FillInput, FillMethod, FillOpts, dilate};
|
||||
use effectcraft_render::{Accelerator, EvalCtx, ExprHost, FootageSource, LayerCache, Renderer};
|
||||
use effectcraft_time::Tick;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -185,8 +185,21 @@ fn memory_shortfall(frames: usize, w: u32, h: u32, available: u64) -> Option<Str
|
||||
})
|
||||
}
|
||||
|
||||
/// The status line shown while the frames are filled. Edge Blend calls only the membrane, which
|
||||
/// the device backend leaves to the CPU reference, so it is not a device fill even when a `fill`
|
||||
/// backend is present; Object and Surface run the flow and PatchMatch on the device.
|
||||
fn filling_status(on_device: bool, method: FillMethod) -> &'static str {
|
||||
if on_device && method != FillMethod::EdgeBlend { "Filling (GPU)" } else { "Filling" }
|
||||
}
|
||||
|
||||
/// Run a fill: the layer's frames (rendered in passes with a job worker's GPU), the fill, the
|
||||
/// PNG sequence written into `plan.dir` (`Services::store_file`). Returns the files.
|
||||
///
|
||||
/// `accel` renders the frames (a job worker's GPU, as for every analysis: `None` on the
|
||||
/// desktop, whose analysis threads render on the CPU by design); `fill` is the fill's own
|
||||
/// backend ([`effectcraft_raster::inpaint::FillBackend`]), which is what makes Content-Aware
|
||||
/// Fill itself run on the GPU. They are separate on purpose: a desktop fill can run on the GPU
|
||||
/// while its input frames are composited on the CPU.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn run_plan(
|
||||
plan: &FillPlan,
|
||||
@@ -196,6 +209,7 @@ pub(crate) async fn run_plan(
|
||||
cache: &LayerCache,
|
||||
services: &dyn crate::Services,
|
||||
accel: Option<&dyn Accelerator>,
|
||||
fill: Option<&dyn effectcraft_raster::inpaint::FillBackend>,
|
||||
progress: &mut dyn FnMut(u64, u64) -> bool,
|
||||
message: &mut dyn FnMut(&str),
|
||||
) -> std::result::Result<Vec<String>, String> {
|
||||
@@ -233,10 +247,13 @@ pub(crate) async fn run_plan(
|
||||
references.push((k, img));
|
||||
}
|
||||
}
|
||||
message("Filling");
|
||||
let opts = FillOpts { method: FillMethod::from_name(&plan.method).unwrap_or_default(), lighting: plan.lighting, ..Default::default() };
|
||||
message(filling_status(fill.is_some(), opts.method));
|
||||
let input = FillInput { frames, holes, references };
|
||||
let out = fill_sequence(&input, &opts, &mut |d, t| progress(n + (d as u64 * n / t.max(1) as u64), n * 3)).ok_or(crate::render_queue::CANCELLED)?;
|
||||
let cpu = effectcraft_raster::inpaint::CpuFill;
|
||||
let backend: &dyn effectcraft_raster::inpaint::FillBackend = fill.unwrap_or(&cpu);
|
||||
let out = effectcraft_raster::inpaint::fill_sequence_with(&input, &opts, backend, &mut |d, t| progress(n + (d as u64 * n / t.max(1) as u64), n * 3))
|
||||
.ok_or(crate::render_queue::CANCELLED)?;
|
||||
message("Writing the fill layer");
|
||||
let dir = &plan.dir;
|
||||
let mut paths = vec![];
|
||||
@@ -324,7 +341,12 @@ fn generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let services = s.services.clone();
|
||||
let importer = s.importer.clone();
|
||||
let name = layer.name.clone();
|
||||
// The fill's own GPU backend (Mercury GPU Acceleration; none under Software Only or
|
||||
// without a compositor). The frames themselves still render on the CPU here, as for every
|
||||
// desktop analysis (`offload::job_accel`): only the fill's own work moves to the device.
|
||||
let accel = if s.project.settings.gpu_acceleration { s.accel.clone() } else { None };
|
||||
s.spawn_task("contentFill", format!("Content-Aware Fill: {name}"), wait, move |ctl| {
|
||||
let fill = accel.as_deref().and_then(|a| a.inpaint());
|
||||
let paths = effectcraft_render::passes::block_on(run_plan(
|
||||
&plan,
|
||||
&lf.project,
|
||||
@@ -333,6 +355,7 @@ fn generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
&lf.cache,
|
||||
services.as_ref(),
|
||||
None,
|
||||
fill,
|
||||
&mut |d, t| ctl.progress(d, t),
|
||||
&mut |m| ctl.message(m),
|
||||
))?;
|
||||
@@ -445,7 +468,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::memory_shortfall;
|
||||
use super::{filling_status, memory_shortfall};
|
||||
|
||||
/// Edge Blend fills with the membrane, which the device backend leaves to the CPU reference:
|
||||
/// it must not claim a GPU fill just because a backend is available.
|
||||
#[test]
|
||||
fn edge_blend_is_not_reported_as_a_device_fill() {
|
||||
use effectcraft_raster::inpaint::FillMethod;
|
||||
assert_eq!(filling_status(true, FillMethod::Object), "Filling (GPU)");
|
||||
assert_eq!(filling_status(true, FillMethod::Surface), "Filling (GPU)");
|
||||
assert_eq!(filling_status(true, FillMethod::EdgeBlend), "Filling");
|
||||
assert_eq!(filling_status(false, FillMethod::Object), "Filling");
|
||||
}
|
||||
|
||||
/// #1789: 10 s of 1080p30 (300 frames) needs at least 20 GB, which 4 GB free can't hold.
|
||||
#[test]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -76,7 +76,15 @@ impl Prefs {
|
||||
Err(_) => {
|
||||
let mut base = serde_json::to_value(Prefs::default()).unwrap_or_default();
|
||||
if let (Some(b), Some(o)) = (base.as_object_mut(), v.as_object()) {
|
||||
// Only the settings' own pages are tried one by one (each trial parses the
|
||||
// whole document): any other key is kept as it is, so a document of many
|
||||
// unknown keys costs one pass, not one parse per key.
|
||||
let pages: Vec<String> = b.keys().cloned().collect();
|
||||
for (k, x) in o {
|
||||
if !pages.contains(k) {
|
||||
b.insert(k.clone(), x.clone());
|
||||
continue;
|
||||
}
|
||||
let mut trial = b.clone();
|
||||
trial.insert(k.clone(), x.clone());
|
||||
if serde_json::from_value::<Prefs>(Value::Object(trial)).is_ok() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -40,6 +40,22 @@ fn prefs_round_trip_through_json() {
|
||||
assert_eq!(Prefs::from_json(&Prefs::default().to_json()), Prefs::default());
|
||||
}
|
||||
|
||||
/// A document with one bad page and many unknown keys recovers its good pages in one pass per
|
||||
/// page, not one full parse per key.
|
||||
#[test]
|
||||
fn prefs_recovery_tries_only_the_settings_pages() {
|
||||
let mut doc = serde_json::Map::new();
|
||||
doc.insert("general".into(), json!({"undoLevels": "not a number"}));
|
||||
doc.insert("labels".into(), serde_json::to_value(Prefs::default()).unwrap()["labels"].clone());
|
||||
for i in 0..20_000 {
|
||||
doc.insert(format!("unknown{i}"), json!({"x": i}));
|
||||
}
|
||||
let t = std::time::Instant::now();
|
||||
let p = Prefs::from_json(&serde_json::Value::Object(doc).to_string());
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed());
|
||||
assert_eq!(p.general, Prefs::default().general);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefs_migrate_old_layout_and_keep_unknown_keys() {
|
||||
let old = r#"{
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ effectcraft-vp9enc = { workspace = true }
|
||||
effectcraft-hevcenc = { workspace = true }
|
||||
effectcraft-av1enc = { workspace = true }
|
||||
image = { workspace = true, features = ["png", "jpeg", "tiff", "exr", "tga"] }
|
||||
exr = { workspace = true }
|
||||
gif = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
|
||||
+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>> {
|
||||
|
||||
@@ -175,7 +175,9 @@ pub fn export(job: &Job, progress: &mut dyn FnMut(&Progress) -> bool) -> Result<
|
||||
pub async fn export_async(job: &Job<'_>, progress: &mut dyn FnMut(&Progress) -> bool) -> Result<Report> {
|
||||
let t0 = Instant::now();
|
||||
let cx = Cx::new(job);
|
||||
let render = RenderEnd(job.accel);
|
||||
let mut r = export_with(&cx, progress, t0).await;
|
||||
drop(render);
|
||||
if matches!(r, Err(ExportError::Cancelled)) {
|
||||
cx.remove_cancelled_outputs()?;
|
||||
return r;
|
||||
@@ -188,6 +190,18 @@ pub async fn export_async(job: &Job<'_>, progress: &mut dyn FnMut(&Progress) ->
|
||||
r
|
||||
}
|
||||
|
||||
/// Calls [`effectcraft_render::Accelerator::render_end`] when dropped: after the export, and
|
||||
/// also when it panics or its future is dropped (a browser worker aborting it).
|
||||
struct RenderEnd<'a>(Option<&'a dyn effectcraft_render::Accelerator>);
|
||||
|
||||
impl Drop for RenderEnd<'_> {
|
||||
fn drop(&mut self) {
|
||||
if let Some(a) = self.0 {
|
||||
a.render_end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn export_with(cx: &Cx<'_>, progress: &mut dyn FnMut(&Progress) -> bool, t0: Instant) -> Result<Report> {
|
||||
let comp = cx.comp().ok_or(ExportError::NoComp)?;
|
||||
let total = cx.settings.frame_count(comp);
|
||||
@@ -297,7 +311,7 @@ struct StillOutput {
|
||||
|
||||
fn write_still<W: std::io::Write + std::io::Seek>(cx: &Cx, file: &mut W, img: &Image, comp: &Comp, output: StillOutput) -> Result<()> {
|
||||
let StillOutput { format: fmt, channels, size: (w, h), quality } = output;
|
||||
use image::{ExtendedColorType, ImageEncoder, ImageFormat};
|
||||
use image::{ExtendedColorType, ImageEncoder};
|
||||
match fmt {
|
||||
OutputFormat::ExrSequence => {
|
||||
// Linear light, premultiplied (the EXR convention); RGB keeps alpha = 1 over the background.
|
||||
@@ -326,8 +340,16 @@ fn write_still<W: std::io::Write + std::io::Seek>(cx: &Cx, file: &mut W, img: &I
|
||||
data.extend_from_slice(&[c[0], c[1], c[2], a]);
|
||||
}
|
||||
}
|
||||
let buf = image::Rgba32FImage::from_raw(w, h, data).ok_or_else(|| ExportError::Encode("exr buffer".into()))?;
|
||||
image::DynamicImage::ImageRgba32F(buf).write_to(file, ImageFormat::OpenExr).map_err(|e| ExportError::Encode(e.to_string()))
|
||||
// Scan lines with ZIP like `comp.saveFrameAsExr`: `image`'s encoder writes 64×64 RLE tiles,
|
||||
// which FilmCraft and other scan-line readers refuse (#2805).
|
||||
use exr::prelude::{Encoding, Layer, LayerAttributes, SpecificChannels, Vec2, WritableImage};
|
||||
let row = w as usize * 4;
|
||||
let pixels = SpecificChannels::rgba(|p: Vec2<usize>| {
|
||||
let o = p.y() * row + p.x() * 4;
|
||||
(data[o], data[o + 1], data[o + 2], data[o + 3])
|
||||
});
|
||||
let layer = Layer::new((w as usize, h as usize), LayerAttributes::default(), Encoding::SMALL_LOSSLESS, pixels);
|
||||
exr::image::Image::from_layer(layer).write().to_buffered(file).map_err(|e| ExportError::Encode(e.to_string()))
|
||||
}
|
||||
OutputFormat::JpegSequence => {
|
||||
let px = cx.pixels(img, comp, if channels == Channels::Alpha { Channels::Alpha } else { Channels::Rgb }, w, h);
|
||||
|
||||
@@ -393,11 +393,14 @@ impl<'a> Cx<'a> {
|
||||
}
|
||||
let t0 = Instant::now();
|
||||
let r = self.renderer();
|
||||
let planes = r.active_accel()?.comp_frame_yuv420(&r, self.job.comp, t, comp.background)?;
|
||||
let a = r.active_accel()?;
|
||||
let planes = a.comp_frame_yuv420(&r, self.job.comp, t, comp.background)?;
|
||||
let (cw, ch) = (w.div_ceil(2) as usize, h.div_ceil(2) as usize);
|
||||
if planes.len() != w as usize * h as usize + 2 * cw * ch {
|
||||
return None;
|
||||
}
|
||||
// A frame rendered off `passes::comp_frame` ends here; on a `None` the fallback renders and ends it.
|
||||
a.frame_end();
|
||||
if let Some(ft) = &self.frame_times {
|
||||
ft.lock().unwrap_or_else(|e| e.into_inner()).push((i, t0.elapsed().as_secs_f64()));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -24,11 +24,21 @@ struct Fake {
|
||||
/// Frames asking for planes right now, and the most at once after the stop.
|
||||
asking: AtomicUsize,
|
||||
most_after_stop: AtomicUsize,
|
||||
/// `Accelerator::frame_end` calls: one per rendered comp frame, planes or not.
|
||||
frame_ends: AtomicUsize,
|
||||
}
|
||||
|
||||
impl Fake {
|
||||
fn new(yuv: bool, deferred: bool, stop_after: usize) -> Self {
|
||||
Fake { yuv, yuv_frames: AtomicUsize::new(0), deferred, stop_after, asking: AtomicUsize::new(0), most_after_stop: AtomicUsize::new(0) }
|
||||
Fake {
|
||||
yuv,
|
||||
yuv_frames: AtomicUsize::new(0),
|
||||
deferred,
|
||||
stop_after,
|
||||
asking: AtomicUsize::new(0),
|
||||
most_after_stop: AtomicUsize::new(0),
|
||||
frame_ends: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +64,9 @@ impl Accelerator for Fake {
|
||||
}
|
||||
Some(effectcraft_raster::yuv::yuv420_over(&r.comp_frame_cpu(comp, t), bg))
|
||||
}
|
||||
fn frame_end(&self) {
|
||||
self.frame_ends.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
fn available(&self) -> bool {
|
||||
self.yuv_frames.load(Ordering::SeqCst) < self.stop_after
|
||||
}
|
||||
@@ -122,6 +135,8 @@ fn accelerator_planes_encode_like_the_cpu_conversion() {
|
||||
let b = run(&p, cid, Some(&rgba), &s, &d.join("rgba.mp4"));
|
||||
let c = run(&p, cid, None, &s, &d.join("cpu.mp4"));
|
||||
assert_eq!(yuv.yuv_frames.load(Ordering::SeqCst), 20, "every frame came from the accelerator");
|
||||
assert_eq!(yuv.frame_ends.load(Ordering::SeqCst), 20, "every planes frame ends once");
|
||||
assert_eq!(rgba.frame_ends.load(Ordering::SeqCst), 20, "every RGBA frame ends once");
|
||||
assert!(a == b && b == c, "the three files differ");
|
||||
let f = effectcraft_media::probe(&yuv_mp4).expect("probe yuv.mp4");
|
||||
assert_eq!((f.width, f.height), (64, 48));
|
||||
@@ -144,6 +159,7 @@ fn frames_the_accelerator_cannot_take_fall_back_to_rgba() {
|
||||
let a = run(&p, cid, Some(&yuv), &odd, &d.join("yuv.mp4"));
|
||||
let c = run(&p, cid, None, &odd, &d.join("cpu.mp4"));
|
||||
assert_eq!(yuv.yuv_frames.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(yuv.frame_ends.load(Ordering::SeqCst), 6, "every fallback frame ends once");
|
||||
assert!(a == c);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ mod hevc_av1_roundtrip;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod mov_audio_sample_positions;
|
||||
mod movie_bytes;
|
||||
mod movie_color;
|
||||
mod ms_rounded_footage;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native_audio_sample_positions;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -302,6 +302,23 @@ fn exr_sequence_is_linear() {
|
||||
assert!((c[2] - 0.1329).abs() < 0.01 && (c[3] - 1.0).abs() < 1e-6, "{c:?}");
|
||||
}
|
||||
|
||||
/// Sequence frames are scan-line EXR, not tiled, so scan-line readers such as FilmCraft open
|
||||
/// them (#2805).
|
||||
#[test]
|
||||
fn exr_sequence_writes_scan_lines() {
|
||||
let (p, cid) = project(None);
|
||||
let d = out_dir("seq-exr-scanline");
|
||||
let om = OutputModule::for_format(OutputFormat::ExrSequence);
|
||||
run(&p, cid, &NoFootage, &om, &d.join("f_[#####].exr"));
|
||||
let bytes = std::fs::read(d.join("f_00000.exr")).expect("exr");
|
||||
// Version field (little-endian u32 at bytes 4..8): version 2, then the flags byte. Its 0x02
|
||||
// bit is the tiled bit, 0x200 of the field, so a scan-line file has 0 there.
|
||||
assert_eq!(&bytes[4..6], &[2, 0], "{:02x?}", &bytes[..8]);
|
||||
let meta = exr::meta::MetaData::read_from_buffered(std::io::Cursor::new(&bytes), false).expect("exr header");
|
||||
assert_eq!(meta.headers[0].blocks, exr::meta::BlockDescription::ScanLines);
|
||||
assert_eq!(meta.headers[0].compression, exr::compression::Compression::ZIP16);
|
||||
}
|
||||
|
||||
/// A 32 bpc project whose only layer is a full-frame opaque solid of the given colour.
|
||||
fn bright_project(color: [f32; 3]) -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
|
||||
@@ -21,6 +21,7 @@ effectcraft-keyframe = { workspace = true }
|
||||
wgpu = { workspace = true }
|
||||
bytemuck = { workspace = true }
|
||||
log = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
|
||||
@@ -489,7 +489,7 @@ fn raster_into(e: &mut Enc, s: &Scene) -> Option<(wgpu::Texture, wgpu::Texture)>
|
||||
let zout = tex(DEPTH_OUT, rt);
|
||||
let depth = tex(DEPTH, wgpu::TextureUsages::RENDER_ATTACHMENT);
|
||||
let (cv, zv, dv) = (color.create_view(&Default::default()), zout.create_view(&Default::default()), depth.create_view(&Default::default()));
|
||||
let enc = e.encoder();
|
||||
let enc = e.record(&[]);
|
||||
{
|
||||
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("adv3d"),
|
||||
@@ -763,7 +763,7 @@ fn dispatch(e: &mut Enc, kernel: &str, u0: [u32; 4], f0: [f32; 4], b: Binds, gro
|
||||
}
|
||||
entries.push(wgpu::BindGroupEntry { binding: 10, resource: wgpu::BindingResource::TextureView(b.out.unwrap_or(&post.dummy_out)) });
|
||||
let bg = g.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some(kernel), layout: &post.bgl, entries: &entries });
|
||||
let enc = e.encoder();
|
||||
let enc = e.record(&[]);
|
||||
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some(kernel), timestamp_writes: None });
|
||||
pass.set_pipeline(pipe);
|
||||
pass.set_bind_group(0, &bg, &[]);
|
||||
@@ -799,7 +799,7 @@ fn read_buffer(e: &mut Enc, buf: &wgpu::Buffer, size: u64) -> Option<Vec<u8>> {
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
e.encoder().copy_buffer_to_buffer(buf, 0, &rb, 0, size);
|
||||
e.record(&[]).copy_buffer_to_buffer(buf, 0, &rb, 0, size);
|
||||
e.submit();
|
||||
let out = e.g.read_buffer(&rb, row, row, 1).map_err(|e| log::error!("gpu 3d readback: {e}")).ok()?;
|
||||
Some(out)
|
||||
@@ -922,6 +922,7 @@ fn depth_of_field(e: &mut Enc, acc: &wgpu::Buffer, depth: &wgpu::Buffer, (w, h):
|
||||
|
||||
/// The resolved run as an image in the canvas's encoding, composited over `canvas` when given.
|
||||
pub(crate) fn finish(e: &mut Enc, r: &Resolved, encode: bool, canvas: Option<&GpuImage>) -> GpuImage {
|
||||
canvas.inspect(|c| e.uses(c));
|
||||
let out = e.image(r.width, r.height);
|
||||
let ov = out.texture.create_view(&Default::default());
|
||||
let cv = canvas.map(|c| c.texture.create_view(&Default::default()));
|
||||
@@ -1023,7 +1024,7 @@ fn render_prepared_deferred(g: &GpuContext, d: &std::sync::Arc<crate::deferred::
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
e.encoder().copy_buffer_to_buffer(&r.depth, 0, &rb, 0, size);
|
||||
e.record(&[]).copy_buffer_to_buffer(&r.depth, 0, &rb, 0, size);
|
||||
e.submit();
|
||||
let done = d.start(kz, w, h, [0.0; 2], 1.0);
|
||||
g.map_readback(&rb, row, row, 1, move |r| done(r.map_err(|e| log::error!("gpu depth readback: {e}")).ok()));
|
||||
@@ -1080,6 +1081,7 @@ pub(crate) fn sky(e: &mut Enc, sky: &effectcraft_render::three_d::SkyDraw, canva
|
||||
/// Wireframe-quality layer outlines: `canvas` with `pixels` set to opaque white
|
||||
/// ([`effectcraft_render::Renderer::wireframe_pixels`]).
|
||||
pub(crate) fn wireframe(e: &mut Enc, canvas: &GpuImage, pixels: &[(u32, u32)]) -> GpuImage {
|
||||
e.uses(canvas);
|
||||
let out = e.image(canvas.width, canvas.height);
|
||||
e.copy_into(canvas, &out, 0, 0);
|
||||
if pixels.is_empty() {
|
||||
|
||||
+561
-28
@@ -42,7 +42,17 @@ pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
|
||||
const UPLOAD_BUDGET: usize = 1536 << 20;
|
||||
|
||||
/// Working textures kept for reuse once their last image is dropped (bytes).
|
||||
const POOL_BUDGET: usize = 768 << 20;
|
||||
const POOL_BUDGET: usize = 256 << 20;
|
||||
|
||||
/// Frames after which a size the pool was not asked for is stale: only stale sizes are
|
||||
/// evicted to make room (see [`Pool::make_free`]).
|
||||
///
|
||||
/// The clock is coarse: it advances on every finished frame ([`GpuContext::pool_frame_end`])
|
||||
/// from any thread, so with many frames in flight (an export renders up to 16 at once) this
|
||||
/// means "not asked for during the last 2 completions", not "during 2 frames' rendering". A
|
||||
/// size a slower in-flight frame still needs can go stale and be evicted. That costs a pool
|
||||
/// miss (a new allocation), never a wrong pixel.
|
||||
const STALE_FRAMES: u64 = 2;
|
||||
|
||||
/// Readback staging buffers kept for reuse across sizes (bytes).
|
||||
const STAGING_BUDGET: u64 = 256 << 20;
|
||||
@@ -66,6 +76,20 @@ impl GpuImage {
|
||||
pub fn new(texture: wgpu::Texture, width: u32, height: u32) -> GpuImage {
|
||||
GpuImage { texture, width, height, _lease: None, levels: None }
|
||||
}
|
||||
|
||||
/// This image leaves the [`Enc`] that made it (read by an encoder that may stay open, see
|
||||
/// [`Enc::uses`]): when its last clone drops, its texture waits for every open encoder
|
||||
/// ([`Pool::release`]) instead of going back to that `Enc` for immediate reuse.
|
||||
pub(crate) fn escape(&self) {
|
||||
if let Some(l) = &self._lease {
|
||||
l.escaped.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// A pooled image of another `Enc` than `owner` (see [`GpuImage::escape`]).
|
||||
fn foreign_to(&self, owner: u64) -> bool {
|
||||
self._lease.as_ref().is_some_and(|l| l.owner != owner)
|
||||
}
|
||||
}
|
||||
|
||||
/// Hands a pooled texture back to its [`Pool`] when the last [`GpuImage`] holding it drops.
|
||||
@@ -74,6 +98,10 @@ struct Lease {
|
||||
pool: Weak<Mutex<Pool>>,
|
||||
/// The [`Enc`] that took it (see [`Pool::owned`]).
|
||||
owner: u64,
|
||||
/// Another [`Enc`] recorded work on it ([`Enc::uses`], [`GpuImage::escape`]):
|
||||
/// its owner can no longer reuse it at once, since that other encoder may still be open, so
|
||||
/// it is released through the guarded [`Pool::release`].
|
||||
escaped: std::sync::atomic::AtomicBool,
|
||||
/// The pool's [`Pool::generation`] when it was taken.
|
||||
generation: u64,
|
||||
}
|
||||
@@ -89,7 +117,15 @@ impl Drop for Lease {
|
||||
if let Some(pool) = self.pool.upgrade()
|
||||
&& let Ok(mut p) = pool.lock()
|
||||
{
|
||||
p.release_owned(self.texture.clone(), self.owner, self.generation);
|
||||
// Always undo `pooled`'s leased count first: `Pool::discard` does not reset
|
||||
// `leased`, and a stale-generation lease must still clear its contribution.
|
||||
p.leased = p.leased.saturating_sub(tex_bytes(&self.texture));
|
||||
if !self.escaped.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
p.release_owned(self.texture.clone(), self.owner, self.generation);
|
||||
} else if self.generation == p.generation {
|
||||
// Like `release_owned`, nothing taken before a failed frame comes back (#1319).
|
||||
p.release(self.texture.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,16 +139,42 @@ impl Drop for Lease {
|
||||
/// it was released has been submitted (or dropped).
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Pool {
|
||||
free: HashMap<(u32, u32), Vec<wgpu::Texture>>,
|
||||
/// Free textures by size, each with the [`Pool::stamp`] at which it was freed (oldest first).
|
||||
free: HashMap<(u32, u32), std::collections::VecDeque<(wgpu::Texture, u64)>>,
|
||||
/// Counts frees, so the free list evicts the oldest texture first.
|
||||
stamp: u64,
|
||||
/// Bytes the free list may hold (0 = [`POOL_BUDGET`]); tests lower it per context.
|
||||
budget: usize,
|
||||
/// (texture, newest encoder open at its release).
|
||||
released: Vec<(wgpu::Texture, u64)>,
|
||||
/// Encoders with recorded, unsubmitted work.
|
||||
open: BTreeSet<u64>,
|
||||
next: u64,
|
||||
bytes: usize,
|
||||
/// Bytes currently held by live [`Lease`]s.
|
||||
leased: usize,
|
||||
/// Working textures reused / newly allocated (diagnostics, tests).
|
||||
hits: u64,
|
||||
misses: u64,
|
||||
/// Free textures dropped to make room for newer ones (diagnostics, tests).
|
||||
evicted: u64,
|
||||
/// Output frames done so far ([`GpuContext::pool_frame_end`]).
|
||||
frame: u64,
|
||||
/// Per size, the [`Pool::frame`] in which it was last asked for (hit or miss).
|
||||
asked: HashMap<(u32, u32), u64>,
|
||||
/// Bytes of evicted textures whose GPU memory may not be freed yet: wgpu frees a dropped
|
||||
/// texture only once the submissions that used it are done. Counted against the budget
|
||||
/// for evicting, so evicting and reallocating cannot stack up freed-but-held memory.
|
||||
dying: Arc<std::sync::atomic::AtomicUsize>,
|
||||
/// The [`Pool::dying`] bytes left after a [`GpuContext::pool_frame_end`] wait that cleared
|
||||
/// none of them (`0` = not stalled). While `dying` does not drop below it, later frames only
|
||||
/// poll instead of waiting again (each wait could take up to [`crate::readback::TIMEOUT`]
|
||||
/// when the queue stopped completing work).
|
||||
stalled: usize,
|
||||
/// Number of empty-submission waits from [`GpuContext::pool_frame_end`] (diagnostics, tests).
|
||||
waits: u64,
|
||||
/// Reports when the submissions that may use an evicted texture are done.
|
||||
queue: Option<wgpu::Queue>,
|
||||
/// Textures released while the [`Enc`] that took them is alive, kept for that `Enc` only.
|
||||
/// Its later commands run after everything it recorded or submitted before (one queue,
|
||||
/// recording order), so it can overwrite them at once instead of allocating a new texture
|
||||
@@ -234,13 +296,100 @@ impl Pool {
|
||||
}
|
||||
}
|
||||
|
||||
fn budget(&self) -> usize {
|
||||
if self.budget == 0 { POOL_BUDGET } else { self.budget }
|
||||
}
|
||||
|
||||
/// Keep `t` on the free list if it fits. Otherwise make room by evicting stale sizes, least
|
||||
/// recently freed first: sizes not asked for in the last [`STALE_FRAMES`] output frames (a
|
||||
/// blur bound or padded layer size that no longer occurs), so they age out instead of
|
||||
/// filling the budget for good. Sizes still in use are never evicted for a newcomer: a
|
||||
/// frame that frees more than the budget in a repeating order would otherwise evict, on
|
||||
/// every free, the texture the next call takes first (LRU on a cycle), and miss on every
|
||||
/// take. If nothing stale makes room, `t` is dropped (the kept set keeps hitting).
|
||||
///
|
||||
/// Evicted textures count against the budget ([`Pool::dying`]) until the GPU work
|
||||
/// submitted so far is done. That throttles eviction to roughly one budget ahead of the
|
||||
/// GPU; it is not a hard bound (wgpu frees a dropped texture only when the device is
|
||||
/// maintained, which can be later than the work-done callback).
|
||||
fn make_free(&mut self, t: wgpu::Texture) {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let b = tex_bytes(&t);
|
||||
if self.bytes + b > POOL_BUDGET {
|
||||
let budget = self.budget();
|
||||
if b > budget {
|
||||
return;
|
||||
}
|
||||
let mut evicted = 0;
|
||||
while self.bytes + b > budget {
|
||||
let frame = self.frame;
|
||||
let asked = &self.asked;
|
||||
let stale = |k: &(u32, u32)| asked.get(k).is_none_or(|f| frame.saturating_sub(*f) >= STALE_FRAMES);
|
||||
let Some(oldest) = self.free.iter().filter(|(k, _)| stale(k)).filter_map(|(k, q)| q.front().map(|(_, s)| (*s, *k))).min().map(|(_, k)| k) else {
|
||||
break;
|
||||
};
|
||||
let ob = oldest.0 as usize * oldest.1 as usize * 16;
|
||||
if self.dying.load(Relaxed) + evicted + ob > budget {
|
||||
break;
|
||||
}
|
||||
let Some(q) = self.free.get_mut(&oldest) else { break };
|
||||
if let Some((old, _)) = q.pop_front() {
|
||||
self.bytes -= tex_bytes(&old);
|
||||
evicted += ob;
|
||||
self.evicted += 1;
|
||||
}
|
||||
if q.is_empty() {
|
||||
self.free.remove(&oldest);
|
||||
}
|
||||
}
|
||||
if evicted > 0 {
|
||||
self.dying.fetch_add(evicted, Relaxed);
|
||||
let dying = self.dying.clone();
|
||||
match &self.queue {
|
||||
Some(q) => q.on_submitted_work_done(move || {
|
||||
let _ = dying.try_update(Relaxed, Relaxed, |d| Some(d.saturating_sub(evicted)));
|
||||
}),
|
||||
None => {
|
||||
let _ = dying.try_update(Relaxed, Relaxed, |d| Some(d.saturating_sub(evicted)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.bytes + b > budget {
|
||||
return;
|
||||
}
|
||||
self.bytes += b;
|
||||
self.free.entry((t.width(), t.height())).or_default().push(t);
|
||||
self.stamp += 1;
|
||||
self.free.entry((t.width(), t.height())).or_default().push_back((t, self.stamp));
|
||||
}
|
||||
|
||||
/// An output frame is done: sizes not asked for since age towards [`STALE_FRAMES`].
|
||||
/// Returns the evicted bytes the caller should wait for: 0 when none still count, or when a
|
||||
/// previous wait cleared none and none has been freed since.
|
||||
fn frame_end(&mut self) -> usize {
|
||||
self.frame += 1;
|
||||
let (frame, free) = (self.frame, &self.free);
|
||||
self.asked.retain(|k, f| frame - *f < STALE_FRAMES || free.contains_key(k));
|
||||
let d = self.dying.load(std::sync::atomic::Ordering::Relaxed);
|
||||
if d == 0 {
|
||||
self.stalled = 0;
|
||||
return 0;
|
||||
}
|
||||
if self.stalled != 0 && d >= self.stalled {
|
||||
// No progress since the stalled wait: do not wait again.
|
||||
return 0;
|
||||
}
|
||||
self.stalled = 0;
|
||||
d
|
||||
}
|
||||
|
||||
/// A [`GpuContext::pool_frame_end`] wait finished with `before` dying bytes still counted
|
||||
/// beforehand. A wait that freed nothing (timed out, or callbacks not run synchronously,
|
||||
/// e.g. on WebGPU where poll does not block) marks the pool stalled.
|
||||
fn waited(&mut self, before: usize) {
|
||||
self.waits += 1;
|
||||
let d = self.dying.load(std::sync::atomic::Ordering::Relaxed);
|
||||
if d >= before {
|
||||
self.stalled = d;
|
||||
}
|
||||
}
|
||||
|
||||
fn open(&mut self) -> u64 {
|
||||
@@ -291,14 +440,92 @@ impl Pool {
|
||||
self.release(t);
|
||||
}
|
||||
|
||||
/// An owner for [`Enc::lent`]. It is a fresh id each time, so a lease of an earlier lent
|
||||
/// `Enc` that drops later (an image that outlived its call) finds no owner and goes
|
||||
/// through [`Pool::release`], which waits for every open encoder.
|
||||
fn lend(&mut self) -> u64 {
|
||||
self.register()
|
||||
}
|
||||
|
||||
/// [`Enc::lent`] is done with `owner` (its work submitted or discarded): its kept textures
|
||||
/// go to the shared free list at once. The free list has one [`POOL_BUDGET`] (only stale
|
||||
/// sizes are evicted), so finished calls no longer keep a budget each (16 frame threads
|
||||
/// kept up to 16 × `POOL_BUDGET`). The budget caps the free list only: textures waiting
|
||||
/// for open encoders ([`Pool::released`]) and those held by live `Enc`s are outside it.
|
||||
///
|
||||
/// They skip [`Pool::release`]'s wait for open encoders: a kept texture's lease has
|
||||
/// dropped and only this `Enc` recorded work on it, which is already submitted or
|
||||
/// discarded, so later work cannot overtake it on the queue. That is enforced, not assumed:
|
||||
/// a lease read by another `Enc` is marked escaped
|
||||
/// ([`GpuImage::escape`]) and released through [`Pool::release`] whenever it drops, and a
|
||||
/// lease that drops after this call finds no owner and goes the same way.
|
||||
fn give_back(&mut self, owner: u64) {
|
||||
if let Some(m) = self.owned.remove(&owner) {
|
||||
for t in m.into_values().flatten() {
|
||||
self.make_free(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves `owner`'s returned working textures onto the shared free list (dropping any over
|
||||
/// budget) right after `owner`'s encoder has been submitted, while keeping `owner` registered.
|
||||
fn flush_owned(&mut self, owner: u64) {
|
||||
let Some(m) = self.owned.get_mut(&owner).map(std::mem::take) else { return };
|
||||
for t in m.into_values().flatten() {
|
||||
self.make_free(t);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes the pool will still keep alive (live `Lease`s, other owners' in-flight working sets,
|
||||
/// released textures still guarded by other open encoders, and up to `budget` on the shared
|
||||
/// free list) once `owner` flushes its returned textures and `closing_enc` submits.
|
||||
fn kept_after_flush(&self, owner: u64, closing_enc: Option<u64>) -> usize {
|
||||
let oldest = self.open.iter().copied().find(|&o| Some(o) != closing_enc).unwrap_or(u64::MAX);
|
||||
let mut ready = 0usize;
|
||||
let mut still_released = 0usize;
|
||||
for (t, w) in &self.released {
|
||||
let b = tex_bytes(t);
|
||||
if *w < oldest {
|
||||
ready += b;
|
||||
} else {
|
||||
still_released += b;
|
||||
}
|
||||
}
|
||||
let mut self_owned = 0usize;
|
||||
let mut other_owned = 0usize;
|
||||
for (&k, m) in &self.owned {
|
||||
let b: usize = m.values().flatten().map(tex_bytes).sum();
|
||||
if k == owner {
|
||||
self_owned += b;
|
||||
} else {
|
||||
other_owned += b;
|
||||
}
|
||||
}
|
||||
let free_after = self.bytes.max((self.bytes + ready + self_owned).min(self.budget()));
|
||||
self.leased + other_owned + still_released + free_after
|
||||
}
|
||||
|
||||
/// Bytes of working textures kept for reuse: the free list, those waiting for open
|
||||
/// encoders and those kept by live `Enc`s.
|
||||
fn retained(&self) -> usize {
|
||||
let owned = self.owned.values().flat_map(|m| m.values().flatten());
|
||||
self.released.iter().map(|(t, _)| t).chain(owned).map(tex_bytes).fold(self.bytes, usize::saturating_add)
|
||||
}
|
||||
|
||||
fn take_owned(&mut self, owner: u64, w: u32, h: u32) -> Option<wgpu::Texture> {
|
||||
let t = self.owned.get_mut(&owner)?.get_mut(&(w, h))?.pop()?;
|
||||
self.asked.insert((w, h), self.frame);
|
||||
self.hits += 1;
|
||||
Some(t)
|
||||
}
|
||||
|
||||
fn take(&mut self, w: u32, h: u32) -> Option<wgpu::Texture> {
|
||||
let t = self.free.get_mut(&(w, h)).and_then(Vec::pop);
|
||||
self.asked.insert((w, h), self.frame);
|
||||
let q = self.free.get_mut(&(w, h));
|
||||
let t = q.and_then(|q| q.pop_back()).map(|(t, _)| t);
|
||||
if self.free.get(&(w, h)).is_some_and(|q| q.is_empty()) {
|
||||
self.free.remove(&(w, h));
|
||||
}
|
||||
match &t {
|
||||
Some(t) => {
|
||||
self.bytes -= tex_bytes(t);
|
||||
@@ -411,6 +638,11 @@ pub struct GpuContext {
|
||||
/// Export frame → YUV 4:2:0 kernel (built on first use).
|
||||
pub(crate) yuv: std::sync::OnceLock<Result<crate::yuv::YuvPipe, String>>,
|
||||
readbacks: Arc<crate::readback::Readbacks>,
|
||||
/// Serializes `device.poll` across threads (`wgpu-core 30.0.1` can panic on concurrent
|
||||
/// `Device::maintain` when two callers poll the same device at once). Shared by every
|
||||
/// context on the same device.
|
||||
poll_lock: Arc<Mutex<()>>,
|
||||
memcap: crate::memcap::MemCap,
|
||||
}
|
||||
|
||||
fn tex_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
|
||||
@@ -571,9 +803,12 @@ impl GpuContext {
|
||||
include_str!("shaders/fx_time.wgsl"),
|
||||
include_str!("shaders/fx_pixel2.wgsl"),
|
||||
include_str!("shaders/fx_gen2.wgsl"),
|
||||
include_str!("shaders/inpaint.wgsl"),
|
||||
include_str!("shaders/sky.wgsl"),
|
||||
]
|
||||
.concat();
|
||||
#[cfg(test)]
|
||||
let src = src + crate::tests::MATH_PROBE_SHADER;
|
||||
let module = init_resource(&device, "kernels shader module", || {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("effectcraft kernels"), source: wgpu::ShaderSource::Wgsl(src.into()) })
|
||||
})?;
|
||||
@@ -657,6 +892,8 @@ impl GpuContext {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
pipelines.insert("math_probe", build("math_probe", &layout)?);
|
||||
for e in EFFECT_ENTRIES
|
||||
.iter()
|
||||
.chain(crate::fx_color::KERNELS)
|
||||
@@ -679,6 +916,7 @@ impl GpuContext {
|
||||
.chain(crate::fx_time::KERNELS)
|
||||
.chain(crate::fx_pixel2::KERNELS)
|
||||
.chain(crate::fx_gen2::KERNELS)
|
||||
.chain(crate::inpaint::KERNELS)
|
||||
{
|
||||
match build(e, &layout) {
|
||||
Ok(p) => {
|
||||
@@ -739,6 +977,9 @@ impl GpuContext {
|
||||
init_resource(&device, "dummy storage buffer", || buffer_init(&device, &queue, Some("dummy"), &[0u8; 16], wgpu::BufferUsages::STORAGE))?;
|
||||
let dummy_tex = init_resource(&device, "dummy texture view", || dummy.create_view(&Default::default()))?;
|
||||
let max_dim = device.limits().max_texture_dimension_2d;
|
||||
let memcap = crate::memcap::MemCap::new(&info);
|
||||
let pool_budget = if memcap.cap() > 0 { POOL_BUDGET.min((memcap.cap() / 4) as usize) } else { 0 };
|
||||
let pool = Arc::new(Mutex::new(Pool { queue: Some(queue.clone()), budget: pool_budget, ..Default::default() }));
|
||||
Ok(GpuContext {
|
||||
device,
|
||||
queue,
|
||||
@@ -758,7 +999,7 @@ impl GpuContext {
|
||||
uploads: Mutex::new(Uploads::default()),
|
||||
upload_budget: std::sync::atomic::AtomicUsize::new(UPLOAD_BUDGET),
|
||||
upload_full_warned: Default::default(),
|
||||
pool: Default::default(),
|
||||
pool,
|
||||
staging: Default::default(),
|
||||
transfers: Default::default(),
|
||||
zeros: Default::default(),
|
||||
@@ -770,6 +1011,8 @@ impl GpuContext {
|
||||
particle_states: Default::default(),
|
||||
deferred: None,
|
||||
readbacks,
|
||||
poll_lock: Default::default(),
|
||||
memcap,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -803,10 +1046,25 @@ impl GpuContext {
|
||||
crate::readback::failure_notifier(&self.readbacks)
|
||||
}
|
||||
|
||||
/// Maximum time a blocking poll holds `poll_lock` in one slice so concurrent readbacks are not
|
||||
/// starved behind a long `mem_gate` or readback wait.
|
||||
const POLL_SLICE: std::time::Duration = std::time::Duration::from_millis(20);
|
||||
|
||||
/// All device polling routes through this failure bridge.
|
||||
pub(crate) fn poll_readbacks(&self, kind: wgpu::PollType) -> Result<wgpu::PollStatus, String> {
|
||||
self.readbacks.check()?;
|
||||
self.device.poll(kind).map_err(|e| {
|
||||
let res = {
|
||||
let _guard = match kind {
|
||||
wgpu::PollType::Poll => match self.poll_lock.try_lock() {
|
||||
Ok(g) => g,
|
||||
Err(std::sync::TryLockError::Poisoned(e)) => e.into_inner(),
|
||||
Err(std::sync::TryLockError::WouldBlock) => return Ok(wgpu::PollStatus::Poll),
|
||||
},
|
||||
_ => self.poll_lock.lock().unwrap_or_else(|e| e.into_inner()),
|
||||
};
|
||||
self.device.poll(kind)
|
||||
};
|
||||
res.map_err(|e| {
|
||||
let error = format!("GPU readback poll: {e}");
|
||||
self.readbacks.retire(error.clone());
|
||||
log::error!("{error}");
|
||||
@@ -814,6 +1072,73 @@ impl GpuContext {
|
||||
})
|
||||
}
|
||||
|
||||
/// Waits on `submission_index` up to `timeout` in [`Self::POLL_SLICE`] increments so wgpu
|
||||
/// frees resources dropped before that submission without holding `poll_lock` for seconds at a
|
||||
/// time. A timeout simply moves on without retiring the context or its readbacks; any other
|
||||
/// poll error retires the readbacks through the failure path.
|
||||
fn poll_wait(&self, submission_index: wgpu::SubmissionIndex, timeout: std::time::Duration) {
|
||||
if self.readbacks.check().is_err() {
|
||||
return;
|
||||
}
|
||||
let deadline = web_time::Instant::now() + timeout;
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(web_time::Instant::now());
|
||||
let slice = remaining.min(Self::POLL_SLICE);
|
||||
let res = {
|
||||
let _guard = self.poll_lock.lock().unwrap_or_else(|e| e.into_inner());
|
||||
self.device.poll(wgpu::PollType::Wait { submission_index: Some(submission_index.clone()), timeout: Some(slice) })
|
||||
};
|
||||
let done = match res {
|
||||
Ok(_) => true,
|
||||
Err(wgpu::PollError::Timeout) => false,
|
||||
Err(e) => {
|
||||
let error = format!("GPU poll wait: {e}");
|
||||
self.readbacks.retire(error.clone());
|
||||
log::error!("{error}");
|
||||
true
|
||||
}
|
||||
};
|
||||
if done || remaining <= slice {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes held by wgpu's device allocator (`0` on backends that do not report).
|
||||
pub(crate) fn allocated_bytes(&self) -> u64 {
|
||||
self.device.generate_allocator_report().map_or(0, |r| r.total_allocated_bytes)
|
||||
}
|
||||
|
||||
/// Bytes held in long-lived or still-referenced GPU caches/leases that submitting and waiting
|
||||
/// on `owner` (`closing_enc`) cannot free.
|
||||
fn kept_bytes(&self, owner: u64, closing_enc: Option<u64>) -> u64 {
|
||||
let uploads = self.uploads.lock().map(|u| u.bytes as u64).unwrap_or(0);
|
||||
let pool = self.pool.lock().map(|p| p.kept_after_flush(owner, closing_enc) as u64).unwrap_or(0);
|
||||
let staging: u64 = self.staging.lock().map(|s| s.iter().map(|(k, v)| k * v.len() as u64).sum()).unwrap_or(0);
|
||||
let zeros: u64 = self.zeros.lock().map(|z| z.keys().map(|&(w, h)| w as u64 * h as u64 * 16).sum()).unwrap_or(0);
|
||||
uploads + pool + staging + zeros
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn memcap_waits(&self) -> u64 {
|
||||
self.memcap.waits()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn memcap_warned(&self) -> bool {
|
||||
self.memcap.warned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_memcap_wait_timeout(&self, timeout: std::time::Duration) {
|
||||
self.memcap.set_wait_timeout(timeout);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn lock_poll_for_test(&self) -> std::sync::MutexGuard<'_, ()> {
|
||||
self.poll_lock.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Start a bounded buffer mapping. Retired contexts reject new requests immediately.
|
||||
pub(crate) fn map_readback(
|
||||
&self,
|
||||
@@ -848,25 +1173,52 @@ impl GpuContext {
|
||||
});
|
||||
}
|
||||
|
||||
/// A bounded native readback: polling and receiving share one deadline.
|
||||
/// A bounded native readback: polling and receiving share one deadline, started once
|
||||
/// `poll_lock` is first acquired and polled in [`Self::POLL_SLICE`] slices so lock contention
|
||||
/// never consumes the readback deadline or holds the mutex across the whole timeout.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn read_buffer(&self, buffer: &wgpu::Buffer, row: usize, stride: usize, height: usize) -> Result<Vec<u8>, String> {
|
||||
let deadline = std::time::Instant::now() + crate::readback::TIMEOUT;
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
self.map_readback(buffer, row, stride, height, move |r| {
|
||||
let _ = tx.send(r);
|
||||
});
|
||||
let mut deadline = None;
|
||||
loop {
|
||||
self.readbacks.check()?;
|
||||
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
if let Ok(result) = rx.try_recv() {
|
||||
return result;
|
||||
}
|
||||
let (remaining, slice, poll_res) = {
|
||||
let _guard = self.poll_lock.lock().unwrap_or_else(|e| e.into_inner());
|
||||
self.readbacks.check()?;
|
||||
let d = *deadline.get_or_insert_with(|| std::time::Instant::now() + crate::readback::TIMEOUT);
|
||||
let remaining = d.saturating_duration_since(std::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
(remaining, std::time::Duration::ZERO, Ok(wgpu::PollStatus::Poll))
|
||||
} else {
|
||||
let slice = remaining.min(Self::POLL_SLICE);
|
||||
let res = self.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(slice) });
|
||||
(remaining, slice, res)
|
||||
}
|
||||
};
|
||||
if let Ok(result) = rx.try_recv() {
|
||||
return result;
|
||||
}
|
||||
if remaining.is_zero() {
|
||||
let error = "GPU readback receive timed out; context retired".to_string();
|
||||
self.readbacks.retire(error.clone());
|
||||
return Err(error);
|
||||
}
|
||||
// Block until the GPU is done (bounded by the deadline: a timeout retires the
|
||||
// context) rather than polling and sleeping, which adds latency to every readback.
|
||||
self.poll_readbacks(wgpu::PollType::Wait { submission_index: None, timeout: Some(remaining) })?;
|
||||
match poll_res {
|
||||
Ok(_) => {}
|
||||
Err(wgpu::PollError::Timeout) if remaining > slice => {}
|
||||
Err(e) => {
|
||||
let error = format!("GPU readback poll: {e}");
|
||||
self.readbacks.retire(error.clone());
|
||||
log::error!("{error}");
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
// The callback may be running on another thread that polled the same device.
|
||||
match rx.recv_timeout(remaining.min(std::time::Duration::from_millis(1))) {
|
||||
Ok(result) => return result,
|
||||
@@ -1005,6 +1357,8 @@ impl GpuContext {
|
||||
readbacks: self.readbacks.clone(),
|
||||
software: self.software,
|
||||
yuv: std::sync::OnceLock::new(),
|
||||
poll_lock: self.poll_lock.clone(),
|
||||
memcap: self.memcap.fresh(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1051,10 +1405,19 @@ impl GpuContext {
|
||||
/// one is zeroed.
|
||||
fn pooled(&self, w: u32, h: u32, owner: u64) -> (GpuImage, bool) {
|
||||
let (w, h) = (w.max(1), h.max(1));
|
||||
let (reused, generation) = self.pool.lock().map(|mut p| (p.take_owned(owner, w, h).or_else(|| p.take(w, h)), p.generation)).unwrap_or((None, u64::MAX));
|
||||
let bytes = w as usize * h as usize * 16;
|
||||
let (reused, generation) = self
|
||||
.pool
|
||||
.lock()
|
||||
.map(|mut p| {
|
||||
let t = p.take_owned(owner, w, h).or_else(|| p.take(w, h));
|
||||
p.leased += bytes;
|
||||
(t, p.generation)
|
||||
})
|
||||
.unwrap_or((None, u64::MAX));
|
||||
let was_reused = reused.is_some();
|
||||
let texture = reused.unwrap_or_else(|| self.texture(w, h));
|
||||
let lease = Arc::new(Lease { texture: texture.clone(), pool: Arc::downgrade(&self.pool), owner, generation });
|
||||
let lease = Arc::new(Lease { texture: texture.clone(), pool: Arc::downgrade(&self.pool), owner, escaped: Default::default(), generation });
|
||||
(GpuImage { texture, width: w, height: h, _lease: Some(lease), levels: None }, was_reused)
|
||||
}
|
||||
|
||||
@@ -1081,6 +1444,73 @@ impl GpuContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes of working textures the pool keeps for reuse (see [`GpuContext::trim_pool`]).
|
||||
pub fn pool_retained_bytes(&self) -> usize {
|
||||
self.pool.lock().map(|p| p.retained()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Drop the working textures kept for reuse (a render is done). Textures still waiting for
|
||||
/// an open encoder and those of live `Enc`s are unaffected.
|
||||
pub fn trim_pool(&self) {
|
||||
if let Ok(mut p) = self.pool.lock() {
|
||||
p.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// An output frame is done: the pool ages the sizes not asked for since (only sizes unused
|
||||
/// for [`STALE_FRAMES`] frames are evicted to make room for others). When evicted textures
|
||||
/// still count ([`Pool::dying`]), waits once so their work-done callbacks can clear that
|
||||
/// count; if the wait frees none of them, later frames only poll until `dying` drops (a
|
||||
/// stopped queue must not re-wait [`crate::readback::TIMEOUT`] on every frame).
|
||||
pub fn pool_frame_end(&self) {
|
||||
let before = self.pool.lock().map(|mut p| p.frame_end()).unwrap_or_default();
|
||||
if before > 0 {
|
||||
// A work-done callback waits for a submission; an empty one closes the wait.
|
||||
let index = self.queue.submit([]);
|
||||
self.poll_wait(index, crate::readback::TIMEOUT);
|
||||
if let Ok(mut p) = self.pool.lock() {
|
||||
p.waited(before);
|
||||
}
|
||||
} else {
|
||||
let _ = self.poll_readbacks(wgpu::PollType::Poll);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes the pool's free list may keep (tests lower it on a context of their own).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_pool_budget(&self, bytes: usize) {
|
||||
if let Ok(mut p) = self.pool.lock() {
|
||||
p.budget = bytes;
|
||||
}
|
||||
}
|
||||
|
||||
/// Free textures evicted to make room for newer ones.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn pool_evictions(&self) -> u64 {
|
||||
self.pool.lock().map(|p| p.evicted).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Empty-submission waits from [`GpuContext::pool_frame_end`].
|
||||
#[cfg(test)]
|
||||
pub(crate) fn pool_waits(&self) -> u64 {
|
||||
self.pool.lock().map(|p| p.waits).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Add or subtract phantom [`Pool::dying`] bytes without a work-done callback (tests that
|
||||
/// simulate a queue which stopped completing work).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn pool_charge_dying(&self, bytes: isize) {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
if let Ok(p) = self.pool.lock() {
|
||||
if bytes >= 0 {
|
||||
p.dying.fetch_add(bytes as usize, Relaxed);
|
||||
} else {
|
||||
let sub = (-bytes) as usize;
|
||||
let _ = p.dying.try_update(Relaxed, Relaxed, |d| Some(d.saturating_sub(sub)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Texture pool counters: (working textures reused, newly allocated).
|
||||
pub fn pool_stats(&self) -> (u64, u64) {
|
||||
self.pool.lock().map(|p| (p.hits, p.misses)).unwrap_or_default()
|
||||
@@ -1192,11 +1622,14 @@ pub(crate) struct Enc<'g> {
|
||||
pub g: &'g GpuContext,
|
||||
/// The open command encoder and its id in the texture pool.
|
||||
enc: Option<(wgpu::CommandEncoder, u64)>,
|
||||
last_submit: Option<wgpu::SubmissionIndex>,
|
||||
pending: usize,
|
||||
/// A dispatch named a kernel the device doesn't have (see [`Enc::take_missing`]).
|
||||
missing: bool,
|
||||
/// This `Enc`'s id in the texture pool (textures it may reuse at once).
|
||||
owner: u64,
|
||||
/// `owner` is lent ([`Enc::lent`]) and outlives this `Enc`.
|
||||
lent: bool,
|
||||
}
|
||||
|
||||
/// Images bound to a compute kernel's source, optional auxiliary and output slots.
|
||||
@@ -1213,7 +1646,11 @@ impl Drop for Enc<'_> {
|
||||
if let Some((_, id)) = self.enc.take() {
|
||||
p.close(id);
|
||||
}
|
||||
p.unregister(self.owner);
|
||||
if self.lent {
|
||||
p.give_back(self.owner);
|
||||
} else {
|
||||
p.unregister(self.owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1221,7 +1658,27 @@ impl Drop for Enc<'_> {
|
||||
impl<'g> Enc<'g> {
|
||||
pub fn new(g: &'g GpuContext) -> Enc<'g> {
|
||||
let owner = g.pool.lock().map(|mut p| p.register()).unwrap_or(0);
|
||||
Enc { g, enc: None, pending: 0, missing: false, owner }
|
||||
Enc { g, enc: None, last_submit: None, pending: 0, missing: false, owner, lent: false }
|
||||
}
|
||||
|
||||
/// An `Enc` for self-contained work (an effect chain): once it is dropped, its working
|
||||
/// textures can be reused at once ([`Pool::give_back`]). Its images stay with it, except a
|
||||
/// result another `Enc` records work on ([`Enc::uses`]), whose lease then releases through
|
||||
/// the guarded [`Pool::release`]. A plain [`Enc::new`] hands its
|
||||
/// textures back through the shared pool, where they wait for every open encoder, and a
|
||||
/// GPU frame keeps its encoder open while the CPU renders layers with GPU effects, so every
|
||||
/// effect's intermediates stayed allocated until the frame ended.
|
||||
pub(crate) fn lent(g: &'g GpuContext) -> Enc<'g> {
|
||||
let owner = g.pool.lock().map(|mut p| p.lend()).unwrap_or(0);
|
||||
Enc { g, enc: None, last_submit: None, pending: 0, missing: false, owner, lent: true }
|
||||
}
|
||||
|
||||
/// `img` is used by this `Enc`: if another `Enc` made it, that one must not reuse it at once
|
||||
/// (this encoder may still be open when its lease drops), so mark it escaped.
|
||||
pub(crate) fn uses(&self, img: &GpuImage) {
|
||||
if img.foreign_to(self.owner) {
|
||||
img.escape();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a dispatch since the last call named a kernel the device doesn't have (one
|
||||
@@ -1230,7 +1687,21 @@ impl<'g> Enc<'g> {
|
||||
std::mem::take(&mut self.missing)
|
||||
}
|
||||
|
||||
pub(crate) fn encoder(&mut self) -> &mut wgpu::CommandEncoder {
|
||||
/// The command encoder, to record work that touches `imgs` (every pooled image the commands
|
||||
/// read or write; buffers and textures of the caller's own need not be named). The only
|
||||
/// way to record outside this module: naming the images marks foreign leases escaped
|
||||
/// ([`Enc::uses`]). Callers must name every pooled image the encoder's work uses, here or
|
||||
/// with separate [`Enc::uses`] calls (`record(&[])` plus `uses()` is allowed); this is a
|
||||
/// convention, not enforced, and skipping it reintroduces the reuse race it guards (#1441).
|
||||
pub(crate) fn record(&mut self, imgs: &[&GpuImage]) -> &mut wgpu::CommandEncoder {
|
||||
for img in imgs {
|
||||
self.uses(img);
|
||||
}
|
||||
self.encoder()
|
||||
}
|
||||
|
||||
/// The raw encoder: within this module only, after [`Enc::uses`] on every pooled image.
|
||||
fn encoder(&mut self) -> &mut wgpu::CommandEncoder {
|
||||
let g = self.g;
|
||||
&mut self
|
||||
.enc
|
||||
@@ -1329,7 +1800,8 @@ impl<'g> Enc<'g> {
|
||||
],
|
||||
})
|
||||
});
|
||||
let enc = self.encoder();
|
||||
let imgs: Vec<&GpuImage> = [Some(src), aux, Some(out)].into_iter().flatten().collect();
|
||||
let enc = self.record(&imgs);
|
||||
{
|
||||
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some(entry), timestamp_writes: None });
|
||||
pass.set_pipeline(pipe);
|
||||
@@ -1354,7 +1826,7 @@ impl<'g> Enc<'g> {
|
||||
|
||||
/// Copy `src` into a larger zeroed image at (`x`, `y`) (Buf::pad).
|
||||
pub fn copy_into(&mut self, src: &GpuImage, dst: &GpuImage, x: u32, y: u32) {
|
||||
let enc = self.encoder();
|
||||
let enc = self.record(&[src, dst]);
|
||||
enc.copy_texture_to_texture(
|
||||
wgpu::TexelCopyTextureInfo { texture: &src.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
|
||||
wgpu::TexelCopyTextureInfo { texture: &dst.texture, mip_level: 0, origin: wgpu::Origin3d { x, y, z: 0 }, aspect: wgpu::TextureAspect::All },
|
||||
@@ -1371,7 +1843,7 @@ impl<'g> Enc<'g> {
|
||||
(b > a).then(|| (a as u32, (a - o) as u32, (b - a) as u32))
|
||||
};
|
||||
let (Some((sx, dx, cw)), Some((sy, dy, ch))) = (span(x, w, src.width), span(y, h, src.height)) else { return dst };
|
||||
let enc = self.encoder();
|
||||
let enc = self.record(&[src, &dst]);
|
||||
enc.copy_texture_to_texture(
|
||||
wgpu::TexelCopyTextureInfo { texture: &src.texture, mip_level: 0, origin: wgpu::Origin3d { x: sx, y: sy, z: 0 }, aspect: wgpu::TextureAspect::All },
|
||||
wgpu::TexelCopyTextureInfo { texture: &dst.texture, mip_level: 0, origin: wgpu::Origin3d { x: dx, y: dy, z: 0 }, aspect: wgpu::TextureAspect::All },
|
||||
@@ -1383,7 +1855,7 @@ impl<'g> Enc<'g> {
|
||||
/// Submit recorded work.
|
||||
pub fn submit(&mut self) {
|
||||
if let Some((enc, id)) = self.enc.take() {
|
||||
self.g.queue.submit([enc.finish()]);
|
||||
self.last_submit = Some(self.g.queue.submit([enc.finish()]));
|
||||
if let Ok(mut p) = self.g.pool.lock() {
|
||||
p.close(id);
|
||||
}
|
||||
@@ -1391,8 +1863,34 @@ impl<'g> Enc<'g> {
|
||||
self.pending = 0;
|
||||
}
|
||||
|
||||
/// Submits and waits on this encoder's own submission when the device allocator is over the
|
||||
/// memory cap and enough of that memory is reclaimable, so wgpu frees textures dropped so far.
|
||||
pub fn mem_gate(&mut self) {
|
||||
if !self.g.memcap.due() {
|
||||
return;
|
||||
}
|
||||
let allocated = self.g.allocated_bytes();
|
||||
if allocated <= self.g.memcap.cap() {
|
||||
let _ = self.g.memcap.should_wait(allocated, 0);
|
||||
return;
|
||||
}
|
||||
let closing = self.enc.as_ref().map(|(_, id)| *id);
|
||||
let kept = self.g.kept_bytes(self.owner, closing);
|
||||
if !self.g.memcap.should_wait(allocated, kept) {
|
||||
return;
|
||||
}
|
||||
self.submit();
|
||||
if let Ok(mut p) = self.g.pool.lock() {
|
||||
p.flush_owned(self.owner);
|
||||
}
|
||||
let idx = self.last_submit.take().unwrap_or_else(|| self.g.queue.submit([]));
|
||||
self.g.poll_wait(idx, self.g.memcap.wait_timeout());
|
||||
self.g.memcap.record_wait(self.g.allocated_bytes(), self.g.kept_bytes(self.owner, None));
|
||||
}
|
||||
|
||||
/// Read an image back to the CPU (submits and waits). `None` where readback is impossible.
|
||||
pub fn download(&mut self, img: &GpuImage) -> Option<Image> {
|
||||
self.uses(img);
|
||||
let layout = crate::readback::Layout::new(img.width, img.height, 16, self.g.device.limits().max_buffer_size)
|
||||
.map_err(|e| log::error!("gpu image readback: {e}"))
|
||||
.ok()?;
|
||||
@@ -1401,23 +1899,31 @@ impl<'g> Enc<'g> {
|
||||
data.resize(layout.len / 16, [0.0; 4]);
|
||||
let mut out = Image { width: img.width, height: img.height, data };
|
||||
let dst: &mut [u8] = bytemuck::cast_slice_mut(&mut out.data);
|
||||
self.read_texture_into(&img.texture, img.width, img.height, 16, dst)?;
|
||||
self.read_texture_into(&img.texture, [0, 0, img.width, img.height], 16, dst)?;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Tightly packed texel bytes of a texture (`bpp` bytes per pixel).
|
||||
pub fn read_texture(&mut self, texture: &wgpu::Texture, w: u32, h: u32, bpp: u32) -> Option<Vec<u8>> {
|
||||
self.read_rect(texture, 0, 0, w, h, bpp)
|
||||
}
|
||||
|
||||
/// [`Enc::read_texture`] of a sub-rectangle at (`x`, `y`): a fill only changes its hole, so
|
||||
/// only the hole's bounds need to cross back (`bytes_per_row` is still row-aligned inside).
|
||||
pub fn read_rect(&mut self, texture: &wgpu::Texture, x: u32, y: u32, w: u32, h: u32, bpp: u32) -> Option<Vec<u8>> {
|
||||
let layout =
|
||||
crate::readback::Layout::new(w, h, bpp, self.g.device.limits().max_buffer_size).map_err(|e| log::error!("gpu texture readback: {e}")).ok()?;
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(layout.len).map_err(|e| log::error!("gpu readback allocation: {e}")).ok()?;
|
||||
out.resize(layout.len, 0);
|
||||
self.read_texture_into(texture, w, h, bpp, &mut out)?;
|
||||
self.read_texture_into(texture, [x, y, w, h], bpp, &mut out)?;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// [`Enc::read_texture`] into `out` (`w × bpp × h` bytes), through a reused staging buffer.
|
||||
fn read_texture_into(&mut self, texture: &wgpu::Texture, w: u32, h: u32, bpp: u32, out: &mut [u8]) -> Option<()> {
|
||||
/// [`Enc::read_rect`] into `out` (`w × bpp × h` bytes), through a reused staging buffer.
|
||||
/// `rect` is `[x, y, w, h]`.
|
||||
fn read_texture_into(&mut self, texture: &wgpu::Texture, rect: [u32; 4], bpp: u32, out: &mut [u8]) -> Option<()> {
|
||||
let [x, y, w, h] = rect;
|
||||
if !self.g.can_wait() {
|
||||
return None;
|
||||
}
|
||||
@@ -1440,7 +1946,7 @@ impl<'g> Enc<'g> {
|
||||
})
|
||||
});
|
||||
self.encoder().copy_texture_to_buffer(
|
||||
wgpu::TexelCopyTextureInfo { texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
|
||||
wgpu::TexelCopyTextureInfo { texture, mip_level: 0, origin: wgpu::Origin3d { x, y, z: 0 }, aspect: wgpu::TextureAspect::All },
|
||||
wgpu::TexelCopyBufferInfo { buffer: &buf, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(padded), rows_per_image: Some(h) } },
|
||||
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
|
||||
);
|
||||
@@ -1509,6 +2015,7 @@ impl<'g> Enc<'g> {
|
||||
/// once they arrived (`Some(None)`: the readback failed), else `None` after starting the
|
||||
/// copy if needed and marking the pass missed.
|
||||
pub(crate) fn read_keyed(&mut self, img: &GpuImage, key: u128, offset: [f64; 2], scale: f64) -> Option<Option<crate::deferred::Readback>> {
|
||||
self.uses(img);
|
||||
use crate::deferred::Lookup;
|
||||
let d = self.g.deferred.clone()?;
|
||||
match d.lookup(key) {
|
||||
@@ -1536,6 +2043,7 @@ impl<'g> Enc<'g> {
|
||||
/// Copy an image into a storage buffer (RGBA f32 rows) for kernels that read a third image
|
||||
/// through `data`: (buffer, row length in pixels).
|
||||
pub fn image_rows(&mut self, img: &GpuImage) -> (wgpu::Buffer, u32) {
|
||||
self.uses(img);
|
||||
let row = (img.width * 16).div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
|
||||
let buf = self.g.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("image rows"),
|
||||
@@ -1565,6 +2073,7 @@ impl<'g> Enc<'g> {
|
||||
/// Convert to the viewer's premultiplied RGBA8 texture (egui-wgpu's native texture format),
|
||||
/// with a full mip chain so the viewer minifies it without aliasing (#417).
|
||||
pub fn display(&mut self, img: &GpuImage) -> wgpu::Texture {
|
||||
self.uses(img);
|
||||
let g = self.g;
|
||||
let levels = mip_levels(img.width, img.height);
|
||||
let tex = g.device.create_texture(&wgpu::TextureDescriptor {
|
||||
@@ -1607,6 +2116,30 @@ pub(crate) fn mip_levels(w: u32, h: u32) -> u32 {
|
||||
u32::BITS - w.max(h).max(1).leading_zeros()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pool_stall_tests {
|
||||
use super::Pool;
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
|
||||
#[test]
|
||||
fn frame_end_skips_wait_while_stalled_until_dying_drops() {
|
||||
let mut p = Pool::default();
|
||||
p.dying.store(100, Relaxed);
|
||||
assert_eq!(p.frame_end(), 100);
|
||||
p.waited(100);
|
||||
assert_eq!(p.stalled, 100);
|
||||
assert_eq!(p.waits, 1);
|
||||
assert_eq!(p.frame_end(), 0);
|
||||
p.dying.store(150, Relaxed);
|
||||
assert_eq!(p.frame_end(), 0);
|
||||
p.dying.store(50, Relaxed);
|
||||
assert_eq!(p.frame_end(), 50);
|
||||
assert_eq!(p.stalled, 0);
|
||||
p.dying.store(0, Relaxed);
|
||||
assert_eq!(p.frame_end(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod adapter_name_tests {
|
||||
use super::{match_adapter_name, wanted_adapter_name};
|
||||
|
||||
@@ -101,6 +101,7 @@ pub(crate) fn run_chain(e: &mut Enc, chain: &[FxStep], buf: &Buf, levels: Option
|
||||
e.take_missing();
|
||||
for step in chain {
|
||||
b = apply(e, step.spec.id, &step.ctx, b)?;
|
||||
e.mem_gate();
|
||||
if e.take_missing() {
|
||||
return None;
|
||||
}
|
||||
@@ -158,6 +159,7 @@ impl effectcraft_render::FxTarget for GpuFx<'_, '_> {
|
||||
self.e.take_missing();
|
||||
for step in steps {
|
||||
let Some(next) = apply(self.e, step.spec.id, &step.ctx, cur) else { return false };
|
||||
self.e.mem_gate();
|
||||
// A kernel the device couldn't build: the effects render on the CPU (#613).
|
||||
if self.e.take_missing() {
|
||||
return false;
|
||||
|
||||
@@ -12,9 +12,11 @@
|
||||
//! Layer is chosen; the colouring runs here.
|
||||
|
||||
use effectcraft_color::BlendMode;
|
||||
use effectcraft_effects::EffectCtx;
|
||||
use effectcraft_effects::{Buf, EffectCtx};
|
||||
use effectcraft_raster::Image;
|
||||
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::context::{Enc, GpuImage, Params};
|
||||
use crate::effects::{GBuf, box_passes, gaussian_blur};
|
||||
use crate::ops::mode_id;
|
||||
@@ -380,14 +382,24 @@ fn clamp_plane(e: &mut Enc, pl: &GpuImage, lo: f32, hi: f32) -> GpuImage {
|
||||
// ---------------------------------------------------------------- CC HexTile / CC Mr. Smoothie (stylize3)
|
||||
|
||||
fn hextile(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let r = (ctx.params.f("radius") * b.scale).max(0.5);
|
||||
let c = b.to_px(ctx.params.v2("center"));
|
||||
let (sn, cs) = ctx.params.f("rotate").to_radians().sin_cos();
|
||||
let mut p = Params::default();
|
||||
p.u[0][0] = ctx.params.e("render");
|
||||
p.f[0] = [c.0 as f32, c.1 as f32, r as f32, (ctx.params.f("smearing") / 100.0).clamp(0.0, 1.0) as f32];
|
||||
p.f[1] = [sn as f32, cs as f32, 0.0, 0.0];
|
||||
run(e, "fp2_hextile", &p, b, None, None)
|
||||
let count = (b.img.width as usize).checked_mul(b.img.height as usize)?;
|
||||
if count.checked_mul(8)? > e.g.device.limits().max_storage_buffer_binding_size as usize {
|
||||
return None;
|
||||
}
|
||||
let geometry = effectcraft_effects::HexTile::new(
|
||||
ctx,
|
||||
&Buf { img: Image { width: b.img.width, height: b.img.height, data: vec![] }, offset: b.offset, scale: b.scale },
|
||||
);
|
||||
let stride = (b.img.width as usize).checked_mul(2)?.max(2);
|
||||
let mut coords = vec![0.0; count.checked_mul(2)?];
|
||||
coords.par_chunks_mut(stride).enumerate().for_each(|(y, row)| {
|
||||
for (x, xy) in row.as_chunks_mut::<2>().0.iter_mut().enumerate() {
|
||||
let (sx, sy) = geometry.source_at(x as u32, y as u32);
|
||||
*xy = [sx as f32, sy as f32];
|
||||
}
|
||||
});
|
||||
let data = e.data(&coords);
|
||||
run(e, "fp2_hextile", &Params::default(), b, None, Some(&data))
|
||||
}
|
||||
|
||||
fn smoothie(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
|
||||
@@ -174,6 +174,62 @@ fn parse_map(s: &str) -> Vec<f32> {
|
||||
vals.iter().map(|v| (v * k).clamp(0.0, 1.0)).collect()
|
||||
}
|
||||
|
||||
/// Piecewise-linear lookup in the CPU effect's f64 phase domain. Store the
|
||||
/// first representable f32 at each boundary, its value and the interval slope.
|
||||
/// Computing floor/wrap in shader f32 can select the end of the map instead of
|
||||
/// its beginning at a wrap (a full-scale colour error).
|
||||
fn arbitrary_map_table(map: &[f32], phase: f64) -> Option<Vec<f32>> {
|
||||
let n = map.len();
|
||||
if !(2..=4096).contains(&n) || !phase.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let scale = (n - 1) as f64;
|
||||
let offset = phase * scale / 255.0;
|
||||
if !offset.is_finite() || offset.abs() >= (1u64 << 52) as f64 {
|
||||
return None;
|
||||
}
|
||||
let at = |v: f32| {
|
||||
let x = (v as f64 * scale + offset).rem_euclid(n as f64);
|
||||
let i = x.floor() as usize;
|
||||
let a = *map.get(i).unwrap_or(&0.0);
|
||||
let b = *map.get((i + 1).min(n - 1)).unwrap_or(&a);
|
||||
(a + (b - a) * (x - x.floor()) as f32, (b - a) * scale as f32)
|
||||
};
|
||||
let mut points = vec![0.0f32];
|
||||
// Search ordered positive f32 encodings using the CPU's actual f64 sum.
|
||||
// Merely rounding a mathematical boundary upward misses the half-ulp
|
||||
// region where f64 addition itself rounds across a wrap.
|
||||
let first = offset.floor() + 1.0;
|
||||
for i in 0..=n {
|
||||
let boundary = first + i as f64;
|
||||
if boundary > scale + offset {
|
||||
break;
|
||||
}
|
||||
let (mut lo, mut hi) = (0u32, 1.0f32.to_bits());
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
if f32::from_bits(mid) as f64 * scale + offset < boundary {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
let start = f32::from_bits(lo);
|
||||
if points.last().is_none_or(|last| start > *last) {
|
||||
points.push(start);
|
||||
}
|
||||
}
|
||||
if points.last() != Some(&1.0) {
|
||||
points.push(1.0);
|
||||
}
|
||||
let mut table = Vec::with_capacity(points.len() * 3);
|
||||
for v in points {
|
||||
let (value, slope) = at(v);
|
||||
table.extend([v, value, slope]);
|
||||
}
|
||||
Some(table)
|
||||
}
|
||||
|
||||
fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let pr = ctx.params;
|
||||
let f = |k: &str| pr.f(k) as f32;
|
||||
@@ -308,9 +364,9 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
}
|
||||
"ec.color.psarbitrarymap" => {
|
||||
let map = parse_map(pr.s("map"));
|
||||
p.u[0] = [12, pr.b("applyPhaseMapToAlpha") as u32, map.len() as u32, 0];
|
||||
p.f[0][0] = (pr.f("phase") * (map.len() - 1) as f64 / 255.0) as f32;
|
||||
data = Some(map);
|
||||
let table = arbitrary_map_table(&map, pr.f("phase"))?;
|
||||
p.u[0] = [12, pr.b("applyPhaseMapToAlpha") as u32, (table.len() / 3) as u32, 0];
|
||||
data = Some(table);
|
||||
}
|
||||
"ec.color.videolimiter" => {
|
||||
const CLIP_LEVELS: [f32; 5] = [0.9, 0.95, 1.0, 1.05, 1.09];
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
use effectcraft_effects::{Buf, EffectCtx};
|
||||
use effectcraft_raster::Image;
|
||||
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::context::{Enc, GpuImage, Params};
|
||||
use crate::effects::{GBuf, gaussian_blur};
|
||||
|
||||
@@ -203,25 +205,22 @@ fn grid_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
if done <= 0.0 {
|
||||
return Some(b);
|
||||
}
|
||||
let pr = ctx.params;
|
||||
let c = b.to_px(pr.v2("center"));
|
||||
let rot = pr.f("rotation").to_radians();
|
||||
let border = pr.f("border").max(0.0) * b.scale;
|
||||
let tiles = pr.f("tiles").round().clamp(1.0, 500.0);
|
||||
let shape = pr.e("shape");
|
||||
let cs = (ctx.layer_size[0] * b.scale / tiles).max(1.0);
|
||||
let (s, co) = rot.sin_cos();
|
||||
let metric = |dx: f64, dy: f64| match shape {
|
||||
0 => dx.abs(),
|
||||
2 => dx.abs().max(dy.abs()),
|
||||
_ => dx.hypot(dy),
|
||||
};
|
||||
let maxd = max_corner_dist(&b, c, |dx, dy| metric(dx * co + dy * s, -dx * s + dy * co));
|
||||
let mut p = Params::default();
|
||||
p.u[0] = [shape, pr.b("reverse") as u32, 0, 0];
|
||||
p.f[0] = f4([c.0, c.1, s, co]);
|
||||
p.f[1] = f4([cs, maxd, done, border]);
|
||||
run(e, "ftr_grid", &p, b, None, None)
|
||||
// Decide discontinuous tile coverage in the CPU effect's f64 geometry.
|
||||
// Only a scalar mask is uploaded; source pixels and multiplication stay resident.
|
||||
let count = (b.img.width as usize).checked_mul(b.img.height as usize)?;
|
||||
if count.checked_mul(4)? > e.g.device.limits().max_storage_buffer_binding_size as usize {
|
||||
return None;
|
||||
}
|
||||
let geometry = shape(&b);
|
||||
let grid = effectcraft_effects::GridWipe::new(ctx, &geometry);
|
||||
let mut mask = vec![0.0; count];
|
||||
mask.par_chunks_mut((b.img.width as usize).max(1)).enumerate().for_each(|(y, row)| {
|
||||
for (x, value) in row.iter_mut().enumerate() {
|
||||
*value = grid.coverage(x as u32, y as u32);
|
||||
}
|
||||
});
|
||||
let data = e.data(&mask);
|
||||
run(e, "ftr_grid", &Params::default(), b, None, Some(&data))
|
||||
}
|
||||
|
||||
fn radial_scale_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
|
||||
@@ -0,0 +1,729 @@
|
||||
//! Content-Aware Fill's per-frame synthesis and flow on the GPU: PatchMatch's multi-scale EM
|
||||
//! loop, propagation, random search and vote of `effectcraft_raster::inpaint`, and the block
|
||||
//! matching of `effectcraft_raster::flow::block_flow`, as compute kernels following the CPU's
|
||||
//! steps (`shaders/inpaint.wgsl`).
|
||||
//!
|
||||
//! The fill's input frames are composited on the CPU by design (analyses do not use the job
|
||||
//! worker's GPU: `effectcraft_engine::background::offload::job_accel`), so this module is about the fill
|
||||
//! itself — the part that runs for minutes on a large mask, and whose parallel search is what a
|
||||
//! device wins on. The membrane (harmonic) solve, the flow's hole completion, the orchestration
|
||||
//! ([`effectcraft_raster::inpaint::fill_sequence_with`]), the flow-guided propagation and the
|
||||
//! per-frame flow control stay on the CPU: the membrane is bandwidth-bound (see
|
||||
//! [`FillBackend::patch`]), and the propagation's coupling is sequential, needing every frame of
|
||||
//! the range resident at once.
|
||||
//!
|
||||
//! Parity with the CPU reference is a quality contract, not bit-exactness: the CPU's
|
||||
//! scan-order propagation becomes a two-pass checkerboard, and its in-place relaxation becomes
|
||||
//! Jacobi reads. Both solve the same problem to the same quality (`tests/inpaint.rs`).
|
||||
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_raster::flow::{Flow, block_flow};
|
||||
use effectcraft_raster::inpaint::{FillBackend, complete_flow, dilate};
|
||||
|
||||
use crate::Gpu;
|
||||
use crate::context::{Enc, GpuContext, GpuImage, KernelImages, Params};
|
||||
|
||||
/// The kernels this module adds to the shared pipeline registry (`context.rs`).
|
||||
pub(crate) const KERNELS: &[&str] = &[
|
||||
"imp_down",
|
||||
"imp_up",
|
||||
"imp_jacobi",
|
||||
"imp_pm_down",
|
||||
"imp_pm_up",
|
||||
"imp_nnf_up",
|
||||
"imp_pm_init",
|
||||
"imp_pm_prop",
|
||||
"imp_pm_rand",
|
||||
"imp_nnf_store",
|
||||
"imp_pm_vote",
|
||||
"imp_hist",
|
||||
"imp_fl_half",
|
||||
"imp_fl_search",
|
||||
"imp_fl_subpix",
|
||||
"imp_fl_median",
|
||||
];
|
||||
|
||||
/// Distance histogram bins (`imp_hist`); bin 0's `.g` holds the largest finite distance the
|
||||
/// dispatch saw, for the HDR re-bin.
|
||||
const BINS: usize = 1024;
|
||||
|
||||
/// The distance range `imp_hist`'s bins cover for SDR input. An image whose channels are all ≤ 1
|
||||
/// has `Σ_c (a − b)² ≤ 4`, so no patch distance exceeds it and the bins need no rescaling.
|
||||
const SDR_DISTANCE: f32 = 4.0;
|
||||
|
||||
/// `u`/`f` blocks of a kernel's parameter buffer (see `inpaint.wgsl`'s header).
|
||||
fn params(u0: [u32; 4], u1: [u32; 4], u2: [u32; 4], f0: [f32; 4]) -> Params {
|
||||
Params { u: [u0, u1, u2, [0; 4]], f: [f0, [0.0; 4], [0.0; 4], [0.0; 4], [0.0; 4], [0.0; 4], [0.0; 4], [0.0; 4], [0.0; 4], [0.0; 4], [0.0; 4], [0.0; 4]] }
|
||||
}
|
||||
|
||||
/// `p` with this level's mask offset in `u[3].x` (`inpaint.wgsl`'s `data`).
|
||||
fn with_off(p: Params, off: u32) -> Params {
|
||||
Params { u: [p.u[0], p.u[1], p.u[2], [off, 0, 0, 0]], f: p.f }
|
||||
}
|
||||
|
||||
/// A relaxation sweep count of `membrane_core`. The CPU uses 200 for the small levels and 40 for
|
||||
/// the large ones; this uses 64 and 40. The small levels are where the difference lies: Jacobi
|
||||
/// needs O(n²) sweeps to converge on an n-wide grid, so at the levels the coarse-to-fine schedule
|
||||
/// actually reaches (n ≤ 16, where n² ≤ 256) 64 sweeps is already converged, and finer levels
|
||||
/// start from a good guess. Matching the CPU's 200 measured ≈ 6% slower on the whole fill with no
|
||||
/// change in the result to two decimals (67.0 dB either way), so the schedule is deliberately
|
||||
/// not the CPU's — Jacobi and Gauss–Seidel do not converge alike, and the parity contract is
|
||||
/// quality, not bit-exactness.
|
||||
fn sweep_count(w: usize, h: usize) -> usize {
|
||||
if w * h <= 4096 { 64 } else { 40 }
|
||||
}
|
||||
|
||||
/// PatchMatch's patch radius at a level (`pm_level`).
|
||||
fn radius(patch: usize, w: u32, h: u32) -> u32 {
|
||||
(patch / 2).min(w.min(h).saturating_sub(1) as usize / 2).max(1) as u32
|
||||
}
|
||||
|
||||
/// The bounding box of `mask`'s set pixels, `[x0, y0, x1, y1)`, or `None` when it is empty.
|
||||
fn bbox(mask: &[bool], w: usize, h: usize) -> Option<[u32; 4]> {
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
if mask.get(y * w + x).copied().unwrap_or(false) {
|
||||
x0 = x0.min(x);
|
||||
y0 = y0.min(y);
|
||||
x1 = x1.max(x + 1);
|
||||
y1 = y1.max(y + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
if x0 == usize::MAX { None } else { Some([x0 as u32, y0 as u32, x1 as u32, y1 as u32]) }
|
||||
}
|
||||
|
||||
/// [`bbox`] grown by `n` pixels, clipped to the image.
|
||||
fn grow(b: [u32; 4], n: u32, w: u32, h: u32) -> [u32; 4] {
|
||||
[b[0].saturating_sub(n), b[1].saturating_sub(n), b[2].saturating_add(n).min(w), b[3].saturating_add(n).min(h)]
|
||||
}
|
||||
|
||||
/// The bounding box of the pixels a membrane level still has to solve (`known` false), or
|
||||
/// `None` when every pixel is known.
|
||||
fn unknown_bbox(known: &[f32], w: usize, h: usize) -> Option<[u32; 4]> {
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
if known.get(y * w + x).copied().unwrap_or(1.0) <= 0.5 {
|
||||
x0 = x0.min(x);
|
||||
y0 = y0.min(y);
|
||||
x1 = x1.max(x + 1);
|
||||
y1 = y1.max(y + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
if x0 == usize::MAX { None } else { Some([x0 as u32, y0 as u32, x1 as u32, y1 as u32]) }
|
||||
}
|
||||
|
||||
/// One dispatch's images, its per-pixel `data` table and the region it covers. Grouped into a
|
||||
/// struct because a kernel's bindings plus its bounds exceed clippy's argument limit, and the
|
||||
/// workspace denies `too_many_arguments`.
|
||||
struct Pass<'a> {
|
||||
src: &'a GpuImage,
|
||||
aux: Option<&'a GpuImage>,
|
||||
out: &'a GpuImage,
|
||||
data: Option<&'a wgpu::Buffer>,
|
||||
bb: [u32; 4],
|
||||
}
|
||||
|
||||
/// Dispatch a kernel over `pass.bb`. The box's origin is written into `P.u[1].xy` (the kernels
|
||||
/// place their other `u[1]` fields in `.zw`): deriving it from the box the dispatch is sized from
|
||||
/// keeps a dispatch's covered region and the origin its shader reads from drifting apart, which
|
||||
/// is the subtle way an edge of the region goes unwritten.
|
||||
fn over(e: &mut Enc, entry: &str, p: &Params, pass: Pass<'_>) {
|
||||
let bb = pass.bb;
|
||||
let p = Params { u: [p.u[0], [bb[0], bb[1], p.u[1][2], p.u[1][3]], p.u[2], p.u[3]], f: p.f };
|
||||
let groups = ((bb[2] - bb[0]).div_ceil(16), (bb[3] - bb[1]).div_ceil(16));
|
||||
e.dispatch(entry, &p, KernelImages { src: pass.src, aux: pass.aux, out: pass.out }, pass.data, groups);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ membrane
|
||||
|
||||
/// Relax `fine`'s unknown pixels — the harmonic interpolation of their border — with the CPU's
|
||||
/// coarse-to-fine schedule (`membrane_core`). `spare` is a free texture of the same size; the
|
||||
/// result is returned (it may be `fine`, `spare` or one of the coarse levels).
|
||||
fn membrane_tex(e: &mut Enc, fine: &GpuImage, spare: &GpuImage, hole: &[bool], w: u32, h: u32) -> Option<GpuImage> {
|
||||
let (uw, uh) = (w as usize, h as usize);
|
||||
let n = uw * uh;
|
||||
if hole.len() < n || !hole.iter().take(n).any(|b| *b) || !hole.iter().take(n).any(|b| !*b) {
|
||||
return Some(fine.clone());
|
||||
}
|
||||
// The recursion's levels: halve while the level is wider than 8.
|
||||
let mut dims = vec![(uw, uh)];
|
||||
while let Some(&(cw, ch)) = dims.last() {
|
||||
if !(cw > 8 && ch > 8) || dims.len() >= 32 {
|
||||
break;
|
||||
}
|
||||
dims.push((cw.div_ceil(2), ch.div_ceil(2)));
|
||||
}
|
||||
// The known mask of every level: a coarse pixel is known where any of its 2×2 is.
|
||||
let mut knowns: Vec<Vec<f32>> = Vec::with_capacity(dims.len());
|
||||
knowns.push(hole.iter().take(n).map(|b| if *b { 0.0 } else { 1.0 }).collect());
|
||||
for k in 1..dims.len() {
|
||||
let (pw, ph) = dims[k - 1];
|
||||
let (cw, ch) = dims[k];
|
||||
let prev = &knowns[k - 1];
|
||||
let mut cur = vec![0.0f32; cw * ch];
|
||||
for y in 0..ch {
|
||||
for x in 0..cw {
|
||||
for (dx, dy) in [(0usize, 0usize), (1, 0), (0, 1), (1, 1)] {
|
||||
let (fx, fy) = (2 * x + dx, 2 * y + dy);
|
||||
if fx < pw && fy < ph && prev[fy * pw + fx] > 0.5 {
|
||||
cur[y * cw + x] = 1.0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
knowns.push(cur);
|
||||
}
|
||||
// One buffer for every level's mask (a buffer per level costs a device allocation each).
|
||||
let mut packed: Vec<f32> = Vec::with_capacity(knowns.iter().map(|k| k.len()).sum());
|
||||
let mut offs: Vec<u32> = Vec::with_capacity(knowns.len());
|
||||
for k in &knowns {
|
||||
offs.push(packed.len() as u32);
|
||||
packed.extend_from_slice(k);
|
||||
}
|
||||
let datas = e.data(&packed);
|
||||
let moff = |k: usize| offs.get(k).copied().unwrap_or(0);
|
||||
// The bounds of each level's unknown pixels: the relaxation only touches them (known pixels
|
||||
// never move), which keeps a fill's cost proportional to the hole, not the frame.
|
||||
let mut uns: Vec<Option<[u32; 4]>> = Vec::with_capacity(dims.len());
|
||||
for (k, &(cw, ch)) in dims.iter().enumerate() {
|
||||
uns.push(unknown_bbox(knowns.get(k)?, cw, ch));
|
||||
}
|
||||
// Two textures per coarse level, for the ping-pong sweeps.
|
||||
let mut lv: Vec<GpuImage> = Vec::with_capacity(2 * dims.len());
|
||||
for k in 1..dims.len() {
|
||||
let (cw, ch) = dims[k];
|
||||
lv.push(e.scratch(cw as u32, ch as u32));
|
||||
lv.push(e.scratch(cw as u32, ch as u32));
|
||||
}
|
||||
// Fine to coarse.
|
||||
for k in 1..dims.len() {
|
||||
let (pw, ph) = dims[k - 1];
|
||||
let (cw, ch) = dims[k];
|
||||
let p = with_off(params([pw as u32, ph as u32, cw as u32, ch as u32], [0; 4], [0; 4], [0.0; 4]), moff(k - 1));
|
||||
let src = if k == 1 { fine } else { lv.get(2 * (k - 2))? };
|
||||
e.pixels("imp_down", &p, src, None, lv.get(2 * (k - 1))?, Some(&datas));
|
||||
}
|
||||
// Relax, coarse to fine. `first[k]` says whether level k's image is the first of its pair
|
||||
// (level 0's pair is `fine`/`spare`).
|
||||
let top = dims.len() - 1;
|
||||
let mut first = vec![true; dims.len()];
|
||||
if top >= 1 {
|
||||
let (a, b) = (lv.get(2 * (top - 1))?, lv.get(2 * (top - 1) + 1)?);
|
||||
// Both textures must hold the level before the sweeps: they now write only the hole.
|
||||
e.copy_into(a, b, 0, 0);
|
||||
first[top] = sweeps(e, &datas, moff(top), a, b, Region { w: dims[top].0 as u32, h: dims[top].1 as u32, bb: uns.get(top).copied().flatten() });
|
||||
}
|
||||
for k in (1..top).rev() {
|
||||
let (cw, ch) = dims[k];
|
||||
let (pw, ph) = dims[k + 1];
|
||||
let coarse = lv.get(2 * k + usize::from(!first[k + 1]))?;
|
||||
let (fa, fb) = (lv.get(2 * (k - 1))?, lv.get(2 * (k - 1) + 1)?);
|
||||
e.copy_into(fa, fb, 0, 0);
|
||||
if let Some(bb) = uns.get(k).copied().flatten() {
|
||||
let p = with_off(params([cw as u32, ch as u32, pw as u32, ph as u32], [0; 4], [0; 4], [0.0; 4]), moff(k));
|
||||
over(e, "imp_up", &p, Pass { src: coarse, aux: None, out: fb, data: Some(&datas), bb });
|
||||
}
|
||||
// `imp_up` wrote into `fb`, the pair's second.
|
||||
first[k] = !sweeps(e, &datas, moff(k), fb, fa, Region { w: cw as u32, h: ch as u32, bb: uns.get(k).copied().flatten() });
|
||||
}
|
||||
if top >= 1 {
|
||||
let coarse = lv.get(usize::from(!first[1]))?;
|
||||
e.copy_into(fine, spare, 0, 0);
|
||||
let bb = uns.first().copied().flatten()?;
|
||||
let p = with_off(params([w, h, dims[1].0 as u32, dims[1].1 as u32], [0; 4], [0; 4], [0.0; 4]), moff(0));
|
||||
over(e, "imp_up", &p, Pass { src: coarse, aux: None, out: spare, data: Some(&datas), bb });
|
||||
return Some(if sweeps(e, &datas, moff(0), spare, fine, Region { w, h, bb: Some(bb) }) { spare.clone() } else { fine.clone() });
|
||||
}
|
||||
let bb = uns.first().copied().flatten()?;
|
||||
e.copy_into(fine, spare, 0, 0);
|
||||
Some(if sweeps(e, &datas, moff(0), fine, spare, Region { w, h, bb: Some(bb) }) { fine.clone() } else { spare.clone() })
|
||||
}
|
||||
|
||||
/// A texture's dimensions and the region a dispatch covers (`None`: nothing to do).
|
||||
struct Region {
|
||||
w: u32,
|
||||
h: u32,
|
||||
bb: Option<[u32; 4]>,
|
||||
}
|
||||
|
||||
/// Jacobi relaxation sweeps of a texture pair, over the unknown pixels' bounds `region.bb` (the
|
||||
/// state starts in `a`); returns whether the result is in `a`.
|
||||
fn sweeps(e: &mut Enc, data: &wgpu::Buffer, off: u32, a: &GpuImage, b: &GpuImage, region: Region) -> bool {
|
||||
let Some(bb) = region.bb else { return true };
|
||||
let p = with_off(params([region.w, region.h, 0, 0], [0; 4], [0; 4], [0.0; 4]), off);
|
||||
let mut in_a = true;
|
||||
for _ in 0..sweep_count(region.w as usize, region.h as usize) {
|
||||
let (src, out) = if in_a { (a, b) } else { (b, a) };
|
||||
over(e, "imp_jacobi", &p, Pass { src, aux: None, out, data: Some(data), bb });
|
||||
in_a = !in_a;
|
||||
}
|
||||
in_a
|
||||
}
|
||||
|
||||
/// `img` with the pixels `mask` marks inside `bb` replaced by `rect` — the tightly packed RGBA f32
|
||||
/// rows of the rectangle that was read back. Pixels outside the mask stay the input's, bit for bit.
|
||||
fn patch_back(img: &Image, mask: &[bool], bb: [u32; 4], rect: &[u8]) -> Image {
|
||||
let (w, rw) = (img.width as usize, (bb[2] - bb[0]) as usize);
|
||||
let mut out = img.clone();
|
||||
for y in bb[1]..bb[3] {
|
||||
for x in bb[0]..bb[2] {
|
||||
let i = y as usize * w + x as usize;
|
||||
if !mask.get(i).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let off = ((y - bb[1]) as usize * rw + (x - bb[0]) as usize) * 16;
|
||||
let Some(b) = rect.get(off..off + 16) else { continue };
|
||||
let mut px = [0.0f32; 4];
|
||||
for (c, v) in px.iter_mut().enumerate() {
|
||||
*v = f32::from_le_bytes([b[c * 4], b[c * 4 + 1], b[c * 4 + 2], b[c * 4 + 3]]);
|
||||
}
|
||||
out.data[i] = px;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl FillBackend for Gpu {
|
||||
/// PatchMatch synthesis — the fill's dominant cost, and the stage a device wins on: two to
|
||||
/// three times faster than the CPU's 18-rayon-thread loop at 1920×1080 with a 300×200 hole
|
||||
/// on an Intel Arc iGPU, and far more on a discrete one.
|
||||
///
|
||||
/// The membrane ([`FillBackend::membrane`]) deliberately stays on the CPU: a harmonic solve
|
||||
/// over a hole is only a few tens of milliseconds, and on the same measurement it is *slower*
|
||||
/// on the device (≈ 90 ms against ≈ 55 ms). Most of that is the fill's full-frame data
|
||||
/// movement — uploading the frame, copying it and building the known pyramid — which does not
|
||||
/// shrink with the hole, while the relaxation itself does. The CPU's in-place Gauss–Seidel
|
||||
/// over many cores is already competitive there, so the device only takes the stage where its
|
||||
/// parallel search actually pays. (`membrane_tex` still runs on the device *inside* this
|
||||
/// stage, where it is the coarsest level's start and never crosses back to the CPU.)
|
||||
fn patch(&self, img: &Image, hole: &[bool], init: Option<&Image>, patch: usize, seed: u32) -> Image {
|
||||
// The error scopes make an allocation or validation failure local to this frame: the
|
||||
// CPU fills it instead, rather than an uncaptured error retiring the shared context.
|
||||
if self.ctx.can_wait()
|
||||
&& let Ok(Some(out)) = self.within_memory(|| patch_image(&self.ctx, img, hole, init, patch, seed))
|
||||
{
|
||||
return out;
|
||||
}
|
||||
effectcraft_raster::inpaint::patch_fill(img, hole, init, patch, seed)
|
||||
}
|
||||
|
||||
/// The block matching that the sequence's flow-guided propagation starts from, which is the
|
||||
/// flow's dominant cost. The field is a small `cols × rows` grid (8 px tiles, so 240 × 135 at
|
||||
/// 1080p), and the search is texture-load-bound: at 1920×1080 the CPU's `block_flow` is
|
||||
/// ≈ 65–70 ms against ≈ 55 ms here, most of it the coarsest level's window (`radius 6` over
|
||||
/// up to six seeds) and the finest level's 8×8 blocks. That is a smaller margin than
|
||||
/// [`patch`](Self::patch), but the flow runs twice per frame pair, so on a four-frame 1080p
|
||||
/// sequence it is worth ≈ 0.15× of the fill's total (1.42× against 1.27× with the CPU flow).
|
||||
///
|
||||
/// The field is exhaustive, not heuristic: it matches the CPU's to a float rounding, so the
|
||||
/// propagation sees the same motion. Both A's block per candidate (hoisted into registers)
|
||||
/// and the CPU's `Plane::luma` (computed here by [`effectcraft_raster::flow::luma_pair`] and
|
||||
/// uploaded, because a device may contract `a·b + c` into a multiply-add and round the luma
|
||||
/// differently) are why it is that close.
|
||||
///
|
||||
/// The completion — marking the tiles that touch either hole and interpolating the surrounding
|
||||
/// motion over them — stays on the CPU ([`complete_flow`]). It is a membrane solve over that
|
||||
/// same tiny grid whose cost is the mask, not the solve, so there is little for the device to
|
||||
/// take; keeping it here also keeps the completed field the CPU's to within the search's
|
||||
/// rounding.
|
||||
fn flow(&self, a: &Image, b: &Image, hole_a: &[bool], hole_b: &[bool]) -> Flow {
|
||||
// Frame-local error scopes, as for the patch: a failed GPU search falls back to the CPU.
|
||||
if self.ctx.can_wait()
|
||||
&& let Ok(Some(f)) = self.within_memory(|| flow_image(&self.ctx, a, b))
|
||||
{
|
||||
return complete_flow(f, hole_a, hole_b, a.width as usize, a.height as usize);
|
||||
}
|
||||
complete_flow(block_flow(a, b, 8, 6), hole_a, hole_b, a.width as usize, a.height as usize)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ block flow
|
||||
|
||||
/// `effectcraft_raster::flow::block_flow(a, b, 8, 6)` on the device: the two frames' luma
|
||||
/// pyramids, the coarse-to-fine search, the sub-pixel fit and the 3×3 median, returning the raw
|
||||
/// field ([`complete_flow`] does the hole completion). `None` where the device cannot run it and
|
||||
/// the caller falls back to the CPU.
|
||||
pub(crate) fn flow_image(g: &GpuContext, a: &Image, b: &Image) -> Option<Flow> {
|
||||
let (w, h) = (a.width, a.height);
|
||||
if w == 0 || h == 0 || w != b.width || h != b.height || !g.fits(w, h) {
|
||||
return None;
|
||||
}
|
||||
let (tile, win, radius) = (8u32, 8u32, 6u32);
|
||||
let cols = (w as usize).div_ceil(tile as usize).max(1);
|
||||
let rows = (h as usize).div_ceil(tile as usize).max(1);
|
||||
// block_flow_window's early out: a frame too small to hold one block and window.
|
||||
if w < tile || h < tile || w < win || h < win {
|
||||
return Some(Flow { width: w, height: h, block: tile, cols, rows, v: vec![[0.0; 2]; cols * rows] });
|
||||
}
|
||||
let mut e = Enc::new(g);
|
||||
// The luma pyramid: `Plane::luma` (packed .x/.y), halved while both sides stay worth matching
|
||||
// (`Plane::half`), at most four levels.
|
||||
let mut dims = vec![(w, h)];
|
||||
while dims.len() < 4 {
|
||||
let (cw, ch) = *dims.last()?;
|
||||
if cw / 2 < 16 || ch / 2 < 16 {
|
||||
break;
|
||||
}
|
||||
dims.push(((cw / 2).max(1), (ch / 2).max(1)));
|
||||
}
|
||||
// The luma is the CPU's (`luma_pair`), so the field matches `block_flow`'s bit for bit; the
|
||||
// halves below are exact additions and a multiply by a power of two.
|
||||
let pair = g.upload_image(&effectcraft_raster::flow::luma_pair(a, b))?;
|
||||
let mut luma: Vec<GpuImage> = Vec::with_capacity(dims.len());
|
||||
luma.push(pair);
|
||||
for k in 1..dims.len() {
|
||||
let (pw, ph) = dims[k - 1];
|
||||
let (cw, ch) = dims[k];
|
||||
let dst = e.scratch(cw, ch);
|
||||
e.pixels("imp_fl_half", ¶ms([pw, ph, cw, ch], [0; 4], [0; 4], [0.0; 4]), luma.get(k - 1)?, None, &dst, None);
|
||||
luma.push(dst);
|
||||
}
|
||||
// The coarse-to-fine search: the coarsest level from the zero vector, each finer level from
|
||||
// the doubled vectors of its tile and its four neighbours.
|
||||
let top = dims.len() - 1;
|
||||
let groups = (cols.div_ceil(16) as u32, rows.div_ceil(16) as u32);
|
||||
let mut field: Vec<GpuImage> = Vec::with_capacity(dims.len());
|
||||
for _ in 0..dims.len() {
|
||||
field.push(e.scratch(cols as u32, rows as u32));
|
||||
}
|
||||
for k in (0..dims.len()).rev() {
|
||||
let (pw, ph) = dims[k];
|
||||
let coarser = (k != top).then(|| field.get(k + 1)).flatten();
|
||||
let p = params([pw, ph, cols as u32, rows as u32], [tile, win, 1u32 << k, if k == top { radius } else { 1 }], [u32::from(k != top), 0, 0, 0], [0.0; 4]);
|
||||
e.dispatch("imp_fl_search", &p, KernelImages { src: luma.get(k)?, aux: coarser, out: field.get(k)? }, None, groups);
|
||||
}
|
||||
// The full-resolution sub-pixel fit, then the vector median.
|
||||
let fine = e.scratch(cols as u32, rows as u32);
|
||||
e.dispatch(
|
||||
"imp_fl_subpix",
|
||||
¶ms([w, h, cols as u32, rows as u32], [tile, win, 0, 0], [0; 4], [0.0; 4]),
|
||||
KernelImages { src: luma.first()?, aux: Some(field.first()?), out: &fine },
|
||||
None,
|
||||
groups,
|
||||
);
|
||||
let out = e.scratch(cols as u32, rows as u32);
|
||||
e.dispatch(
|
||||
"imp_fl_median",
|
||||
¶ms([w, h, cols as u32, rows as u32], [0; 4], [0; 4], [0.0; 4]),
|
||||
KernelImages { src: &fine, aux: None, out: &out },
|
||||
None,
|
||||
groups,
|
||||
);
|
||||
if e.take_missing() {
|
||||
return None;
|
||||
}
|
||||
// Only the field crosses back to the CPU: `cols × rows` entries.
|
||||
let rect = e.read_rect(&out.texture, 0, 0, cols as u32, rows as u32, 16)?;
|
||||
let mut v = Vec::with_capacity(cols * rows);
|
||||
for i in 0..cols * rows {
|
||||
let b = rect.get(i * 16..i * 16 + 16)?;
|
||||
let f = |o: usize| b.get(o).copied().unwrap_or(0);
|
||||
v.push([f32::from_le_bytes([f(0), f(1), f(2), f(3)]), f32::from_le_bytes([f(4), f(5), f(6), f(7)])]);
|
||||
}
|
||||
Some(Flow { width: w, height: h, block: tile, cols, rows, v })
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ PatchMatch
|
||||
|
||||
/// One PatchMatch level's plan.
|
||||
struct Lv {
|
||||
w: u32,
|
||||
h: u32,
|
||||
r: u32,
|
||||
/// The target centres: pixels whose patch overlaps the hole (`near`).
|
||||
near: [u32; 4],
|
||||
/// The hole's own bounding box (the vote's dispatch).
|
||||
holes: [u32; 4],
|
||||
/// Where the nearest-neighbour field must be defined: the vote reads it `r` pixels out.
|
||||
field: [u32; 4],
|
||||
flags: wgpu::Buffer,
|
||||
}
|
||||
|
||||
/// The multi-scale PatchMatch synthesis of `patch_fill`: `init` is the starting guess (the
|
||||
/// previous frame's fill warped onto this one), which refines the finest level only.
|
||||
pub(crate) fn patch_image(g: &GpuContext, img: &Image, hole: &[bool], init: Option<&Image>, patch: usize, seed: u32) -> Option<Image> {
|
||||
let (w, h) = (img.width, img.height);
|
||||
if w == 0 || h == 0 || !g.fits(w, h) {
|
||||
return None;
|
||||
}
|
||||
let (uw, uh) = (w as usize, h as usize);
|
||||
let n = uw * uh;
|
||||
if hole.len() < n || !hole.iter().take(n).any(|b| *b) {
|
||||
return Some(img.clone());
|
||||
}
|
||||
let hole: Vec<bool> = hole.iter().take(n).copied().collect();
|
||||
let patch = patch.max(3) | 1;
|
||||
let mut e = Enc::new(g);
|
||||
|
||||
// A refinement of one frame (`patch_fill`'s `init`): the previous frame's fill seeds the
|
||||
// hole and only the finest level runs, from 2 EM rounds of 4 iterations.
|
||||
if let Some(init) = init {
|
||||
if init.width != w || init.height != h {
|
||||
return None;
|
||||
}
|
||||
let mut start = img.clone();
|
||||
for (i, v) in start.data.iter_mut().enumerate() {
|
||||
if hole.get(i).copied().unwrap_or(false) {
|
||||
*v = *init.data.get(i)?;
|
||||
}
|
||||
}
|
||||
let lv = plan_level(&mut e, &hole, w, h, patch)?;
|
||||
let image = g.upload_image(&start)?;
|
||||
let spare = e.scratch(w, h);
|
||||
let nnf = e.image(w, h);
|
||||
let (out, _) = pm_level(&mut e, &lv, PmRun { seed, em: 2, iters: 4 }, &image, &spare, &nnf)?;
|
||||
if e.take_missing() {
|
||||
return None;
|
||||
}
|
||||
let bb = bbox(&hole, uw, uh)?;
|
||||
let rect = e.read_rect(&out.texture, bb[0], bb[1], bb[2] - bb[0], bb[3] - bb[1], 16)?;
|
||||
return Some(patch_back(img, &hole, bb, &rect));
|
||||
}
|
||||
|
||||
// The pyramid: halve while the level is still a few patches across.
|
||||
let mut dims = vec![(w, h)];
|
||||
let mut holes = vec![hole.clone()];
|
||||
while dims.len() < 6 {
|
||||
let &(lw, lh) = dims.last()?;
|
||||
let hl = holes.last()?;
|
||||
if lw.min(lh) / 2 < patch as u32 * 4 || !hl.iter().any(|b| *b) {
|
||||
break;
|
||||
}
|
||||
let (cw, ch) = (lw.div_ceil(2) as usize, lh.div_ceil(2) as usize);
|
||||
let mut nh = vec![false; cw * ch];
|
||||
for y in 0..ch {
|
||||
for x in 0..cw {
|
||||
for (dx, dy) in [(0usize, 0usize), (1, 0), (0, 1), (1, 1)] {
|
||||
let (fx, fy) = ((2 * x + dx).min(lw as usize - 1), (2 * y + dy).min(lh as usize - 1));
|
||||
if hl[fy * lw as usize + fx] {
|
||||
nh[y * cw + x] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !nh.iter().any(|b| !*b) {
|
||||
break;
|
||||
}
|
||||
dims.push((cw as u32, ch as u32));
|
||||
holes.push(nh);
|
||||
}
|
||||
let levels = dims.len();
|
||||
let top = levels - 1;
|
||||
|
||||
let mut plan = Vec::with_capacity(levels);
|
||||
for k in 0..levels {
|
||||
plan.push(plan_level(&mut e, holes.get(k)?, dims[k].0, dims[k].1, patch)?);
|
||||
}
|
||||
|
||||
// One image per level (the coarse-to-fine steps and the vote ping-pong in fresh textures).
|
||||
let mut ima: Vec<GpuImage> = Vec::with_capacity(levels);
|
||||
ima.push(g.upload_image(img)?);
|
||||
for k in 1..levels {
|
||||
let (cw, ch) = dims[k];
|
||||
ima.push(e.scratch(cw, ch));
|
||||
}
|
||||
for k in 1..levels {
|
||||
let (pw, ph) = dims[k - 1];
|
||||
let (cw, ch) = dims[k];
|
||||
e.pixels("imp_pm_down", ¶ms([pw, ph, cw, ch], [0; 4], [0; 4], [0.0; 4]), &ima[k - 1], None, &ima[k], Some(&plan[k - 1].flags));
|
||||
}
|
||||
// The coarsest level starts as its membrane fill.
|
||||
let (cw, ch) = dims[top];
|
||||
let spare = e.scratch(cw, ch);
|
||||
ima[top] = membrane_tex(&mut e, &ima[top], &spare, &holes[top], cw, ch)?;
|
||||
|
||||
// Coarse to fine: upsample, then the EM rounds of propagation and voting.
|
||||
let mut nnf: Option<GpuImage> = None;
|
||||
for k in (0..levels).rev() {
|
||||
let lv = &plan[k];
|
||||
nnf = Some(match nnf {
|
||||
Some(coarse) => {
|
||||
let (cw, ch) = dims[k + 1];
|
||||
let fine = e.scratch(lv.w, lv.h);
|
||||
e.pixels("imp_nnf_up", ¶ms([lv.w, lv.h, cw, ch], [0; 4], [0; 4], [0.0; 4]), &coarse, None, &fine, None);
|
||||
fine
|
||||
}
|
||||
None => e.image(lv.w, lv.h),
|
||||
});
|
||||
if k < top {
|
||||
let (cw, ch) = dims[k + 1];
|
||||
let dst = e.scratch(lv.w, lv.h);
|
||||
e.copy_into(&ima[k], &dst, 0, 0);
|
||||
e.pixels("imp_pm_up", ¶ms([lv.w, lv.h, cw, ch], [0; 4], [0; 4], [0.0; 4]), &ima[k + 1], None, &dst, Some(&lv.flags));
|
||||
ima[k] = dst;
|
||||
}
|
||||
let (em, iters) = if k == top { (8, 5) } else { (4, 5) };
|
||||
let spare = e.scratch(lv.w, lv.h);
|
||||
let run = PmRun { seed: seed.wrapping_add(k as u32 * 101), em, iters };
|
||||
let (out, out_nnf) = pm_level(&mut e, lv, run, &ima[k], &spare, nnf.as_ref()?)?;
|
||||
ima[k] = out;
|
||||
nnf = Some(out_nnf);
|
||||
}
|
||||
if e.take_missing() {
|
||||
return None;
|
||||
}
|
||||
// Only the hole changes, so only the hole's bounds cross back to the CPU.
|
||||
let bb = bbox(&hole, uw, uh)?;
|
||||
let rect = e.read_rect(&ima[0].texture, bb[0], bb[1], bb[2] - bb[0], bb[3] - bb[1], 16)?;
|
||||
Some(patch_back(img, &hole, bb, &rect))
|
||||
}
|
||||
|
||||
/// The plan of one level: its flags (`bit 0` hole, `bit 1` a target centre) and the dispatch
|
||||
/// bounds of its targets, holes and field.
|
||||
fn plan_level(e: &mut Enc, hole: &[bool], w: u32, h: u32, patch: usize) -> Option<Lv> {
|
||||
let (iw, ih) = (w as usize, h as usize);
|
||||
let r = radius(patch, w, h);
|
||||
let near = dilate(hole, iw, ih, r as usize);
|
||||
let near_bb = bbox(&near, iw, ih)?;
|
||||
let flags: Vec<f32> = (0..iw * ih).map(|i| (u32::from(hole[i]) + 2 * u32::from(near[i])) as f32).collect();
|
||||
Some(Lv { w, h, r, near: near_bb, holes: bbox(hole, iw, ih)?, field: grow(near_bb, r + 1, w, h), flags: e.data(&flags) })
|
||||
}
|
||||
|
||||
/// The PatchMatch run at one level: the random stream's seed and the EM round and iteration
|
||||
/// counts.
|
||||
struct PmRun {
|
||||
seed: u32,
|
||||
em: usize,
|
||||
iters: usize,
|
||||
}
|
||||
|
||||
/// `pm_level`: `run.em` EM rounds of propagation and voting at one level. `img` holds the level's
|
||||
/// image and `img_spare` is a free texture of the same size; the nearest-neighbour field starts
|
||||
/// at `nnf`. Returns the level's filled image and its field.
|
||||
fn pm_level(e: &mut Enc, lv: &Lv, run: PmRun, img: &GpuImage, img_spare: &GpuImage, nnf: &GpuImage) -> Option<(GpuImage, GpuImage)> {
|
||||
let (seed, em, iters) = (run.seed, run.em, run.iters);
|
||||
let (r, w, h) = (lv.r, lv.w, lv.h);
|
||||
let field_a = e.scratch(w, h);
|
||||
let field_b = e.scratch(w, h);
|
||||
let hist = e.scratch(BINS as u32, 1);
|
||||
let (img_a, img_b) = (img.clone(), img_spare.clone());
|
||||
let (mut field_nnf, mut nnf_spare) = (nnf.clone(), e.scratch(w, h));
|
||||
// The propagation and random search write the targets and the one-pixel ring around them, so
|
||||
// every neighbour they read has a defined alpha (`grow(near, 1)`). The dispatch origin must
|
||||
// be the ring's, not the targets': `over` sizes the dispatch from the box it is given.
|
||||
let ring = grow(lv.near, 1, w, h);
|
||||
let mut in_a = true;
|
||||
for round in 0..em {
|
||||
// The round's initial field: the inherited source where it is still valid, else random.
|
||||
// Every pixel of the field's bounds is written, so the previous round's field is free.
|
||||
let img = if in_a { &img_a } else { &img_b };
|
||||
let p = params([w, h, r, 0], [0; 4], [seed, 0, 0, round as u32], [0.0; 4]);
|
||||
over(e, "imp_pm_init", &p, Pass { src: &field_nnf, aux: Some(img), out: &field_a, data: Some(&lv.flags), bb: lv.field });
|
||||
let mut f_in_a = true;
|
||||
for it in 0..iters {
|
||||
for parity in 0..2u32 {
|
||||
let (s, o) = if f_in_a { (&field_a, &field_b) } else { (&field_b, &field_a) };
|
||||
let p = params([w, h, r, parity], [0; 4], [seed, it as u32, 0, round as u32], [0.0; 4]);
|
||||
over(e, "imp_pm_prop", &p, Pass { src: s, aux: Some(img), out: o, data: Some(&lv.flags), bb: ring });
|
||||
f_in_a = !f_in_a;
|
||||
}
|
||||
let (s, o) = if f_in_a { (&field_a, &field_b) } else { (&field_b, &field_a) };
|
||||
let p = params([w, h, r, 0], [0; 4], [seed, it as u32, 0, round as u32], [0.0; 4]);
|
||||
over(e, "imp_pm_rand", &p, Pass { src: s, aux: Some(img), out: o, data: Some(&lv.flags), bb: ring });
|
||||
f_in_a = !f_in_a;
|
||||
}
|
||||
let field = if f_in_a { &field_a } else { &field_b };
|
||||
// The vote's sigma²: the 75th percentile of the targets' match distances (`pm_level`).
|
||||
let sigma2 = sigma2(e, field, lv, &hist)?;
|
||||
let next_img = if in_a { &img_b } else { &img_a };
|
||||
e.copy_into(img, next_img, 0, 0);
|
||||
let p = params([w, h, r, 0], [0; 4], [0; 4], [sigma2, 0.0, 0.0, 0.0]);
|
||||
over(e, "imp_pm_vote", &p, Pass { src: field, aux: Some(img), out: next_img, data: Some(&lv.flags), bb: lv.holes });
|
||||
// Carry the round's sources into the nearest-neighbour field (targets only).
|
||||
e.pixels("imp_nnf_store", ¶ms([w, h, 0, 0], [0; 4], [0; 4], [0.0; 4]), field, Some(&field_nnf), &nnf_spare, Some(&lv.flags));
|
||||
std::mem::swap(&mut field_nnf, &mut nnf_spare);
|
||||
in_a = !in_a;
|
||||
}
|
||||
Some((if in_a { img_a } else { img_b }, field_nnf))
|
||||
}
|
||||
|
||||
/// The 75th percentile of a 1024-bin distance histogram (`imp_hist`'s readback) covering
|
||||
/// `0..range`, and the histogram's total. The bin's centre is the estimate: within half a bin
|
||||
/// (`range/2048`) of the CPU's exact percentile.
|
||||
fn percentile(bytes: &[u8], range: f32) -> (f32, f64) {
|
||||
let bin = |i: usize| -> f64 {
|
||||
match bytes.get(i * 16..i * 16 + 4) {
|
||||
Some(b) => f32::from_le_bytes([b[0], b[1], b[2], b[3]]) as f64,
|
||||
None => 0.0,
|
||||
}
|
||||
};
|
||||
let total: f64 = (0..BINS).map(bin).sum();
|
||||
if total < 1.0 {
|
||||
return (1.0, total);
|
||||
}
|
||||
let rank = total * 3.0 / 4.0;
|
||||
let mut cum = 0.0f64;
|
||||
let mut at = BINS - 1;
|
||||
for i in 0..BINS {
|
||||
cum += bin(i);
|
||||
if cum > rank {
|
||||
at = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let centre = ((at as f32) + 0.5) * range / BINS as f32;
|
||||
(centre.max(1e-5), total)
|
||||
}
|
||||
|
||||
/// The largest finite distance `imp_hist` saw, from bin 0's `.g` channel.
|
||||
fn max_distance(bytes: &[u8]) -> f32 {
|
||||
match bytes.get(4..8) {
|
||||
Some(b) => f32::from_le_bytes([b[0], b[1], b[2], b[3]]),
|
||||
None => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// `imp_hist` over `bb`, the targets' bounds: the 75th percentile of the finite distances and the
|
||||
/// sample count. SDR input is binned over the fixed [`SDR_DISTANCE`] range; a frame with values
|
||||
/// above 1 (32-bpc HDR) has larger distances, so the second dispatch bins over the observed
|
||||
/// maximum instead. Clamping those to the SDR range would report a sigma² of ≈ 4 for a distance
|
||||
/// of 1600, and `imp_pm_vote`'s weights would round to zero and keep the prior image.
|
||||
///
|
||||
/// One 16 KB readback per EM round (≈ 28 per 1080p patch) on the SDR path: a few percent of the
|
||||
/// stage, and the obvious next thing to remove if the patch has to be faster (a device-side
|
||||
/// percentile would need a reduction instead). HDR frames pay one more dispatch and readback.
|
||||
fn histogram(e: &mut Enc, field: &GpuImage, flags: &wgpu::Buffer, w: u32, h: u32, bb: [u32; 4], hist: &GpuImage) -> Option<(f32, f64)> {
|
||||
let (bw, bh) = (bb[2] - bb[0], bb[3] - bb[1]);
|
||||
let mut p = params([w, h, 0, 0], [bb[0], bb[1], bw, bh], [0; 4], [0.0; 4]);
|
||||
e.dispatch("imp_hist", &p, KernelImages { src: field, aux: None, out: hist }, Some(flags), (1, 1));
|
||||
let bytes = e.read_texture(&hist.texture, BINS as u32, 1, 16)?;
|
||||
let max = max_distance(&bytes);
|
||||
if max <= SDR_DISTANCE {
|
||||
return Some(percentile(&bytes, SDR_DISTANCE));
|
||||
}
|
||||
// The observed maximum itself has to be in the last bin, so widen the range by a hair rather
|
||||
// than binning it to 1024.
|
||||
let range = max * (1.0 + 1e-6);
|
||||
p.f[0] = [range, 0.0, 0.0, 0.0];
|
||||
e.dispatch("imp_hist", &p, KernelImages { src: field, aux: None, out: hist }, Some(flags), (1, 1));
|
||||
let bytes = e.read_texture(&hist.texture, BINS as u32, 1, 16)?;
|
||||
Some(percentile(&bytes, range))
|
||||
}
|
||||
|
||||
/// The vote's sigma² at a level (`pm_level`): the 75th percentile of the targets' distances.
|
||||
fn sigma2(e: &mut Enc, field: &GpuImage, lv: &Lv, hist: &GpuImage) -> Option<f32> {
|
||||
Some(histogram(e, field, &lv.flags, lv.w, lv.h, lv.near, hist)?.0)
|
||||
}
|
||||
|
||||
/// [`imp_hist`] on its own: `field`'s match distance (`.b`, `.a` > 0.5 where set) over the
|
||||
/// pixels `near` marks, returning the 75th percentile and the sample count. Used by the test that
|
||||
/// checks the histogram counts the whole target population.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn distance_histogram(g: &GpuContext, field: &Image, near: &[bool], w: u32, h: u32) -> Option<(f32, f64)> {
|
||||
let flags: Vec<f32> = near.iter().map(|b| if *b { 2.0 } else { 0.0 }).collect();
|
||||
let mut e = Enc::new(g);
|
||||
let data = e.data(&flags);
|
||||
let img = g.upload_image(field)?;
|
||||
let hist = e.scratch(BINS as u32, 1);
|
||||
let out = histogram(&mut e, &img, &data, w, h, [0, 0, w, h], &hist);
|
||||
if e.take_missing() {
|
||||
return None;
|
||||
}
|
||||
out
|
||||
}
|
||||
+28
-1
@@ -79,6 +79,8 @@ mod fx_tone;
|
||||
mod fx_transition;
|
||||
mod fx_vr;
|
||||
mod fx_warp;
|
||||
mod inpaint;
|
||||
mod memcap;
|
||||
mod ops;
|
||||
mod particles;
|
||||
mod readback;
|
||||
@@ -397,7 +399,7 @@ impl Accelerator for Gpu {
|
||||
|
||||
fn effects(&self, chain: &[FxStep], buf: &Buf, levels: Option<f32>) -> Option<Buf> {
|
||||
self.ctx.check_health().ok()?;
|
||||
effects::run_chain(&mut Enc::new(&self.ctx), chain, buf, levels)
|
||||
effects::run_chain(&mut Enc::lent(&self.ctx), chain, buf, levels)
|
||||
}
|
||||
|
||||
fn raster_3d(&self, scene: &effectcraft_render::three_d::adv::Scene) -> Option<effectcraft_render::three_d::adv::Target> {
|
||||
@@ -415,16 +417,41 @@ impl Accelerator for Gpu {
|
||||
self.ctx.can_readback().then_some(self as &dyn effectcraft_effects::psim::ParticleSim)
|
||||
}
|
||||
|
||||
fn inpaint(&self) -> Option<&dyn effectcraft_raster::inpaint::FillBackend> {
|
||||
self.ctx.check_health().ok()?;
|
||||
// Readback is synchronous-only, and a kernel that failed to build would make every stage
|
||||
// fall back to the CPU *after* paying the upload and a failed dispatch (and the fill
|
||||
// would still report the GPU path). Neither is a backend at all.
|
||||
if !self.ctx.can_wait() || self.ctx.unavailable_kernels().iter().any(|(e, _)| crate::inpaint::KERNELS.contains(e)) {
|
||||
return None;
|
||||
}
|
||||
Some(self as &dyn effectcraft_raster::inpaint::FillBackend)
|
||||
}
|
||||
|
||||
fn auto_pick(&self) -> Option<&effectcraft_render::AutoPick> {
|
||||
Some(&self.auto)
|
||||
}
|
||||
|
||||
fn render_end(&self) {
|
||||
let (hits, misses) = self.ctx.pool_stats();
|
||||
let pct = hits.saturating_mul(100) / hits.saturating_add(misses).max(1);
|
||||
log::debug!(
|
||||
target: "effectcraft_gpu",
|
||||
"gpu pool: {hits} working textures reused, {misses} allocated ({pct}% reuse) so far"
|
||||
);
|
||||
self.ctx.trim_pool();
|
||||
}
|
||||
|
||||
fn frame_begin(&self) {
|
||||
if let Some(d) = self.ctx.deferred() {
|
||||
d.frame_begin();
|
||||
}
|
||||
}
|
||||
|
||||
fn frame_end(&self) {
|
||||
self.ctx.pool_frame_end();
|
||||
}
|
||||
|
||||
fn pass_begin(&self) {
|
||||
if let Some(d) = self.ctx.deferred() {
|
||||
d.pass_begin();
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
//! Memory back-pressure for frames in flight.
|
||||
//!
|
||||
//! wgpu destroys a texture or buffer we drop only once the submissions that used it have
|
||||
//! completed, and pooled textures released while other frames' encoders are open stay pending
|
||||
//! until then. With several 4K frames in flight, the frames record work faster than the GPU
|
||||
//! retires it, and freed-but-not-destroyed memory runs the device out of memory (#879). Once the
|
||||
//! allocator holds more than the cap and enough of that total is reclaimable (not held by the
|
||||
//! upload cache, the texture pool, or live images), a frame submits its encoder and waits for its
|
||||
//! own submission so wgpu frees what was dropped. Frames are not held back for memory that cannot
|
||||
//! be reclaimed; a warning is logged once.
|
||||
//!
|
||||
//! wgpu has no safe query for the adapter's memory size (`max_buffer_size` is capped at `i32::MAX`
|
||||
//! on Direct3D 12 and Linux/Mesa Vulkan), so the default cap is conservative per device type;
|
||||
//! `EFFECTCRAFT_GPU_MEMCAP_MB` overrides it (0 = off). Backends without an allocator report
|
||||
//! (Metal, WebGPU, GL) are never gated.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering::Relaxed};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Check the allocator only every this many gate calls (effect calls and layers): the report
|
||||
/// lists every allocation, and between checks a frame adds at most a few textures.
|
||||
const EVERY: u64 = 8;
|
||||
|
||||
const GIB: u64 = 1 << 30;
|
||||
const MIB: u64 = 1 << 20;
|
||||
|
||||
/// Overrides the default cap: whole MiB, 0 turns the gate off.
|
||||
const MEMCAP_ENV: &str = "EFFECTCRAFT_GPU_MEMCAP_MB";
|
||||
|
||||
pub(crate) struct MemCap {
|
||||
cap: u64,
|
||||
hysteresis: u64,
|
||||
calls: AtomicU64,
|
||||
waits: AtomicU64,
|
||||
/// Reclaimable bytes remaining after the last wait: another wait fires only once reclaimable
|
||||
/// memory grows by `hysteresis` above this floor (or drops below it first).
|
||||
floor: AtomicU64,
|
||||
/// Set after the first over-cap sample where non-reclaimable memory alone exceeds the cap.
|
||||
warned: AtomicBool,
|
||||
#[cfg(test)]
|
||||
timeout_ns: AtomicU64,
|
||||
}
|
||||
|
||||
impl MemCap {
|
||||
pub(crate) fn new(info: &wgpu::AdapterInfo) -> MemCap {
|
||||
let env = std::env::var(MEMCAP_ENV).ok();
|
||||
Self::from_env(env.as_deref(), info.device_type)
|
||||
}
|
||||
|
||||
fn from_env(var: Option<&str>, device_type: wgpu::DeviceType) -> MemCap {
|
||||
let cap = cap_from_env(var, device_type);
|
||||
MemCap {
|
||||
cap,
|
||||
hysteresis: default_hysteresis(cap),
|
||||
calls: AtomicU64::new(0),
|
||||
waits: AtomicU64::new(0),
|
||||
floor: AtomicU64::new(0),
|
||||
warned: AtomicBool::new(false),
|
||||
#[cfg(test)]
|
||||
timeout_ns: AtomicU64::new(crate::readback::TIMEOUT.as_nanos() as u64),
|
||||
}
|
||||
}
|
||||
|
||||
/// The same cap with fresh counters (a test context sharing another's device).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn fresh(&self) -> MemCap {
|
||||
MemCap {
|
||||
cap: self.cap,
|
||||
hysteresis: self.hysteresis,
|
||||
calls: AtomicU64::new(0),
|
||||
waits: AtomicU64::new(0),
|
||||
floor: AtomicU64::new(0),
|
||||
warned: AtomicBool::new(false),
|
||||
timeout_ns: AtomicU64::new(self.timeout_ns.load(Relaxed)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn cap(&self) -> u64 {
|
||||
self.cap
|
||||
}
|
||||
|
||||
/// Whether to sample the allocator on this call.
|
||||
pub(crate) fn due(&self) -> bool {
|
||||
self.cap != 0 && self.calls.fetch_add(1, Relaxed).is_multiple_of(EVERY)
|
||||
}
|
||||
|
||||
/// Whether `allocated` bytes in the allocator with `kept` non-reclaimable bytes (upload
|
||||
/// cache, pool free list, live frames) should trigger a submit and wait.
|
||||
pub(crate) fn should_wait(&self, allocated: u64, kept: u64) -> bool {
|
||||
if self.cap == 0 || allocated <= self.cap {
|
||||
self.floor.store(0, Relaxed);
|
||||
return false;
|
||||
}
|
||||
if kept > self.cap && !self.warned.swap(true, Relaxed) {
|
||||
log::warn!(
|
||||
target: "effectcraft_gpu",
|
||||
"gpu: {} MiB held by live frames, the upload cache and the texture pool is over the {} MiB memory cap: waiting cannot free it, so frames are not held back for it ({MEMCAP_ENV} sets the cap)",
|
||||
kept >> 20,
|
||||
self.cap >> 20,
|
||||
);
|
||||
}
|
||||
let reclaimable = allocated.saturating_sub(kept);
|
||||
let floor = self.floor.load(Relaxed);
|
||||
if reclaimable < floor {
|
||||
self.floor.store(reclaimable, Relaxed);
|
||||
return false;
|
||||
}
|
||||
reclaimable >= floor.saturating_add(self.hysteresis)
|
||||
}
|
||||
|
||||
pub(crate) fn record_wait(&self, allocated_after: u64, kept_after: u64) {
|
||||
self.floor.store(allocated_after.saturating_sub(kept_after), Relaxed);
|
||||
let n = self.waits.fetch_add(1, Relaxed);
|
||||
if n == 0 {
|
||||
log::info!("gpu: allocator over the {} MiB memory cap, frames wait for the GPU to catch up", self.cap >> 20);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn wait_timeout(&self) -> Duration {
|
||||
#[cfg(test)]
|
||||
{
|
||||
Duration::from_nanos(self.timeout_ns.load(Relaxed))
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
crate::readback::TIMEOUT
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn waits(&self) -> u64 {
|
||||
self.waits.load(Relaxed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn warned(&self) -> bool {
|
||||
self.warned.load(Relaxed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_wait_timeout(&self, timeout: Duration) {
|
||||
self.timeout_ns.store(timeout.as_nanos() as u64, Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// The cap from the `EFFECTCRAFT_GPU_MEMCAP_MB` value: whole MiB (0 turns the gate off), or the
|
||||
/// default for `device_type` when unset, or (with a warning) when not a number or too large.
|
||||
fn cap_from_env(var: Option<&str>, device_type: wgpu::DeviceType) -> u64 {
|
||||
let Some(v) = var else { return default_cap(device_type) };
|
||||
match v.trim().parse::<u64>().ok().and_then(|mb| mb.checked_mul(MIB)) {
|
||||
Some(cap) => cap,
|
||||
None => {
|
||||
let cap = default_cap(device_type);
|
||||
log::warn!(target: "effectcraft_gpu", "{MEMCAP_ENV}={v:?} is not a whole number of MiB that fits in 64 bits: using {} MiB", cap / MIB);
|
||||
cap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Discrete cards: 3 GiB, low enough to fire on 4–8 GB cards with headroom for the driver and
|
||||
/// desktop while leaving ~1 GiB above the default 1.5 GiB upload cache + 256 MB pool (larger
|
||||
/// cards can raise it with `EFFECTCRAFT_GPU_MEMCAP_MB`). Integrated and software adapters share
|
||||
/// system memory: 2 GiB.
|
||||
fn default_cap(t: wgpu::DeviceType) -> u64 {
|
||||
match t {
|
||||
wgpu::DeviceType::DiscreteGpu => 3 * GIB,
|
||||
_ => 2 * GIB,
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimum reclaimable bytes before a wait is worth draining an encoder (1/8 of the cap,
|
||||
/// clamped between 1 MiB and 256 MiB, or 1/4 of caps smaller than 4 MiB).
|
||||
fn default_hysteresis(cap: u64) -> u64 {
|
||||
if cap == 0 { 0 } else { (cap / 8).clamp(MIB.min(cap / 4), 256 * MIB) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_and_cadence() {
|
||||
assert_eq!(default_cap(wgpu::DeviceType::DiscreteGpu), 3 * GIB);
|
||||
assert_eq!(default_cap(wgpu::DeviceType::IntegratedGpu), 2 * GIB);
|
||||
let m = MemCap::from_env(None, wgpu::DeviceType::IntegratedGpu);
|
||||
assert_eq!(m.cap(), 2 * GIB);
|
||||
let due = (0..2 * EVERY).filter(|_| m.due()).count();
|
||||
assert_eq!(due, 2);
|
||||
let off = MemCap::from_env(Some("0"), wgpu::DeviceType::DiscreteGpu);
|
||||
assert_eq!(off.cap(), 0);
|
||||
assert!((0..EVERY).all(|_| !off.due()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_is_whole_mib_or_the_default() {
|
||||
let cap = |v| MemCap::from_env(Some(v), wgpu::DeviceType::DiscreteGpu).cap();
|
||||
assert_eq!(cap("2048"), 2 * GIB);
|
||||
assert_eq!(cap(" 8 "), 8 * MIB);
|
||||
assert_eq!(cap("17592186044415"), 17592186044415 * MIB, "the largest value that fits");
|
||||
for bad in ["", "lots", "-1", "1.5", "17592186044416", "99999999999999999999"] {
|
||||
assert_eq!(cap(bad), 3 * GIB, "{bad:?} falls back to the default");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hysteresis_skips_non_reclaimable_and_unfreed_memory() {
|
||||
let m = MemCap::from_env(Some("2048"), wgpu::DeviceType::IntegratedGpu);
|
||||
// Upload cache + pool + live frames (2.15 GiB) sit over the 2 GiB cap with only 50 MiB
|
||||
// reclaimable: waiting would free almost nothing, so the gate must stay quiet.
|
||||
let kept = 2150 * MIB;
|
||||
assert!(!m.should_wait(kept + 50 * MIB, kept));
|
||||
assert!(!m.should_wait(kept + 200 * MIB, kept));
|
||||
// Once 256 MiB of dropped textures pile up, the gate fires.
|
||||
assert!(m.should_wait(kept + 256 * MIB, kept));
|
||||
// If a wait freed nothing (e.g. held by another thread's open encoder or timed out),
|
||||
// the floor prevents firing again until another 256 MiB of reclaimable memory accumulates.
|
||||
m.record_wait(kept + 256 * MIB, kept);
|
||||
assert!(!m.should_wait(kept + 256 * MIB, kept));
|
||||
assert!(!m.should_wait(kept + 400 * MIB, kept));
|
||||
assert!(m.should_wait(kept + 512 * MIB, kept));
|
||||
// Once the other thread finishes and reclaimable memory drops, the floor drops with it.
|
||||
assert!(!m.should_wait(kept + 16 * MIB, kept));
|
||||
assert!(m.should_wait(kept + (16 + 256) * MIB, kept));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_memory_over_the_cap_warns_once_and_never_waits() {
|
||||
let m = MemCap::from_env(Some("8"), wgpu::DeviceType::IntegratedGpu);
|
||||
// Cap 8 MiB: kept alone over the cap with reclaimable under the hysteresis → no wait, warn once.
|
||||
assert!(!m.should_wait(20 * MIB, 20 * MIB));
|
||||
assert!(m.warned());
|
||||
assert!(!m.should_wait(20 * MIB, 20 * MIB));
|
||||
assert!(!m.should_wait(20 * MIB, 19 * MIB + MIB / 2));
|
||||
assert!(m.should_wait(20 * MIB, 4 * MIB), "16 MiB reclaimable is over the hysteresis");
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,91 @@ fn acos_p(x: f32) -> f32 {
|
||||
return 1.5707963267948966 - asin_p(x);
|
||||
}
|
||||
|
||||
// Some hardware's native trig instructions saturate cos near a full turn and
|
||||
// have errors large enough to move particles and sphere samples visibly. Reduce
|
||||
// to [-pi/4, pi/4] with a split pi/2, then evaluate the Taylor polynomials with
|
||||
// explicit fused operations. The effect angles use this bounded range; retain
|
||||
// the builtins for non-finite or very large arguments rather than cast them.
|
||||
fn sincos_p(x: f32) -> vec2<f32> {
|
||||
if (!(abs(x) <= 8192.0)) {
|
||||
return vec2<f32>(sin(x), cos(x));
|
||||
}
|
||||
let k = round(x * 0.6366197723675814);
|
||||
// Keep the coarse reduction in integers. Some drivers reassociate even
|
||||
// explicit fma chains into x - k*pi/2, losing low bits for large angles.
|
||||
// 3216/2048 is exact; the bounded scaled input and product fit in i32.
|
||||
let scaled = x * 2048.0;
|
||||
let whole = floor(scaled);
|
||||
var r = f32(i32(whole) - i32(k) * 3216) / 2048.0;
|
||||
r += (scaled - whole) / 2048.0;
|
||||
r = fma(-k, 0.0004838267893064022, r);
|
||||
r = fma(-k, 5.564431554416771e-12, r);
|
||||
let z = r * r;
|
||||
var sp = fma(z, -1.0 / 39916800.0, 1.0 / 362880.0);
|
||||
sp = fma(z, sp, -1.0 / 5040.0);
|
||||
sp = fma(z, sp, 1.0 / 120.0);
|
||||
sp = fma(z, sp, -1.0 / 6.0);
|
||||
let sn = fma(r * z, sp, r);
|
||||
var cp = fma(z, -1.0 / 3628800.0, 1.0 / 40320.0);
|
||||
cp = fma(z, cp, -1.0 / 720.0);
|
||||
cp = fma(z, cp, 1.0 / 24.0);
|
||||
cp = fma(z, cp, -0.5);
|
||||
let cs = fma(z, cp, 1.0);
|
||||
switch imod(i32(k), 4) {
|
||||
case 1: { return vec2<f32>(cs, -sn); }
|
||||
case 2: { return vec2<f32>(-sn, -cs); }
|
||||
case 3: { return vec2<f32>(-cs, sn); }
|
||||
default: { return vec2<f32>(sn, cs); }
|
||||
}
|
||||
}
|
||||
|
||||
fn sin_p(x: f32) -> f32 { return sincos_p(x).x; }
|
||||
fn cos_p(x: f32) -> f32 { return sincos_p(x).y; }
|
||||
|
||||
// Correctly-rounded positive x/a, clamped to [0,1]. Native division and fma
|
||||
// need not have IEEE precision on every shader backend. Discontinuous colour
|
||||
// effects need the exact CPU f32 value, not just an approximation within an ulp.
|
||||
// Long division uses only bounded 24-bit significands (at most 24 iterations).
|
||||
fn unit_div(x: f32, a: f32) -> f32 {
|
||||
if (x <= 0.0 || a <= 0.0) { return 0.0; }
|
||||
if (x >= a) { return 1.0; }
|
||||
if (a == 1.0) { return x; }
|
||||
let xb = bitcast<u32>(x);
|
||||
let ab = bitcast<u32>(a);
|
||||
var nx = xb & 0x7fffffu;
|
||||
var na = ab & 0x7fffffu;
|
||||
var ex = i32((xb >> 23u) & 255u) - 127;
|
||||
var ea = i32((ab >> 23u) & 255u) - 127;
|
||||
if (ex == -127) {
|
||||
let shift = 23u - firstLeadingBit(nx);
|
||||
nx <<= shift;
|
||||
ex = -126 - i32(shift);
|
||||
} else { nx |= 0x800000u; }
|
||||
if (ea == -127) {
|
||||
let shift = 23u - firstLeadingBit(na);
|
||||
na <<= shift;
|
||||
ea = -126 - i32(shift);
|
||||
} else { na |= 0x800000u; }
|
||||
var exponent = ex - ea;
|
||||
if (nx < na) { nx <<= 1u; exponent -= 1; }
|
||||
var rem = nx - na;
|
||||
if (exponent < -150) { return 0.0; }
|
||||
if (exponent == -150) { return bitcast<f32>(select(0u, 1u, rem != 0u)); }
|
||||
let fraction = u32(min(23, exponent + 149));
|
||||
var q = 1u;
|
||||
for (var i = 0u; i <= fraction; i++) {
|
||||
rem <<= 1u;
|
||||
q <<= 1u;
|
||||
if (rem >= na) { rem -= na; q |= 1u; }
|
||||
}
|
||||
let guard = q & 1u;
|
||||
q >>= 1u;
|
||||
if (guard != 0u && (rem != 0u || (q & 1u) != 0u)) { q += 1u; }
|
||||
if (exponent < -126) { return bitcast<f32>(q); }
|
||||
if (q == 0x1000000u) { q >>= 1u; exponent += 1; }
|
||||
return bitcast<f32>((u32(exponent + 127) << 23u) | (q & 0x7fffffu));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- sampling (raster::Image)
|
||||
|
||||
fn tex_get(t: texture_2d<f32>, x: i32, y: i32) -> vec4<f32> {
|
||||
@@ -157,13 +242,16 @@ fn powz(x: f32, y: f32) -> f32 {
|
||||
return pow(x, y);
|
||||
}
|
||||
|
||||
// Rust's f32::round (half away from zero; WGSL's round() is half to even).
|
||||
// Rust's f32::round (half away from zero; WGSL's round() is half to even). Decided by comparing
|
||||
// `x` with `t ± 0.5` (exact for integer `t`) rather than subtracting: drivers may fuse a product
|
||||
// that feeds `x - t` into one fma (AMD does), skipping the product's rounding, so `5/6 · 3`,
|
||||
// which rounds to exactly 2.5 on the CPU, came out 0.49999994 above 2 and rounded down (#1605).
|
||||
fn round_away(x: f32) -> f32 {
|
||||
let t = trunc(x);
|
||||
if (abs(x - t) >= 0.5) {
|
||||
return t + sign(x);
|
||||
if (x >= 0.0) {
|
||||
return select(t, t + 1.0, x >= t + 0.5);
|
||||
}
|
||||
return t;
|
||||
return select(t, t - 1.0, x <= t - 0.5);
|
||||
}
|
||||
|
||||
// f32::rem_euclid(1.0).
|
||||
|
||||
@@ -633,52 +633,9 @@ fn fp2_hextile(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if (!fp2_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let cx = P.f[0].x;
|
||||
let cy = P.f[0].y;
|
||||
let r = P.f[0].z;
|
||||
let smear = P.f[0].w;
|
||||
let sn = P.f[1].x;
|
||||
let cs = P.f[1].y;
|
||||
let mode = P.u[0].x;
|
||||
let dx = f32(p.x) + 0.5 - cx;
|
||||
let dy = f32(p.y) + 0.5 - cy;
|
||||
let u = dx * cs + dy * sn;
|
||||
let v = -dx * sn + dy * cs;
|
||||
// stylize3::hex_cell (cube rounding).
|
||||
let q = (0.5773502691896257 * u - v / 3.0) / r;
|
||||
let rr = (2.0 / 3.0 * v) / r;
|
||||
// Evaluate the third cube coordinate symmetrically with q so the centerline
|
||||
// tie is not broken by subtracting separately rounded coordinates.
|
||||
let ccy = (-0.5773502691896257 * u - v / 3.0) / r;
|
||||
var rx = round_away(q);
|
||||
let ry = round_away(ccy);
|
||||
var rz = round_away(rr);
|
||||
let ddx = abs(rx - q);
|
||||
let ddy = abs(ry - ccy);
|
||||
let ddz = abs(rz - rr);
|
||||
if (ddx > ddy && ddx > ddz) {
|
||||
rx = -ry - rz;
|
||||
} else if (ddy <= ddz) {
|
||||
rz = -rx - ry;
|
||||
}
|
||||
let hx = r * 1.7320508075688772 * (rx + rz / 2.0);
|
||||
let hy = r * 1.5 * rz;
|
||||
var lu = u - hx;
|
||||
var lv = v - hy;
|
||||
let odd = imod(i32(rx) + i32(rz), 2) == 1;
|
||||
if ((mode == 1u || mode == 2u) && odd) {
|
||||
lu = -lu;
|
||||
}
|
||||
if (mode == 2u && odd) {
|
||||
lv = -lv;
|
||||
}
|
||||
let ox = lu * cs - lv * sn;
|
||||
let oy = lu * sn + lv * cs;
|
||||
let tx = cx + ox;
|
||||
let ty = cy + oy;
|
||||
let sx = tx + (f32(p.x) + 0.5 - tx) * smear;
|
||||
let sy = ty + (f32(p.y) + 0.5 - ty) * smear;
|
||||
textureStore(out, p, sample_bilinear_clamped(src, sx, sy));
|
||||
// Cell membership and reflection were decided in the CPU effect's f64 geometry.
|
||||
let i = (gid.y * u32(out_dims().x) + gid.x) * 2u;
|
||||
textureStore(out, p, sample_bilinear_clamped(src, data[i], data[i + 1u]));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Mr. Smoothie (stylize3::smoothie)
|
||||
|
||||
@@ -114,8 +114,8 @@ fn fxm_sprite(at: u32, x: f32, y: f32) -> FxmShade {
|
||||
cov = min(0.12 * select(0.0, 1.0, d <= 1.0) + ring * 0.8 + hl, 1.0);
|
||||
}
|
||||
case 5u: {
|
||||
let sn = sin(data[at + 8u]);
|
||||
let cs = cos(data[at + 8u]);
|
||||
let sn = sin_p(data[at + 8u]);
|
||||
let cs = cos_p(data[at + 8u]);
|
||||
let ux = (dx * cs + dy * sn) / r;
|
||||
let uy = (-dx * sn + dy * cs) / r;
|
||||
let d = sqrt(ux * ux + uy * uy);
|
||||
@@ -125,23 +125,23 @@ fn fxm_sprite(at: u32, x: f32, y: f32) -> FxmShade {
|
||||
cov = min(core + stx + sty, 1.0);
|
||||
}
|
||||
case 6u: {
|
||||
let sn = sin(data[at + 8u]);
|
||||
let cs = cos(data[at + 8u]);
|
||||
let sn = sin_p(data[at + 8u]);
|
||||
let cs = cos_p(data[at + 8u]);
|
||||
let ux = dx * cs + dy * sn;
|
||||
let uy = -dx * sn + dy * cs;
|
||||
let e = r * 0.75;
|
||||
cov = fxm_aa(e - abs(ux)) * fxm_aa(e - abs(uy));
|
||||
}
|
||||
case 7u: {
|
||||
let sn = sin(data[at + 8u]);
|
||||
let cs = cos(data[at + 8u]);
|
||||
let sn = sin_p(data[at + 8u]);
|
||||
let cs = cos_p(data[at + 8u]);
|
||||
let ux = dx * cs + dy * sn;
|
||||
let uy = -dx * sn + dy * cs;
|
||||
let e = r * 0.5;
|
||||
var m = 3.4e38;
|
||||
for (var k = 0; k < 3; k++) {
|
||||
let a = 1.5707964 + f32(k) * 6.2831855 / 3.0;
|
||||
m = min(m, e - (ux * cos(a) + uy * sin(a)));
|
||||
m = min(m, e - (ux * cos_p(a) + uy * sin_p(a)));
|
||||
}
|
||||
cov = fxm_aa(m);
|
||||
}
|
||||
@@ -431,8 +431,8 @@ fn fxm_ripples(x: f32, y: f32) -> f32 {
|
||||
let r = data[i * 4u + 2u];
|
||||
let u = (d - r) / P.f[0].y;
|
||||
if (abs(u) < 1.0) {
|
||||
let env = 0.5 + 0.5 * cos(u * 3.1415927);
|
||||
v += data[i * 4u + 3u] * env * cos((d - r) / P.f[0].x * 6.2831855);
|
||||
let env = 0.5 + 0.5 * cos_p(u * 3.1415927);
|
||||
v += data[i * 4u + 3u] * env * cos_p((d - r) / P.f[0].x * 6.2831855);
|
||||
}
|
||||
}
|
||||
return v;
|
||||
|
||||
@@ -42,7 +42,11 @@ fn fxs_qdiv(x: vec3<f32>, a: f32) -> vec3<f32> {
|
||||
let r = fma(-q, vec3<f32>(a), x);
|
||||
// `q + r / a`, written as an fma so drivers (NVIDIA Vulkan) can't contract it another way
|
||||
// and round differently from the CPU.
|
||||
return fma(r / a, vec3<f32>(1.0), q);
|
||||
let corrected = fma(r / a, vec3<f32>(1.0), q);
|
||||
return vec3<f32>(
|
||||
select(corrected.x, unit_div(x.x, a), x.x >= 0.0 && x.x <= a),
|
||||
select(corrected.y, unit_div(x.y, a), x.y >= 0.0 && x.y <= a),
|
||||
select(corrected.z, unit_div(x.z, a), x.z >= 0.0 && x.z <= a));
|
||||
}
|
||||
|
||||
fn fxs_rem2(i: i32) -> i32 {
|
||||
@@ -63,6 +67,32 @@ fn fxs_bin(v: f32, t: f32, inv: bool) -> f32 {
|
||||
return select(0.0, 1.0, (v >= t) != inv);
|
||||
}
|
||||
|
||||
// Preserve the CPU's separately-rounded f32 multiply before rounding to a bin.
|
||||
// A shader compiler may reassociate division and multiplication across a half
|
||||
// threshold. The 24-bit mantissa times at most 254 fits in u32 exactly.
|
||||
fn fxs_posterize_bin(v: f32, levels: u32) -> f32 {
|
||||
let bits = bitcast<u32>(clamp(v, 0.0, 1.0));
|
||||
let exponent = (bits >> 23u) & 255u;
|
||||
if (exponent == 0u) { return 0.0; }
|
||||
var product = ((bits & 0x7fffffu) | 0x800000u) * levels;
|
||||
let high = firstLeadingBit(product);
|
||||
if (high > 23u) {
|
||||
let lost = high - 23u;
|
||||
let mask = (1u << lost) - 1u;
|
||||
let remainder = product & mask;
|
||||
let half = 1u << (lost - 1u);
|
||||
var kept = product >> lost;
|
||||
if (remainder > half || (remainder == half && (kept & 1u) != 0u)) { kept += 1u; }
|
||||
product = kept << lost;
|
||||
}
|
||||
let shift = 150u - exponent;
|
||||
if (shift > 32u) { return 0.0; }
|
||||
if (shift == 32u) { return select(0.0, 1.0, product >= 0x80000000u); }
|
||||
let mask = (1u << shift) - 1u;
|
||||
let integer = (product >> shift) + select(0u, 1u, (product & mask) >= (1u << (shift - 1u)));
|
||||
return f32(integer);
|
||||
}
|
||||
|
||||
fn fxs_strobe_op(op: u32, v: f32, k: f32) -> f32 {
|
||||
switch op {
|
||||
case 1u: { return v + k; }
|
||||
@@ -96,8 +126,8 @@ fn fxs_point(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
switch mode {
|
||||
case 1u: {
|
||||
let lv = P.f[0].x;
|
||||
let c = clamp(fxs_qdiv(px.xyz, a), vec3<f32>(0.0), vec3<f32>(1.0)) * lv;
|
||||
let o = vec3<f32>(round_away(c.x), round_away(c.y), round_away(c.z)) / lv;
|
||||
let c = vec3<f32>(unit_div(px.x, a), unit_div(px.y, a), unit_div(px.z, a));
|
||||
let o = vec3<f32>(fxs_posterize_bin(c.x, u32(lv)), fxs_posterize_bin(c.y, u32(lv)), fxs_posterize_bin(c.z, u32(lv))) / lv;
|
||||
textureStore(out, p, vec4<f32>(o * a, a));
|
||||
}
|
||||
case 2u: {
|
||||
@@ -611,7 +641,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,
|
||||
|
||||
@@ -129,16 +129,17 @@ fn fxt_knee(v: f32, start: f32, limit: f32) -> f32 {
|
||||
return start + r * (1.0 - exp(-(v - start) / r));
|
||||
}
|
||||
|
||||
// PS Arbitrary Map lookup (map in data, n entries; f[0].x = phase offset in entries).
|
||||
// PS Arbitrary Map: CPU-rounded interval starts, values and slopes in data.
|
||||
fn fxt_lookup(v: f32, n: u32) -> f32 {
|
||||
let nf = f32(n);
|
||||
var x = clamp(v, 0.0, 1.0) * (nf - 1.0) + P.f[0].x;
|
||||
x = x - nf * floor(x / nf);
|
||||
let fl = floor(x);
|
||||
let i0 = u32(fl) % n;
|
||||
let i1 = select(i0, i0 + 1u, i0 + 1u < n);
|
||||
let t = x - fl;
|
||||
return data[i0] + (data[i1] - data[i0]) * t;
|
||||
let x = clamp(v, 0.0, 1.0);
|
||||
var lo = 0u;
|
||||
var hi = n;
|
||||
while (lo + 1u < hi) {
|
||||
let mid = lo + (hi - lo) / 2u;
|
||||
if (x < data[mid * 3u]) { hi = mid; } else { lo = mid; }
|
||||
}
|
||||
let i = lo * 3u;
|
||||
return data[i + 1u] + (x - data[i]) * data[i + 2u];
|
||||
}
|
||||
|
||||
fn fxt_eq_look(table: u32, n: u32, v: f32) -> f32 {
|
||||
@@ -447,7 +448,11 @@ fn fxt_point(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
case 12u: {
|
||||
let n = u0.z;
|
||||
let aa = max(a, 0.0);
|
||||
let r = vec3<f32>(fxt_lookup(c.x, n), fxt_lookup(c.y, n), fxt_lookup(c.z, n));
|
||||
var straight = vec3<f32>(0.0);
|
||||
if (a > 1e-6) {
|
||||
straight = vec3<f32>(unit_div(px.x, a), unit_div(px.y, a), unit_div(px.z, a));
|
||||
}
|
||||
let r = vec3<f32>(fxt_lookup(straight.x, n), fxt_lookup(straight.y, n), fxt_lookup(straight.z, n));
|
||||
var na = aa;
|
||||
if (u0.y != 0u) {
|
||||
na = fxt_lookup(aa, n);
|
||||
|
||||
@@ -224,28 +224,8 @@ fn ftr_grid(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
return;
|
||||
}
|
||||
let px = textureLoad(src, p, 0);
|
||||
let d = ftr_centre(p) - P.f[0].xy;
|
||||
let s = P.f[0].z;
|
||||
let co = P.f[0].w;
|
||||
let q = vec2<f32>(d.x * co + d.y * s, -d.x * s + d.y * co);
|
||||
let cs = P.f[1].x;
|
||||
let cc = (floor(q / cs) + 0.5) * cs;
|
||||
var o = clamp(ftr_metric(P.u[0].x, cc) / P.f[1].y, 0.0, 1.0);
|
||||
if (P.u[0].y != 0u) {
|
||||
o = 1.0 - o;
|
||||
}
|
||||
let spread = 0.25;
|
||||
let pr = clamp((P.f[1].z * (1.0 + spread) - o) / spread, 0.0, 1.0);
|
||||
if (pr <= 0.0) {
|
||||
textureStore(out, p, px);
|
||||
return;
|
||||
}
|
||||
let half = cs * 0.5 * (1.0 - pr);
|
||||
let m = max(abs(q.x - cc.x), abs(q.y - cc.y));
|
||||
var k = select(0.0, 1.0, m < half);
|
||||
if (P.f[1].w > 0.0) {
|
||||
k = clamp((half - m) / P.f[1].w + 0.5, 0.0, 1.0);
|
||||
}
|
||||
// CPU-generated coverage preserves exact f64 tile and hard-edge decisions.
|
||||
let k = data[gid.y * u32(out_dims().x) + gid.x];
|
||||
textureStore(out, p, px * k);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ fn fxv_unpremul(p: vec4<f32>) -> vec4<f32> {
|
||||
fn fxv_equi_dir(x: f32, y: f32, w: f32, h: f32) -> vec3<f32> {
|
||||
let lon = (x / w - 0.5) * 2.0 * FXV_PI;
|
||||
let lat = (0.5 - y / h) * FXV_PI;
|
||||
return vec3<f32>(cos(lat) * sin(lon), sin(lat), cos(lat) * cos(lon));
|
||||
return vec3<f32>(cos_p(lat) * sin_p(lon), sin_p(lat), cos_p(lat) * cos_p(lon));
|
||||
}
|
||||
|
||||
// vr::equi_px.
|
||||
@@ -168,7 +168,7 @@ fn fxv_dir(kind: u32, fov: f32, x: f32, y: f32, w: f32, h: f32) -> vec4<f32> {
|
||||
cu = u / r;
|
||||
cv = v / r;
|
||||
}
|
||||
return vec4<f32>(sin(th) * cu, cos(th), sin(th) * cv, 1.0);
|
||||
return vec4<f32>(sin_p(th) * cu, cos_p(th), sin_p(th) * cv, 1.0);
|
||||
}
|
||||
if (kind == 4u) {
|
||||
let half = radians(clamp(fov, 1.0, 360.0) / 2.0);
|
||||
@@ -186,7 +186,7 @@ fn fxv_dir(kind: u32, fov: f32, x: f32, y: f32, w: f32, h: f32) -> vec4<f32> {
|
||||
cu = u / r;
|
||||
cv = v / r;
|
||||
}
|
||||
return vec4<f32>(sin(th) * cu, sin(th) * cv, cos(th), 1.0);
|
||||
return vec4<f32>(sin_p(th) * cu, sin_p(th) * cv, cos_p(th), 1.0);
|
||||
}
|
||||
let t = tan(radians(clamp(fov, 1.0, 179.0) / 2.0));
|
||||
let u = (x / w * 2.0 - 1.0) * t;
|
||||
@@ -519,8 +519,8 @@ fn fxv_gen(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
var s = px;
|
||||
if (al >= 1e-9) {
|
||||
let new_th = th * (1.0 - P.f[0][c] * weight);
|
||||
let perp = normalize(d - center * cos(th));
|
||||
let nd = center * cos(new_th) + perp * sin(new_th);
|
||||
let perp = normalize(d - center * cos_p(th));
|
||||
let nd = center * cos_p(new_th) + perp * sin_p(new_th);
|
||||
let q = fxv_equi_px(nd, w, h);
|
||||
s = fxv_sample_equi(q.x, q.y);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,796 @@
|
||||
// GPU Content-Aware Fill: the membrane (harmonic) fill and PatchMatch synthesis of
|
||||
// effectcraft_raster::inpaint, operation for operation (the CPU reference is the oracle; see
|
||||
// crates/gpu/src/tests/inpaint.rs).
|
||||
//
|
||||
// Bindings are the shared ones (common.wgsl): `src` / `aux` are the input textures (an image, a
|
||||
// coarser level, or the nearest-neighbour field), `out` is the storage texture and `data` is a
|
||||
// per-pixel float table — the level's flags:
|
||||
// * membrane kernels: data[i] = 1 where the pixel is known (the hole's complement);
|
||||
// * PatchMatch kernels: data[i] = bit 0 hole, bit 1 near (a pixel whose patch touches the hole).
|
||||
//
|
||||
// Parameter block (P.u = u32, P.f = f32):
|
||||
// membrane kernels: u[0] = (w, h, coarseW, coarseH), u[1] = (x0, y0, 0, 0) (dispatch
|
||||
// origin), u[3].x = this level's offset into `data`
|
||||
// down kernels: u[0] = (srcW, srcH, dstW, dstH)
|
||||
// PatchMatch kernels: u[0] = (w, h, r, parity), u[1] = (x0, y0, 0, 0) (dispatch origin),
|
||||
// u[2] = (seed, iteration, 0, EM round), f[0] = (sigma², 0, 0, 0)
|
||||
// histogram: u[0] = (w, h, 0, 0), u[1] = (x0, y0, w, h) (target bounds),
|
||||
// f[0].x = the distance range (0: the SDR range 0…4)
|
||||
// The nearest-neighbour field texture holds one entry per pixel: r, g = source centre ("px"),
|
||||
// b = SSD distance (inf where unset), a = 1 where the entry is set.
|
||||
|
||||
const IMP_INF: f32 = 1e30;
|
||||
|
||||
fn imp_flags(i: i32) -> u32 {
|
||||
return u32(max(data[i], 0.0));
|
||||
}
|
||||
|
||||
fn imp_hole(i: i32) -> bool {
|
||||
return (imp_flags(i) & 1u) != 0u;
|
||||
}
|
||||
|
||||
fn imp_near(i: i32) -> bool {
|
||||
return (imp_flags(i) & 2u) != 0u;
|
||||
}
|
||||
|
||||
// A source centre whose patch is fully known and inside the image (Pm::valid_source).
|
||||
fn imp_valid_source(x: i32, y: i32, w: i32, h: i32, r: i32) -> bool {
|
||||
if (x < r || y < r || x + r >= w || y + r >= h) {
|
||||
return false;
|
||||
}
|
||||
return !imp_near(y * w + x);
|
||||
}
|
||||
|
||||
// PatchMatch's hashing rnd (raster::inpaint::rnd), bit for bit: the CPU's random stream is a
|
||||
// contract of the algorithm, not an implementation detail.
|
||||
fn imp_rnd(seed: u32, a: u32, b: u32) -> u32 {
|
||||
var h = (seed * 0x9E3779B9u) ^ (a * 0x85EBCA6Bu) ^ (b * 0xC2B2AE35u);
|
||||
h = h ^ (h >> 16u);
|
||||
h = h * 0x7FEB352Du;
|
||||
h = h ^ (h >> 15u);
|
||||
h = h * 0x846CA68Bu;
|
||||
return h ^ (h >> 16u);
|
||||
}
|
||||
|
||||
// SSD of the patch at target (tx, ty) (clipped to the image) against source (sx, sy)
|
||||
// (Pm::dist). `cap` short-circuits a match that is already worse.
|
||||
fn imp_dist(tx: i32, ty: i32, sx: i32, sy: i32, w: i32, h: i32, r: i32, cap: f32) -> f32 {
|
||||
var d = 0.0;
|
||||
var n = 0.0;
|
||||
for (var dy = -r; dy <= r; dy = dy + 1) {
|
||||
let ty2 = ty + dy;
|
||||
if (ty2 < 0 || ty2 >= h) {
|
||||
continue;
|
||||
}
|
||||
let sy2 = sy + dy;
|
||||
for (var dx = -r; dx <= r; dx = dx + 1) {
|
||||
let tx2 = tx + dx;
|
||||
if (tx2 < 0 || tx2 >= w) {
|
||||
continue;
|
||||
}
|
||||
let a = textureLoad(aux, vec2<i32>(tx2, ty2), 0);
|
||||
let b = textureLoad(aux, vec2<i32>(sx + dx, sy2), 0);
|
||||
let e = a - b;
|
||||
d = d + e.x * e.x + e.y * e.y + e.z * e.z + e.w * e.w;
|
||||
n = n + 1.0;
|
||||
}
|
||||
if (d > cap * max(n, 1.0)) {
|
||||
return IMP_INF;
|
||||
}
|
||||
}
|
||||
return d / max(n, 1.0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- membrane
|
||||
|
||||
// membrane_core's coarse level: the known-weighted 2×2 average.
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn imp_down(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let w = i32(P.u[0].x);
|
||||
let h = i32(P.u[0].y);
|
||||
let cw = i32(P.u[0].z);
|
||||
let ch = i32(P.u[0].w);
|
||||
let cx = i32(gid.x);
|
||||
let cy = i32(gid.y);
|
||||
if (cx >= cw || cy >= ch) {
|
||||
return;
|
||||
}
|
||||
var s = vec4<f32>(0.0);
|
||||
var n = 0.0;
|
||||
for (var dy = 0; dy < 2; dy = dy + 1) {
|
||||
for (var dx = 0; dx < 2; dx = dx + 1) {
|
||||
let fx = 2 * cx + dx;
|
||||
let fy = 2 * cy + dy;
|
||||
if (fx < w && fy < h && data[i32(P.u[3].x) + fy * w + fx] > 0.5) {
|
||||
s = s + textureLoad(src, vec2<i32>(fx, fy), 0);
|
||||
n = n + 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
var v = vec4<f32>(0.0);
|
||||
if (n > 0.0) {
|
||||
v = s / n;
|
||||
}
|
||||
textureStore(out, vec2<i32>(cx, cy), v);
|
||||
}
|
||||
|
||||
// membrane_core's coarse-to-fine step: unknown pixels take the nearest coarse value. The fine
|
||||
// image is copied into `out` first, so writing only the unknown pixels keeps the known ones.
|
||||
// Dispatched over the unknown pixels' bounds (`P.u[1]`); the coarse level is valid everywhere.
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn imp_up(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let w = i32(P.u[0].x);
|
||||
let h = i32(P.u[0].y);
|
||||
let cw = i32(P.u[0].z);
|
||||
let ch = i32(P.u[0].w);
|
||||
let x = i32(P.u[1].x) + i32(gid.x);
|
||||
let y = i32(P.u[1].y) + i32(gid.y);
|
||||
if (x >= w || y >= h || data[i32(P.u[3].x) + y * w + x] > 0.5) {
|
||||
return;
|
||||
}
|
||||
let cx = min(x / 2, cw - 1);
|
||||
let cy = min(y / 2, ch - 1);
|
||||
textureStore(out, vec2<i32>(x, y), textureLoad(src, vec2<i32>(cx, cy), 0));
|
||||
}
|
||||
|
||||
// One relaxation of the unknown pixels (membrane_core's Jacobi sweep). `src` is the current
|
||||
// state and `aux` is unused. Dispatched over the unknown pixels' bounds (`P.u[1]`): known
|
||||
// pixels never move, so nothing outside them needs writing. Both `src` and `out` are valid
|
||||
// across the image (the fine level's, a copy of it, or the full downsample).
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn imp_jacobi(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let w = i32(P.u[0].x);
|
||||
let h = i32(P.u[0].y);
|
||||
let x = i32(P.u[1].x) + i32(gid.x);
|
||||
let y = i32(P.u[1].y) + i32(gid.y);
|
||||
if (x >= w || y >= h) {
|
||||
return;
|
||||
}
|
||||
var v = textureLoad(src, vec2<i32>(x, y), 0);
|
||||
if (data[i32(P.u[3].x) + y * w + x] <= 0.5) {
|
||||
var s = vec4<f32>(0.0);
|
||||
var n = 0.0;
|
||||
if (x > 0) {
|
||||
s = s + textureLoad(src, vec2<i32>(x - 1, y), 0);
|
||||
n = n + 1.0;
|
||||
}
|
||||
if (x + 1 < w) {
|
||||
s = s + textureLoad(src, vec2<i32>(x + 1, y), 0);
|
||||
n = n + 1.0;
|
||||
}
|
||||
if (y > 0) {
|
||||
s = s + textureLoad(src, vec2<i32>(x, y - 1), 0);
|
||||
n = n + 1.0;
|
||||
}
|
||||
if (y + 1 < h) {
|
||||
s = s + textureLoad(src, vec2<i32>(x, y + 1), 0);
|
||||
n = n + 1.0;
|
||||
}
|
||||
if (n > 0.0) {
|
||||
v = s / n;
|
||||
}
|
||||
}
|
||||
textureStore(out, vec2<i32>(x, y), v);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- PatchMatch
|
||||
|
||||
// PatchMatch's pyramid downsample (downsample): the level's image, hole pixels excluded from the
|
||||
// average, over `out`'s size.
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn imp_pm_down(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let w = i32(P.u[0].x);
|
||||
let h = i32(P.u[0].y);
|
||||
let cw = i32(P.u[0].z);
|
||||
let ch = i32(P.u[0].w);
|
||||
let cx = i32(gid.x);
|
||||
let cy = i32(gid.y);
|
||||
if (cx >= cw || cy >= ch) {
|
||||
return;
|
||||
}
|
||||
var s = vec4<f32>(0.0);
|
||||
var n = 0.0;
|
||||
for (var dy = 0; dy < 2; dy = dy + 1) {
|
||||
for (var dx = 0; dx < 2; dx = dx + 1) {
|
||||
let fx = min(2 * cx + dx, w - 1);
|
||||
let fy = min(2 * cy + dy, h - 1);
|
||||
if (!imp_hole(fy * w + fx)) {
|
||||
s = s + textureLoad(src, vec2<i32>(fx, fy), 0);
|
||||
n = n + 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
var v = vec4<f32>(0.0);
|
||||
if (n > 0.0) {
|
||||
v = s / n;
|
||||
}
|
||||
textureStore(out, vec2<i32>(cx, cy), v);
|
||||
}
|
||||
|
||||
// PatchMatch's coarse-to-fine step: bilinear upsample of the coarse image into this level's hole
|
||||
// (patch_fill's upsample loop). The fine image is copied into `out` first.
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn imp_pm_up(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let w = i32(P.u[0].x);
|
||||
let h = i32(P.u[0].y);
|
||||
let cw = i32(P.u[0].z);
|
||||
let ch = i32(P.u[0].w);
|
||||
let x = i32(P.u[1].x) + i32(gid.x);
|
||||
let y = i32(P.u[1].y) + i32(gid.y);
|
||||
if (x >= w || y >= h || !imp_hole(y * w + x)) {
|
||||
return;
|
||||
}
|
||||
let fx = clamp((f32(x) + 0.5) / 2.0 - 0.5, 0.0, f32(cw - 1));
|
||||
let fy = clamp((f32(y) + 0.5) / 2.0 - 0.5, 0.0, f32(ch - 1));
|
||||
let x0 = i32(floor(fx));
|
||||
let y0 = i32(floor(fy));
|
||||
let x1 = min(x0 + 1, cw - 1);
|
||||
let y1 = min(y0 + 1, ch - 1);
|
||||
let tx = fx - f32(x0);
|
||||
let ty = fy - f32(y0);
|
||||
let a = textureLoad(src, vec2<i32>(x0, y0), 0);
|
||||
let b = textureLoad(src, vec2<i32>(x1, y0), 0);
|
||||
let c = textureLoad(src, vec2<i32>(x0, y1), 0);
|
||||
let d = textureLoad(src, vec2<i32>(x1, y1), 0);
|
||||
let top = a + (b - a) * tx;
|
||||
let bot = c + (d - c) * tx;
|
||||
textureStore(out, vec2<i32>(x, y), top + (bot - top) * ty);
|
||||
}
|
||||
|
||||
// The NNF's coarse-to-fine step: ×2 (patch_fill's nnf upsample).
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn imp_nnf_up(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let w = i32(P.u[0].x);
|
||||
let h = i32(P.u[0].y);
|
||||
let cw = i32(P.u[0].z);
|
||||
let ch = i32(P.u[0].w);
|
||||
let x = i32(gid.x);
|
||||
let y = i32(gid.y);
|
||||
if (x >= w || y >= h) {
|
||||
return;
|
||||
}
|
||||
let cx = min(x / 2, cw - 1);
|
||||
let cy = min(y / 2, ch - 1);
|
||||
let c = textureLoad(src, vec2<i32>(cx, cy), 0);
|
||||
var o = vec4<f32>(0.0);
|
||||
if (c.a > 0.5) {
|
||||
o = vec4<f32>(c.r * 2.0 + f32(x % 2), c.g * 2.0 + f32(y % 2), 0.0, 1.0);
|
||||
}
|
||||
textureStore(out, vec2<i32>(x, y), o);
|
||||
}
|
||||
|
||||
// A random valid source centre, by rejection (the CPU draws from its compacted source list; the
|
||||
// distribution over the valid centres is the same, the stream differs from the list's order).
|
||||
fn imp_any_source(seed: u32, i: u32, k: u32, w: i32, h: i32, r: i32) -> vec2<i32> {
|
||||
for (var n = 0u; n < 32u; n = n + 1u) {
|
||||
let a = imp_rnd(seed, i, k + n);
|
||||
let x = i32(a % u32(w));
|
||||
let y = i32((a >> 16u) % u32(h));
|
||||
if (imp_valid_source(x, y, w, h, r)) {
|
||||
return vec2<i32>(x, y);
|
||||
}
|
||||
}
|
||||
return vec2<i32>(-1, -1);
|
||||
}
|
||||
|
||||
// A round's initial field: the inherited source where it is still valid, a random one otherwise
|
||||
// (pm_level's initialise/validate step).
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn imp_pm_init(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let w = i32(P.u[0].x);
|
||||
let h = i32(P.u[0].y);
|
||||
let r = i32(P.u[0].z);
|
||||
let x = i32(P.u[1].x) + i32(gid.x);
|
||||
let y = i32(P.u[1].y) + i32(gid.y);
|
||||
if (x >= w || y >= h) {
|
||||
return;
|
||||
}
|
||||
let i = y * w + x;
|
||||
var o = vec4<f32>(0.0, 0.0, IMP_INF, 0.0);
|
||||
if (imp_near(i)) {
|
||||
let prev = textureLoad(src, vec2<i32>(x, y), 0);
|
||||
var s = vec2<i32>(i32(prev.r), i32(prev.g));
|
||||
if (prev.a <= 0.5 || !imp_valid_source(s.x, s.y, w, h, r)) {
|
||||
// rnd(seed ^ e, i, 0), the round's random source (see imp_any_source).
|
||||
s = imp_any_source(P.u[2].x ^ P.u[2].w, u32(i), 0u, w, h, r);
|
||||
}
|
||||
if (s.x >= 0) {
|
||||
let d = imp_dist(x, y, s.x, s.y, w, h, r, IMP_INF);
|
||||
o = vec4<f32>(f32(s.x), f32(s.y), d, 1.0);
|
||||
}
|
||||
}
|
||||
textureStore(out, vec2<i32>(x, y), o);
|
||||
}
|
||||
|
||||
// One propagation pass: every target pixel tries its four neighbours' sources shifted by the
|
||||
// step that would move that neighbour's match onto this pixel. The checkerboard parity in P.u[0].w
|
||||
// makes the pass race-free (a pixel of one parity only reads the other's).
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn imp_pm_prop(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let w = i32(P.u[0].x);
|
||||
let h = i32(P.u[0].y);
|
||||
let r = i32(P.u[0].z);
|
||||
let x = i32(P.u[1].x) + i32(gid.x);
|
||||
let y = i32(P.u[1].y) + i32(gid.y);
|
||||
if (x >= w || y >= h) {
|
||||
return;
|
||||
}
|
||||
let cur = textureLoad(src, vec2<i32>(x, y), 0);
|
||||
let i = y * w + x;
|
||||
if (!imp_near(i) || ((x + y) & 1) != i32(P.u[0].w)) {
|
||||
textureStore(out, vec2<i32>(x, y), cur);
|
||||
return;
|
||||
}
|
||||
var best = vec2<i32>(i32(cur.r), i32(cur.g));
|
||||
var bd = cur.b;
|
||||
if (cur.a <= 0.5) {
|
||||
bd = IMP_INF;
|
||||
}
|
||||
for (var k = 0; k < 4; k = k + 1) {
|
||||
var nx = x;
|
||||
var ny = y;
|
||||
var sx = 0;
|
||||
var sy = 0;
|
||||
if (k == 0) {
|
||||
nx = x - 1;
|
||||
sx = 1;
|
||||
} else if (k == 1) {
|
||||
nx = x + 1;
|
||||
sx = -1;
|
||||
} else if (k == 2) {
|
||||
ny = y - 1;
|
||||
sy = 1;
|
||||
} else {
|
||||
ny = y + 1;
|
||||
sy = -1;
|
||||
}
|
||||
if (nx < 0 || ny < 0 || nx >= w || ny >= h) {
|
||||
continue;
|
||||
}
|
||||
let n = textureLoad(src, vec2<i32>(nx, ny), 0);
|
||||
if (n.a <= 0.5) {
|
||||
continue;
|
||||
}
|
||||
let c = vec2<i32>(i32(n.r) + sx, i32(n.g) + sy);
|
||||
if (c.x == best.x && c.y == best.y) {
|
||||
continue;
|
||||
}
|
||||
if (!imp_valid_source(c.x, c.y, w, h, r)) {
|
||||
continue;
|
||||
}
|
||||
let d = imp_dist(x, y, c.x, c.y, w, h, r, bd);
|
||||
if (d < bd) {
|
||||
bd = d;
|
||||
best = c;
|
||||
}
|
||||
}
|
||||
// alpha = the entry is set: `bd` stays INF only if no match was found.
|
||||
textureStore(out, vec2<i32>(x, y), vec4<f32>(f32(best.x), f32(best.y), bd, select(0.0, 1.0, bd < IMP_INF)));
|
||||
}
|
||||
|
||||
// The random search (halving radius) and one random source anywhere, after propagation.
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn imp_pm_rand(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let w = i32(P.u[0].x);
|
||||
let h = i32(P.u[0].y);
|
||||
let r = i32(P.u[0].z);
|
||||
let x = i32(P.u[1].x) + i32(gid.x);
|
||||
let y = i32(P.u[1].y) + i32(gid.y);
|
||||
if (x >= w || y >= h) {
|
||||
return;
|
||||
}
|
||||
let cur = textureLoad(src, vec2<i32>(x, y), 0);
|
||||
let i = y * w + x;
|
||||
if (!imp_near(i) || cur.a <= 0.5) {
|
||||
textureStore(out, vec2<i32>(x, y), cur);
|
||||
return;
|
||||
}
|
||||
var best = vec2<i32>(i32(cur.r), i32(cur.g));
|
||||
var bd = cur.b;
|
||||
let seed = P.u[2].x;
|
||||
let it = P.u[2].y;
|
||||
let em = P.u[2].w;
|
||||
let iu = u32(i);
|
||||
var rad = max(w, h);
|
||||
var k = 0u;
|
||||
while (rad >= 1) {
|
||||
let a = imp_rnd(seed + it * 977u + em * 31u, iu, k);
|
||||
let ox = i32(a % u32(2 * rad + 1)) - rad;
|
||||
let oy = i32((a >> 16u) % u32(2 * rad + 1)) - rad;
|
||||
let c = vec2<i32>(best.x + ox, best.y + oy);
|
||||
if (imp_valid_source(c.x, c.y, w, h, r)) {
|
||||
let d = imp_dist(x, y, c.x, c.y, w, h, r, bd);
|
||||
if (d < bd) {
|
||||
bd = d;
|
||||
best = c;
|
||||
}
|
||||
}
|
||||
rad = rad / 2;
|
||||
k = k + 1u;
|
||||
}
|
||||
let any = imp_any_source(seed ^ 0xABCDu, iu, it + em * 64u, w, h, r);
|
||||
if (any.x >= 0 && (any.x != best.x || any.y != best.y)) {
|
||||
let d = imp_dist(x, y, any.x, any.y, w, h, r, bd);
|
||||
if (d < bd) {
|
||||
bd = d;
|
||||
best = any;
|
||||
}
|
||||
}
|
||||
textureStore(out, vec2<i32>(x, y), vec4<f32>(f32(best.x), f32(best.y), bd, select(0.0, 1.0, bd < IMP_INF)));
|
||||
}
|
||||
|
||||
// Carry a round's field back into the nearest-neighbour field (only targets, as on the CPU).
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn imp_nnf_store(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let w = i32(P.u[0].x);
|
||||
let h = i32(P.u[0].y);
|
||||
let x = i32(gid.x);
|
||||
let y = i32(gid.y);
|
||||
if (x >= w || y >= h) {
|
||||
return;
|
||||
}
|
||||
let i = y * w + x;
|
||||
var o = textureLoad(aux, vec2<i32>(x, y), 0);
|
||||
if (imp_near(i)) {
|
||||
let f = textureLoad(src, vec2<i32>(x, y), 0);
|
||||
o = vec4<f32>(f.r, f.g, 0.0, 1.0);
|
||||
}
|
||||
textureStore(out, vec2<i32>(x, y), o);
|
||||
}
|
||||
|
||||
// The vote: every hole pixel becomes the distance-weighted mean of the samples its overlapping
|
||||
// patches carry (pm_level's vote step). `aux` is the current image; the image is copied into
|
||||
// `out` first, so only hole pixels are written.
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn imp_pm_vote(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let w = i32(P.u[0].x);
|
||||
let h = i32(P.u[0].y);
|
||||
let r = i32(P.u[0].z);
|
||||
let x = i32(P.u[1].x) + i32(gid.x);
|
||||
let y = i32(P.u[1].y) + i32(gid.y);
|
||||
if (x >= w || y >= h) {
|
||||
return;
|
||||
}
|
||||
let i = y * w + x;
|
||||
if (!imp_hole(i)) {
|
||||
return;
|
||||
}
|
||||
let sigma2 = P.f[0].x;
|
||||
var acc = vec4<f32>(0.0);
|
||||
var wsum = 0.0;
|
||||
for (var dy = -r; dy <= r; dy = dy + 1) {
|
||||
let py = y + dy;
|
||||
if (py < 0 || py >= h) {
|
||||
continue;
|
||||
}
|
||||
for (var dx = -r; dx <= r; dx = dx + 1) {
|
||||
let px = x + dx;
|
||||
if (px < 0 || px >= w) {
|
||||
continue;
|
||||
}
|
||||
let f = textureLoad(src, vec2<i32>(px, py), 0);
|
||||
if (f.a <= 0.5 || f.b >= IMP_INF) {
|
||||
continue;
|
||||
}
|
||||
let s = vec2<i32>(i32(f.r) - dx, i32(f.g) - dy);
|
||||
let wt = exp(-f.b / (2.0 * sigma2));
|
||||
acc = acc + textureLoad(aux, vec2<i32>(s.x, s.y), 0) * wt;
|
||||
wsum = wsum + wt;
|
||||
}
|
||||
}
|
||||
if (wsum > 0.0) {
|
||||
textureStore(out, vec2<i32>(x, y), acc / wsum);
|
||||
} else {
|
||||
textureStore(out, vec2<i32>(x, y), textureLoad(aux, vec2<i32>(x, y), 0));
|
||||
}
|
||||
}
|
||||
|
||||
// The workgroup's 1024 distance bins (`imp_hist`); the name is unique across the concatenated
|
||||
// shader sources because `var<workgroup>` is module scope.
|
||||
var<workgroup> imp_hist_bins: array<atomic<u32>, 1024>;
|
||||
// The largest finite distance this dispatch saw (as its f32 bit pattern: positive floats are
|
||||
// ordered by their bits). `sigma2` uses it to learn whether the frame needs a wider range.
|
||||
var<workgroup> imp_hist_max: atomic<u32>;
|
||||
|
||||
// One workgroup's histogram of the targets' match distances, for the vote's sigma² (the CPU takes
|
||||
// the 75th percentile of the finite distances). The single workgroup accumulates the whole
|
||||
// population through the shared atomic bins above.
|
||||
//
|
||||
// `P.f[0].x` is the range the bins cover; 0 selects the SDR range 0…4, where an image whose
|
||||
// channels are all ≤ 1 lives: each channel's squared difference is ≤ 4, so the mean SSD is too.
|
||||
// Frames with values above 1 (32-bpc HDR) have larger distances, so `sigma2` re-dispatches with
|
||||
// the observed maximum as the range instead of clamping them to bin 1023, which would report a
|
||||
// sigma² of ≈ 4 for a distance of 1600 and round the vote's weights to zero. The observed maximum
|
||||
// rides in bin 0's unused `.g` channel on every dispatch (its `.x` stays the count).
|
||||
@compute @workgroup_size(256, 1, 1)
|
||||
fn imp_hist(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let w = i32(P.u[0].x);
|
||||
let h = i32(P.u[0].y);
|
||||
let x0 = i32(P.u[1].x);
|
||||
let y0 = i32(P.u[1].y);
|
||||
let bw = i32(P.u[1].z);
|
||||
let bh = i32(P.u[1].w);
|
||||
let t = gid.x;
|
||||
let range = select(4.0, P.f[0].x, P.f[0].x > 0.0);
|
||||
let scale = 1024.0 / range;
|
||||
if (t == 0u) {
|
||||
atomicStore(&imp_hist_max, 0u);
|
||||
}
|
||||
// One workgroup, so the whole population is counted through shared atomics: a per-thread
|
||||
// strided pass would keep only the pixels whose bin selected that thread (a biased ~1/256
|
||||
// subsample, correlated with the pixel index).
|
||||
for (var i = t; i < 1024u; i = i + 256u) {
|
||||
atomicStore(&imp_hist_bins[i], 0u);
|
||||
}
|
||||
workgroupBarrier();
|
||||
let total = u32(max(bw, 0) * max(bh, 0));
|
||||
for (var idx = t; idx < total; idx = idx + 256u) {
|
||||
let x = x0 + i32(idx) % bw;
|
||||
let y = y0 + i32(idx) / bw;
|
||||
let i = y * w + x;
|
||||
if (!imp_near(i)) {
|
||||
continue;
|
||||
}
|
||||
let f = textureLoad(src, vec2<i32>(x, y), 0);
|
||||
if (f.a <= 0.5 || f.b >= IMP_INF) {
|
||||
continue;
|
||||
}
|
||||
atomicMax(&imp_hist_max, bitcast<u32>(f.b));
|
||||
// Clamp in f32 before the conversion: a distance far outside the range (a mismatched patch
|
||||
// in an HDR frame) would overflow the i32 conversion, which is indeterminate in WGSL.
|
||||
let b = u32(clamp(f.b * scale, 0.0, 1023.0));
|
||||
atomicAdd(&imp_hist_bins[b], 1u);
|
||||
}
|
||||
workgroupBarrier();
|
||||
// 256 threads × 4 bins cover the histogram.
|
||||
for (var j = 0u; j < 4u; j = j + 1u) {
|
||||
let bin = t * 4u + j;
|
||||
textureStore(out, vec2<i32>(i32(bin), 0), vec4<f32>(f32(atomicLoad(&imp_hist_bins[bin])), 0.0, 0.0, 0.0));
|
||||
}
|
||||
// Thread 0 is bin 0's only writer, so this store follows the one above. `.g` carries the
|
||||
// observed maximum: host readbacks keep the row 1024 texels wide (a 16 KB, 256-aligned copy).
|
||||
if (t == 0u) {
|
||||
textureStore(out, vec2<i32>(0, 0), vec4<f32>(f32(atomicLoad(&imp_hist_bins[0])), bitcast<f32>(atomicLoad(&imp_hist_max)), 0.0, 0.0));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- block flow (flow completion)
|
||||
|
||||
// The block matcher of effectcraft_raster::flow::block_flow(a, b, 8, 6), which the fill uses to
|
||||
// compute the motion between neighbouring membrane-filled frames before completing it inside the
|
||||
// holes (`FillBackend::flow`). `src` packs the two frames' luma planes at the current level:
|
||||
// .x is frame A's, .y is frame B's (`effectcraft_raster::flow::Plane::luma`). The kernels mirror
|
||||
// the CPU operation for operation, so the field matches the CPU's (crates/gpu/src/tests/inpaint.rs).
|
||||
|
||||
// Plane::half of the packed pair: the clamped 2×2 average of each channel.
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn imp_fl_half(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let cx = i32(gid.x);
|
||||
let cy = i32(gid.y);
|
||||
if (cx >= i32(P.u[0].z) || cy >= i32(P.u[0].w)) {
|
||||
return;
|
||||
}
|
||||
let w = i32(P.u[0].x);
|
||||
let h = i32(P.u[0].y);
|
||||
var sx = 0.0;
|
||||
var sy = 0.0;
|
||||
for (var dy = 0; dy < 2; dy = dy + 1) {
|
||||
for (var dx = 0; dx < 2; dx = dx + 1) {
|
||||
let v = textureLoad(src, vec2<i32>(min(2 * cx + dx, w - 1), min(2 * cy + dy, h - 1)), 0);
|
||||
sx = sx + v.x;
|
||||
sy = sy + v.y;
|
||||
}
|
||||
}
|
||||
textureStore(out, vec2<i32>(cx, cy), vec4<f32>(sx * 0.25, sy * 0.25, 0.0, 0.0));
|
||||
}
|
||||
|
||||
// Plane::get: the packed luma at (x, y), clamped to the plane.
|
||||
fn imp_fl_tex(x: i32, y: i32, w: i32, h: i32) -> vec2<f32> {
|
||||
let v = textureLoad(src, clamp(vec2<i32>(x, y), vec2<i32>(0, 0), vec2<i32>(w - 1, h - 1)), 0);
|
||||
return vec2<f32>(v.x, v.y);
|
||||
}
|
||||
|
||||
// The block match's SAD with A's block already loaded (`la`, row-major): B's luma is the packed
|
||||
// .y. The sub-pixel kernel's five fits share the window's block, so it is read once; the search
|
||||
// hoists its own 4×4/8×8 block the same way. The loops' bounds are literals so the backend can
|
||||
// unroll them and keep the loads in flight — a runtime-bound loop runs an order of magnitude
|
||||
// slower.
|
||||
fn imp_fl_sad8h(bx: i32, by: i32, dx: i32, dy: i32, w: i32, h: i32, la: array<f32, 64>) -> f32 {
|
||||
var s = 0.0;
|
||||
for (var yy = 0; yy < 8; yy = yy + 1) {
|
||||
for (var xx = 0; xx < 8; xx = xx + 1) {
|
||||
s = s + abs(la[yy * 8 + xx] - imp_fl_tex(bx + xx + dx, by + yy + dy, w, h).y);
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// block_flow_window's coarse-to-fine search at one level: one integer vector per tile, seeded by
|
||||
// the previous (coarser) level's vector at the tile and its four neighbours. `aux` is that field
|
||||
// (`P.u[2].x` = 0 at the coarsest level, where the only seed is the zero vector).
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn imp_fl_search(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let cols = i32(P.u[0].z);
|
||||
let rows = i32(P.u[0].w);
|
||||
let c = i32(gid.x);
|
||||
let r = i32(gid.y);
|
||||
if (c >= cols || r >= rows) {
|
||||
return;
|
||||
}
|
||||
let w = i32(P.u[0].x);
|
||||
let h = i32(P.u[0].y);
|
||||
let bs = i32(P.u[1].x);
|
||||
let k = i32(P.u[1].z);
|
||||
let rad = i32(P.u[1].w);
|
||||
let lbs = max(i32(P.u[1].y) / k, 4);
|
||||
let bx = c * bs / k;
|
||||
let by = r * bs / k;
|
||||
// The zero vector plus up to five neighbour seeds (the tile and its four neighbours).
|
||||
var seeds: array<vec2<i32>, 6>;
|
||||
var ns = 0;
|
||||
seeds[ns] = vec2<i32>(0, 0);
|
||||
ns = ns + 1;
|
||||
if (P.u[2].x != 0u) {
|
||||
var nbr = array<vec2<i32>, 5>(vec2<i32>(0, 0), vec2<i32>(-1, 0), vec2<i32>(1, 0), vec2<i32>(0, -1), vec2<i32>(0, 1));
|
||||
for (var j = 0; j < 5; j = j + 1) {
|
||||
let nc = c + nbr[j].x;
|
||||
let nr = r + nbr[j].y;
|
||||
if (nc >= 0 && nr >= 0 && nc < cols && nr < rows) {
|
||||
let pv = textureLoad(aux, vec2<i32>(nc, nr), 0);
|
||||
seeds[ns] = vec2<i32>(i32(round(pv.x)) * 2, i32(round(pv.y)) * 2);
|
||||
ns = ns + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// sort_unstable + dedup: distinct seeds in lexicographic (x, then y) order.
|
||||
for (var i = 1; i < ns; i = i + 1) {
|
||||
let key = seeds[i];
|
||||
var j = i - 1;
|
||||
while (j >= 0 && (seeds[j].x > key.x || (seeds[j].x == key.x && seeds[j].y > key.y))) {
|
||||
seeds[j + 1] = seeds[j];
|
||||
j = j - 1;
|
||||
}
|
||||
seeds[j + 1] = key;
|
||||
}
|
||||
var m = 0;
|
||||
for (var i = 0; i < ns; i = i + 1) {
|
||||
if (m == 0 || seeds[i].x != seeds[m - 1].x || seeds[i].y != seeds[m - 1].y) {
|
||||
seeds[m] = seeds[i];
|
||||
m = m + 1;
|
||||
}
|
||||
}
|
||||
ns = m;
|
||||
// A's block is the same for every candidate, so load it once (the unrolled loops keep it in
|
||||
// registers) and the search only reads B's shifted block.
|
||||
var la4: array<f32, 16>;
|
||||
var la8: array<f32, 64>;
|
||||
if (lbs >= 8) {
|
||||
for (var yy = 0; yy < 8; yy = yy + 1) {
|
||||
for (var xx = 0; xx < 8; xx = xx + 1) {
|
||||
la8[yy * 8 + xx] = imp_fl_tex(bx + xx, by + yy, w, h).x;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (var yy = 0; yy < 4; yy = yy + 1) {
|
||||
for (var xx = 0; xx < 4; xx = xx + 1) {
|
||||
la4[yy * 4 + xx] = imp_fl_tex(bx + xx, by + yy, w, h).x;
|
||||
}
|
||||
}
|
||||
}
|
||||
var best = vec2<i32>(0, 0);
|
||||
var best_e = 1e30;
|
||||
for (var si = 0; si < ns; si = si + 1) {
|
||||
let s = seeds[si];
|
||||
for (var dy = -rad; dy <= rad; dy = dy + 1) {
|
||||
for (var dx = -rad; dx <= rad; dx = dx + 1) {
|
||||
let d0 = s.x + dx;
|
||||
let d1 = s.y + dy;
|
||||
var acc = 0.0;
|
||||
if (lbs >= 8) {
|
||||
for (var yy = 0; yy < 8; yy = yy + 1) {
|
||||
for (var xx = 0; xx < 8; xx = xx + 1) {
|
||||
acc = acc + abs(la8[yy * 8 + xx] - imp_fl_tex(bx + xx + d0, by + yy + d1, w, h).y);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (var yy = 0; yy < 4; yy = yy + 1) {
|
||||
for (var xx = 0; xx < 4; xx = xx + 1) {
|
||||
acc = acc + abs(la4[yy * 4 + xx] - imp_fl_tex(bx + xx + d0, by + yy + d1, w, h).y);
|
||||
}
|
||||
}
|
||||
}
|
||||
let e = acc + 1e-4 * f32(abs(d0) + abs(d1));
|
||||
if (e < best_e) {
|
||||
best_e = e;
|
||||
best = vec2<i32>(d0, d1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
textureStore(out, vec2<i32>(c, r), vec4<f32>(f32(best.x), f32(best.y), 0.0, 0.0));
|
||||
}
|
||||
|
||||
// The parabola fit of the SAD around the best vector (block_flow_window's sub-pixel step).
|
||||
fn imp_fl_fit(em: f32, ep: f32, e0: f32) -> f32 {
|
||||
let den = em - 2.0 * e0 + ep;
|
||||
if (den > 1e-6) {
|
||||
return clamp(0.5 * (em - ep) / den, -0.5, 0.5);
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Sub-pixel refinement at full resolution: `aux` is the integer field of level 0, `src` the
|
||||
// level-0 packed luma (`P.u[1]` = (tile, window, 0, 0)).
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn imp_fl_subpix(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let cols = i32(P.u[0].z);
|
||||
let rows = i32(P.u[0].w);
|
||||
let c = i32(gid.x);
|
||||
let r = i32(gid.y);
|
||||
if (c >= cols || r >= rows) {
|
||||
return;
|
||||
}
|
||||
let w = i32(P.u[0].x);
|
||||
let h = i32(P.u[0].y);
|
||||
let bs = i32(P.u[1].x);
|
||||
let win = i32(P.u[1].y);
|
||||
let bx = c * bs;
|
||||
let by = r * bs;
|
||||
let f = textureLoad(aux, vec2<i32>(c, r), 0);
|
||||
let d = vec2<i32>(i32(round(f.x)), i32(round(f.y)));
|
||||
// The window's A block is the same for the five SADs: load it once (the loop is unrolled).
|
||||
var la: array<f32, 64>;
|
||||
for (var yy = 0; yy < 8; yy = yy + 1) {
|
||||
for (var xx = 0; xx < 8; xx = xx + 1) {
|
||||
la[yy * 8 + xx] = imp_fl_tex(bx + xx, by + yy, w, h).x;
|
||||
}
|
||||
}
|
||||
let e0 = imp_fl_sad8h(bx, by, d.x, d.y, w, h, la);
|
||||
let sx = imp_fl_fit(imp_fl_sad8h(bx, by, d.x - 1, d.y, w, h, la), imp_fl_sad8h(bx, by, d.x + 1, d.y, w, h, la), e0);
|
||||
let sy = imp_fl_fit(imp_fl_sad8h(bx, by, d.x, d.y - 1, w, h, la), imp_fl_sad8h(bx, by, d.x, d.y + 1, w, h, la), e0);
|
||||
textureStore(out, vec2<i32>(c, r), vec4<f32>(f32(d.x) + sx, f32(d.y) + sy, 0.0, 0.0));
|
||||
}
|
||||
|
||||
// The 3×3 component-wise vector median against outliers (each component sorted on its own).
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn imp_fl_median(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let cols = i32(P.u[0].z);
|
||||
let rows = i32(P.u[0].w);
|
||||
let c = i32(gid.x);
|
||||
let r = i32(gid.y);
|
||||
if (c >= cols || r >= rows) {
|
||||
return;
|
||||
}
|
||||
var xs: array<f32, 9>;
|
||||
var ys: array<f32, 9>;
|
||||
var n = 0;
|
||||
for (var dr = -1; dr <= 1; dr = dr + 1) {
|
||||
for (var dc = -1; dc <= 1; dc = dc + 1) {
|
||||
let nc = c + dc;
|
||||
let nr = r + dr;
|
||||
if (nc >= 0 && nr >= 0 && nc < cols && nr < rows) {
|
||||
let v = textureLoad(src, vec2<i32>(nc, nr), 0);
|
||||
xs[n] = v.x;
|
||||
ys[n] = v.y;
|
||||
n = n + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (var i = 1; i < n; i = i + 1) {
|
||||
let kx = xs[i];
|
||||
var j = i - 1;
|
||||
while (j >= 0 && xs[j] > kx) {
|
||||
xs[j + 1] = xs[j];
|
||||
j = j - 1;
|
||||
}
|
||||
xs[j + 1] = kx;
|
||||
let ky = ys[i];
|
||||
j = i - 1;
|
||||
while (j >= 0 && ys[j] > ky) {
|
||||
ys[j + 1] = ys[j];
|
||||
j = j - 1;
|
||||
}
|
||||
ys[j + 1] = ky;
|
||||
}
|
||||
textureStore(out, vec2<i32>(c, r), vec4<f32>(xs[n / 2], ys[n / 2], 0.0, 0.0));
|
||||
}
|
||||
@@ -53,12 +53,12 @@ fn ps_norm3(v: vec3<f32>) -> vec3<f32> {
|
||||
fn ps_rand_dir(i: u32, k: u32, flat: bool) -> vec3<f32> {
|
||||
if (flat) {
|
||||
let a = ps_h(i, k) * PS_TAU;
|
||||
return vec3<f32>(cos(a), sin(a), 0.0);
|
||||
return vec3<f32>(cos_p(a), sin_p(a), 0.0);
|
||||
}
|
||||
let z = ps_hs(i, k);
|
||||
let a = ps_h(i, k + 1u) * PS_TAU;
|
||||
let r = sqrt(max(1.0 - z * z, 0.0));
|
||||
return vec3<f32>(r * cos(a), r * sin(a), z);
|
||||
return vec3<f32>(r * cos_p(a), r * sin_p(a), z);
|
||||
}
|
||||
|
||||
struct Part {
|
||||
@@ -189,7 +189,7 @@ fn ps_spawn_cannon(id: u32) -> Part {
|
||||
let vel = S.f[1].y;
|
||||
let vel_spread = S.f[1].z;
|
||||
let a = radians(angle + ps_hs(id, 1u) * spread * 0.5);
|
||||
let d = vec2<f32>(sin(a), -cos(a));
|
||||
let d = vec2<f32>(sin_p(a), -cos_p(a));
|
||||
let speed = max(vel + ps_hs(id, 3u) * vel_spread, 0.0);
|
||||
var off: vec2<f32>;
|
||||
if (r >= 0.0) {
|
||||
@@ -197,7 +197,7 @@ fn ps_spawn_cannon(id: u32) -> Part {
|
||||
} else {
|
||||
let aa = ps_h(id, 5u) * PS_TAU;
|
||||
let rr = sqrt(ps_h(id, 6u)) * -r;
|
||||
off = vec2<f32>(cos(aa) * rr, sin(aa) * rr);
|
||||
off = vec2<f32>(cos_p(aa) * rr, sin_p(aa) * rr);
|
||||
}
|
||||
let v = d * speed;
|
||||
let dt = S.f[2].z;
|
||||
|
||||
+582
-2
@@ -52,10 +52,108 @@ mod fx_vr;
|
||||
mod fx_warp;
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod init;
|
||||
mod inpaint;
|
||||
mod particles;
|
||||
mod three_d;
|
||||
mod wgsl_grammar;
|
||||
|
||||
pub(crate) const MATH_PROBE_SHADER: &str = r#"
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn math_probe(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (p.x >= out_dims().x || p.y >= out_dims().y) { return; }
|
||||
let v = textureLoad(src, p, 0).x;
|
||||
if (P.u[0].x == 1u) {
|
||||
let a = textureLoad(src, p, 0).w;
|
||||
let q = unit_div(v, a);
|
||||
textureStore(out, p, vec4<f32>(q, fxs_posterize_bin(q, P.u[0].y), 0.0, 1.0));
|
||||
return;
|
||||
}
|
||||
textureStore(out, p, vec4<f32>(sin_p(v), cos_p(v), 0.0, 1.0));
|
||||
}
|
||||
"#;
|
||||
|
||||
/// Direction errors accumulate into visible particle offsets and spherical
|
||||
/// remapping errors. Check the numerical primitive before rendering a scene.
|
||||
#[test]
|
||||
fn angular_math_preserves_particle_and_vr_directions() {
|
||||
let Some(g) = gpu() else { return };
|
||||
for extent in [std::f32::consts::TAU, 100.0, 8192.0] {
|
||||
let mut img = Image::new(4097, 1);
|
||||
for (i, px) in img.data.iter_mut().enumerate() {
|
||||
px[0] = (i as f32 - 2048.0) * extent / 2048.0;
|
||||
}
|
||||
let input = g.ctx.upload_image(&img).unwrap();
|
||||
let mut e = crate::context::Enc::new(&g.ctx);
|
||||
let out = e.scratch(img.width, img.height);
|
||||
e.pixels("math_probe", &Default::default(), &input, None, &out, None);
|
||||
let result = e.download(&out).unwrap();
|
||||
for (i, (v, got)) in img.data.iter().zip(result.data).enumerate() {
|
||||
let want = [(v[0] as f64).sin() as f32, (v[0] as f64).cos() as f32];
|
||||
for c in 0..2 {
|
||||
assert!((want[c] - got[c]).abs() <= 2e-7, "sample {i}, angle {}: CPU {want:?}, GPU {got:?}", v[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alpha_division_and_posterization_match_f32_rounding() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let mut values = vec![];
|
||||
for levels in [1u32, 3, 8, 254] {
|
||||
values.clear();
|
||||
for a in [1.0f32, 0.1, 0.3, 1.0 / 255.0, 6.0 / 255.0] {
|
||||
for i in 0..levels {
|
||||
let half = (i as f32 + 0.5) / levels as f32;
|
||||
for v in [0.0, 1e-6, half.next_down(), half, half.next_up(), 1.0] {
|
||||
values.push([v * a, 0.0, 0.0, a]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let img = Image { width: values.len() as u32, height: 1, data: values.clone() };
|
||||
let input = g.ctx.upload_image(&img).unwrap();
|
||||
let mut e = crate::context::Enc::new(&g.ctx);
|
||||
let out = e.scratch(img.width, 1);
|
||||
let mut params = crate::context::Params::default();
|
||||
params.u[0] = [1, levels, 0, 0];
|
||||
e.pixels("math_probe", ¶ms, &input, None, &out, None);
|
||||
let result = e.download(&out).unwrap();
|
||||
for (i, (v, got)) in values.iter().zip(result.data).enumerate() {
|
||||
let q = (v[0] / v[3]).clamp(0.0, 1.0);
|
||||
assert_eq!(q.to_bits(), got[0].to_bits(), "levels {levels}, pixel {i}, input {v:?}");
|
||||
assert_eq!((q * levels as f32).round(), got[1], "levels {levels}, pixel {i}, input {v:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prove that the numerical fixes run on the device, without per-effect image
|
||||
/// downloads or CPU fallback. Compiler-limited DX12 devices retain their tested
|
||||
/// fallback contract; these kernels must compile on the Vulkan test adapters.
|
||||
#[test]
|
||||
fn numerical_effects_remain_resident_on_vulkan() {
|
||||
let Some(g) = gpu() else { return };
|
||||
if !g.ctx.name.ends_with("(Vulkan)") {
|
||||
return;
|
||||
}
|
||||
for entry in ["fxs_point", "fxt_point", "ftr_grid", "fp2_hextile"] {
|
||||
assert!(!g.ctx.unavailable_kernels().iter().any(|(name, _)| *name == entry), "{entry}: {:?}", g.ctx.unavailable_kernels());
|
||||
}
|
||||
for (id, vals) in [
|
||||
("ec.stylize.posterize", vec![("level", n(4.0))]),
|
||||
("ec.color.psarbitrarymap", vec![("phase", n(40.0))]),
|
||||
("ec.transition.ccgridwipe", vec![("completion", n(40.0))]),
|
||||
("ec.stylize.cchextile", vec![("radius", n(9.3))]),
|
||||
] {
|
||||
let before = g.ctx.transfer_stats();
|
||||
effect_direct(id, &vals, false);
|
||||
let after = g.ctx.transfer_stats();
|
||||
// Full- and half-resolution runs each upload once and download once.
|
||||
assert_eq!(after.uploads - before.uploads, 2, "{id}: uploads");
|
||||
assert_eq!(after.readbacks - before.readbacks, 2, "{id}: readbacks");
|
||||
}
|
||||
}
|
||||
|
||||
/// One GPU test at a time in this process: the calling test thread holds a process-wide lock
|
||||
/// until it exits (libtest runs every test on a thread of its own). wgpu's OpenGL backend (Mesa
|
||||
/// llvmpipe on FreeBSD, Linux without Vulkan) has one context lock per adapter and panics when a
|
||||
@@ -784,7 +882,7 @@ fn display_frames_are_mipmapped() {
|
||||
view_formats: &[],
|
||||
});
|
||||
let mut e = crate::context::Enc::new(&g.ctx);
|
||||
e.encoder().copy_texture_to_texture(
|
||||
e.record(&[]).copy_texture_to_texture(
|
||||
wgpu::TexelCopyTextureInfo { texture: &f.texture, mip_level: 1, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
|
||||
wgpu::TexelCopyTextureInfo { texture: &tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
|
||||
wgpu::Extent3d { width: w1, height: h1, depth_or_array_layers: 1 },
|
||||
@@ -920,7 +1018,7 @@ fn a_failed_frame_returns_no_texture_to_the_pool() {
|
||||
let rejected = rejected_texture(&g);
|
||||
let earlier = g.within_memory(|| crate::context::Enc::new(&g.ctx).image(48, 16)).expect("an earlier frame");
|
||||
let mut other = crate::context::Enc::new(&g.ctx);
|
||||
other.encoder();
|
||||
other.record(&[]);
|
||||
g.within_memory(|| (crate::context::Enc::new(&g.ctx).image(64, 32), rejected.create_view(&Default::default()))).expect_err("the frame fails");
|
||||
drop(earlier);
|
||||
drop(other);
|
||||
@@ -1347,3 +1445,485 @@ fn skipping_the_hdr_step_on_the_gpu_matches_clip() {
|
||||
assert!(img.data.iter().any(|p| p[3] > 0.0 && p[0] / p[3] > 1.0), "{mode:?}: over-range values survive");
|
||||
}
|
||||
}
|
||||
|
||||
/// A GPU context of its own with a small pool budget, so other tests (sharing [`gpu`]) neither
|
||||
/// see the budget nor add to what the pool keeps.
|
||||
fn private_gpu(budget: usize) -> Option<Gpu> {
|
||||
gpu()?;
|
||||
let g = Gpu::headless()?;
|
||||
g.context().set_pool_budget(budget);
|
||||
Some(g)
|
||||
}
|
||||
|
||||
/// Three effect calls while a frame encoder is open with recorded work reuse one set of
|
||||
/// textures: lent calls give them back at once, without waiting for that frame to submit.
|
||||
#[test]
|
||||
fn three_effect_calls_in_a_frame_reuse_one_set_of_textures() {
|
||||
let Some(g) = private_gpu(8 << 20) else { return };
|
||||
let ctx = g.context();
|
||||
let mut frame = crate::context::Enc::new(ctx);
|
||||
// Open the frame encoder with real work so the pool has an open id (an empty Enc::new does not).
|
||||
let src = frame.image(64, 64);
|
||||
let dst = frame.image(64, 64);
|
||||
frame.copy_into(&src, &dst, 0, 0);
|
||||
let (hits0, misses0) = ctx.pool_stats();
|
||||
for _ in 0..3 {
|
||||
let mut e = crate::context::Enc::lent(ctx);
|
||||
let a = e.image(256, 256);
|
||||
let b = e.image(256, 256);
|
||||
drop((a, b));
|
||||
drop(e);
|
||||
}
|
||||
let (hits, misses) = (ctx.pool_stats().0 - hits0, ctx.pool_stats().1 - misses0);
|
||||
assert_eq!(misses, 2, "only the first effect call allocates: {hits} hits, {misses} misses");
|
||||
assert_eq!(hits, 4, "calls 2 and 3 reuse both textures: {hits} hits, {misses} misses");
|
||||
frame.submit();
|
||||
drop((src, dst, frame));
|
||||
}
|
||||
|
||||
/// Review of #1523: after the first frame fills the free list, later identical frames hit
|
||||
/// instead of allocating again.
|
||||
#[test]
|
||||
fn a_repeated_frame_reuses_after_the_first() {
|
||||
let Some(g) = private_gpu(8 << 20) else { return };
|
||||
let ctx = g.context();
|
||||
let frame = || {
|
||||
let mut e = crate::context::Enc::lent(ctx);
|
||||
drop((e.image(256, 256), e.image(128, 64)));
|
||||
drop(e);
|
||||
ctx.pool_frame_end();
|
||||
};
|
||||
let (hits0, misses0) = ctx.pool_stats();
|
||||
frame();
|
||||
let (hits1, misses1) = (ctx.pool_stats().0 - hits0, ctx.pool_stats().1 - misses0);
|
||||
assert_eq!(misses1, 2, "first frame allocates both sizes: {hits1} hits, {misses1} misses");
|
||||
let (hits_a, misses_a) = ctx.pool_stats();
|
||||
for _ in 0..3 {
|
||||
frame();
|
||||
}
|
||||
let (hits, misses) = (ctx.pool_stats().0 - hits_a, ctx.pool_stats().1 - misses_a);
|
||||
assert_eq!(misses, 0, "frames 2..4 allocate nothing: {hits} hits, {misses} misses");
|
||||
assert!(hits >= 6, "frames 2..4 reuse both sizes: {hits} hits");
|
||||
}
|
||||
|
||||
/// Review of #1523: every idle effect-call owner kept up to the pool budget of its own,
|
||||
/// outside the shared accounting, and there are as many as calls ever ran at once (16 frame
|
||||
/// threads: ~12 GB after a 4K render). Finished calls share the free list's one budget, and
|
||||
/// the end of a render drops it.
|
||||
#[test]
|
||||
fn idle_effect_calls_share_one_pool_budget() {
|
||||
let budget = 8 << 20;
|
||||
let Some(g) = private_gpu(budget) else { return };
|
||||
let ctx = g.context();
|
||||
// 16 effect calls in flight at once, each with three 1 MB working textures.
|
||||
let mut encs: Vec<_> = (0..16).map(|_| crate::context::Enc::lent(ctx)).collect();
|
||||
for e in &mut encs {
|
||||
let imgs: Vec<_> = (0..3).map(|_| e.image(256, 256)).collect();
|
||||
drop(imgs);
|
||||
}
|
||||
drop(encs);
|
||||
let kept = ctx.pool_retained_bytes();
|
||||
assert!(kept <= budget, "idle effect calls keep {} MB, budget {} MB", kept >> 20, budget >> 20);
|
||||
// A later call reuses them instead of allocating.
|
||||
let (hits, _) = ctx.pool_stats();
|
||||
drop(crate::context::Enc::lent(ctx).image(256, 256));
|
||||
assert_eq!(ctx.pool_stats().0, hits + 1, "a later effect call reuses a kept texture");
|
||||
effectcraft_render::Accelerator::render_end(&g);
|
||||
assert_eq!(ctx.pool_retained_bytes(), 0, "the end of a render drops the kept textures");
|
||||
}
|
||||
|
||||
/// Review of #1523: a full free list rejected every new texture instead of evicting, so once
|
||||
/// sizes that no longer occur (per-frame blur bounds, padded layer sizes) filled it, a steady
|
||||
/// size never got a hit again: ~0.75 GB of dead weight plus an allocation per working texture.
|
||||
#[test]
|
||||
fn stale_sizes_do_not_starve_a_steady_size() {
|
||||
let budget = 8 << 20;
|
||||
let Some(g) = private_gpu(budget) else { return };
|
||||
let ctx = g.context();
|
||||
for k in 0..16 {
|
||||
let mut e = crate::context::Enc::lent(ctx);
|
||||
drop((e.image(256 + k, 256), e.image(256 + k, 256)));
|
||||
drop(e);
|
||||
ctx.pool_frame_end();
|
||||
}
|
||||
let (hits0, misses0) = ctx.pool_stats();
|
||||
for _ in 0..10 {
|
||||
let mut e = crate::context::Enc::lent(ctx);
|
||||
drop((e.image(300, 300), e.image(300, 300)));
|
||||
drop(e);
|
||||
ctx.pool_frame_end();
|
||||
}
|
||||
let (hits, misses) = (ctx.pool_stats().0 - hits0, ctx.pool_stats().1 - misses0);
|
||||
assert!(ctx.pool_retained_bytes() <= budget);
|
||||
assert!(ctx.pool_evictions() > 0, "the stale sizes were evicted");
|
||||
assert!(hits >= 18 && misses <= 2, "steady 300x300 calls after stale sizes: {hits} hits, {misses} misses");
|
||||
}
|
||||
|
||||
/// Review of #1523: a `pool_frame_end` wait that clears none of the evicted bytes (timeout, or
|
||||
/// callbacks that never run) must not re-wait on every later frame while `dying` stays put.
|
||||
#[test]
|
||||
fn a_wait_without_progress_is_not_repeated() {
|
||||
let Some(g) = private_gpu(8 << 20) else { return };
|
||||
let ctx = g.context();
|
||||
let phantom = 1 << 20;
|
||||
ctx.pool_charge_dying(phantom as isize);
|
||||
ctx.pool_frame_end();
|
||||
assert_eq!(ctx.pool_waits(), 1, "first frame with dying bytes waits once");
|
||||
for _ in 0..3 {
|
||||
ctx.pool_frame_end();
|
||||
}
|
||||
assert_eq!(ctx.pool_waits(), 1, "no progress: later frames must not wait again");
|
||||
ctx.pool_charge_dying(-(phantom as isize));
|
||||
ctx.pool_frame_end();
|
||||
assert_eq!(ctx.pool_waits(), 1, "dying cleared: poll only, stall cleared");
|
||||
ctx.pool_charge_dying(phantom as isize);
|
||||
ctx.pool_frame_end();
|
||||
assert_eq!(ctx.pool_waits(), 2, "a new stall waits once");
|
||||
// Partial progress: more dying while stalled → still no wait; drop below stalled → wait.
|
||||
let extra = phantom / 2;
|
||||
ctx.pool_charge_dying(extra as isize);
|
||||
ctx.pool_frame_end();
|
||||
assert_eq!(ctx.pool_waits(), 2, "dying above stalled: poll only");
|
||||
ctx.pool_charge_dying(-(extra as isize + 1));
|
||||
ctx.pool_frame_end();
|
||||
assert_eq!(ctx.pool_waits(), 3, "dying dropped below stalled: wait again");
|
||||
ctx.pool_charge_dying(-((phantom - 1) as isize));
|
||||
}
|
||||
|
||||
/// RTX 3090 4K runs of #1523: evicting the least recently freed texture to fit every newcomer
|
||||
/// thrashes when a frame frees more than the budget in a repeating order (blur/glow
|
||||
/// intermediates): each free evicts the size the next call takes first, so every take
|
||||
/// missed (d_blurglow misses back at the old level, and evict + reallocate raised the peak).
|
||||
/// Sizes still in use stay; a kept subset of the cycle must keep hitting.
|
||||
#[test]
|
||||
fn repeating_sizes_over_the_budget_keep_hitting() {
|
||||
let budget = 8 << 20;
|
||||
let Some(g) = private_gpu(budget) else { return };
|
||||
let ctx = g.context();
|
||||
// Five ~2.1 MB sizes a frame (10.5 MB > 8 MB), one effect call each, same order every frame.
|
||||
let sizes: Vec<(u32, u32)> = (0..5).map(|k| (256 + 8 * k, 512)).collect();
|
||||
let frame = || {
|
||||
for &(w, h) in &sizes {
|
||||
drop(crate::context::Enc::lent(ctx).image(w, h));
|
||||
}
|
||||
ctx.pool_frame_end();
|
||||
};
|
||||
for _ in 0..3 {
|
||||
frame();
|
||||
}
|
||||
let (hits0, misses0) = ctx.pool_stats();
|
||||
let evicted0 = ctx.pool_evictions();
|
||||
for _ in 0..10 {
|
||||
frame();
|
||||
}
|
||||
let (hits, misses) = (ctx.pool_stats().0 - hits0, ctx.pool_stats().1 - misses0);
|
||||
assert!(ctx.pool_retained_bytes() <= budget);
|
||||
assert!(hits >= 30, "10 frames of a 5-size cycle over the budget: {hits} hits, {misses} misses");
|
||||
assert_eq!(ctx.pool_evictions(), evicted0, "sizes in use were evicted");
|
||||
}
|
||||
|
||||
/// Review of #1523: a lent call's image read by another, still open encoder must not go back to
|
||||
/// the lent call for reuse when its lease drops first: that encoder's read is not submitted yet.
|
||||
#[test]
|
||||
fn image_read_by_an_open_encoder_is_not_reused_by_its_lent_call() {
|
||||
let Some(g) = private_gpu(8 << 20) else { return };
|
||||
let ctx = g.context();
|
||||
let mut frame = crate::context::Enc::new(ctx);
|
||||
let mut lent = crate::context::Enc::lent(ctx);
|
||||
let img = lent.image(64, 64);
|
||||
let dst = frame.image(64, 64);
|
||||
frame.copy_into(&img, &dst, 0, 0);
|
||||
drop(img);
|
||||
let (hits, _) = ctx.pool_stats();
|
||||
drop(lent.image(64, 64));
|
||||
assert_eq!(ctx.pool_stats().0, hits, "the lent call reused a texture an open encoder still reads");
|
||||
drop(lent);
|
||||
let (hits, _) = ctx.pool_stats();
|
||||
drop(crate::context::Enc::lent(ctx).image(64, 64));
|
||||
// The other 64x64 (the lent call's second image) is free; the read one still waits.
|
||||
let (hits2, _) = ctx.pool_stats();
|
||||
assert_eq!(hits2, hits + 1);
|
||||
let (h, _) = ctx.pool_stats();
|
||||
let mut e = crate::context::Enc::lent(ctx);
|
||||
let a = e.image(64, 64);
|
||||
let b = e.image(64, 64);
|
||||
assert_eq!(ctx.pool_stats().0, h + 1, "the read texture waits for the open encoder");
|
||||
drop((a, b, e));
|
||||
frame.submit();
|
||||
drop(dst);
|
||||
}
|
||||
|
||||
/// The same through [`Gpu::effects`] from 16 threads at once, as an export's frame threads.
|
||||
#[test]
|
||||
fn effect_calls_on_16_threads_keep_one_pool_budget() {
|
||||
if on_gl() {
|
||||
return; // one context lock per adapter on wgpu's GL backend (see hold_gpu_lock)
|
||||
}
|
||||
let budget = 8 << 20;
|
||||
let Some(g) = private_gpu(budget) else { return };
|
||||
let g = &g;
|
||||
let ctx = g.context();
|
||||
let spec = effectcraft_effects::find("ec.blur.gaussian").unwrap();
|
||||
let size = [240.0, 240.0];
|
||||
let mut params =
|
||||
effectcraft_effects::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, size))).collect() };
|
||||
params.values.insert("blurriness".into(), Value::Scalar(6.0));
|
||||
let buf = effectcraft_effects::Buf { img: pattern(3, 240, 240), offset: [0.0, 0.0], scale: 1.0 };
|
||||
let go = std::sync::Barrier::new(16);
|
||||
std::thread::scope(|s| {
|
||||
for i in 0..16 {
|
||||
let (params, buf, go) = (¶ms, &buf, &go);
|
||||
s.spawn(move || {
|
||||
go.wait();
|
||||
for _ in 0..3 {
|
||||
let fx = effectcraft_effects::EffectCtx { params, time: 0.0, layer_size: size, seed: i, adjustment: false, env: Default::default() };
|
||||
effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: fx }], buf, None).expect("the GPU runs the effect");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
let kept = ctx.pool_retained_bytes();
|
||||
assert!(kept <= budget, "16 threads of effect calls keep {} MB, budget {} MB", kept >> 20, budget >> 20);
|
||||
effectcraft_render::Accelerator::render_end(g);
|
||||
assert_eq!(ctx.pool_retained_bytes(), 0, "the end of a render drops the kept textures");
|
||||
}
|
||||
|
||||
/// Sceptic r2 of #1523: only the export pipeline ended frames, so off export (preview, remote
|
||||
/// frames) the pool's clock stood still, no size ever went stale and a full free list dropped
|
||||
/// every newcomer again. Frames rendered through [`effectcraft_render::passes::comp_frame`]
|
||||
/// (no explicit `pool_frame_end`) must age the sizes they did not ask for.
|
||||
#[test]
|
||||
fn frames_outside_the_export_pipeline_age_the_pool() {
|
||||
let budget = 8 << 20;
|
||||
let Some(g) = private_gpu(budget) else { return };
|
||||
let ctx = g.context();
|
||||
// Fill the free list with sizes no frame asks for again.
|
||||
for k in 0..16 {
|
||||
let mut e = crate::context::Enc::lent(ctx);
|
||||
drop((e.image(256 + k, 256), e.image(256 + k, 256)));
|
||||
}
|
||||
let evicted0 = ctx.pool_evictions();
|
||||
let s = blend_scene(BitDepth::Bpc8, BlendMode::Screen);
|
||||
let mut r = Renderer::new(&s.p, &Pattern, opts());
|
||||
r.accel = Some(&g);
|
||||
for _ in 0..3 {
|
||||
effectcraft_render::passes::block_on(effectcraft_render::passes::comp_frame(&r, s.cid, Tick::ZERO));
|
||||
}
|
||||
for _ in 0..4 {
|
||||
let mut e = crate::context::Enc::lent(ctx);
|
||||
drop((e.image(300, 300), e.image(300, 300)));
|
||||
}
|
||||
assert!(ctx.pool_retained_bytes() <= budget);
|
||||
assert!(ctx.pool_evictions() > evicted0, "rendered frames did not age the pool: stale sizes were never evicted");
|
||||
}
|
||||
|
||||
/// #1550: with a small `EFFECTCRAFT_GPU_MEMCAP_MB` on a backend that reports allocator usage
|
||||
/// (Vulkan, DX12), `Enc::mem_gate` stays quiet when the bytes over the cap are non-reclaimable
|
||||
/// (live uploads), submits and waits when dropped working textures push reclaimable bytes over
|
||||
/// the cap, carries on without retiring the context if a wait times out, warns once when a
|
||||
/// frame's kept memory alone exceeds the cap, waits between two frames that fit only one at a
|
||||
/// time, and recovers budget when a frame is dropped unfinished.
|
||||
#[test]
|
||||
fn mem_gate_reclaims_over_cap_and_tolerates_timeout() {
|
||||
const ENV: &str = "EFFECTCRAFT_GPU_MEMCAP_TEST_CHILD";
|
||||
const SKIP: &str = "mem_gate_reclaims_over_cap_and_tolerates_timeout: skipped";
|
||||
if std::env::var_os(ENV).is_none() {
|
||||
let exe = std::env::current_exe().expect("test binary path");
|
||||
let out = std::process::Command::new(exe)
|
||||
.env(ENV, "1")
|
||||
.env("EFFECTCRAFT_GPU_MEMCAP_MB", "8")
|
||||
.args(["--exact", "tests::mem_gate_reclaims_over_cap_and_tolerates_timeout", "--nocapture"])
|
||||
.output()
|
||||
.expect("spawn child test process");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"child test failed (status {:?}):\nstdout:\n{}\nstderr:\n{}",
|
||||
out.status.code(),
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr),
|
||||
);
|
||||
// The child's output is captured: pass its skip line on.
|
||||
String::from_utf8_lossy(&out.stderr).lines().filter(|l| l.starts_with(SKIP)).for_each(|l| eprintln!("{l}"));
|
||||
return;
|
||||
}
|
||||
|
||||
// EFFECTCRAFT_REQUIRE_GPU=1 (inherited from the parent) fails on a GPU that didn't start.
|
||||
let g = Gpu::try_headless();
|
||||
let Some(g) = require_gpu("mem_gate child", g.as_ref()) else {
|
||||
eprintln!("{SKIP}: no GPU ({})", g.as_ref().err().map_or("", String::as_str));
|
||||
return;
|
||||
};
|
||||
let ctx = g.context();
|
||||
if ctx.allocated_bytes() == 0 {
|
||||
// The backend has no allocator report (Metal, GL, WebGPU): the gate never engages.
|
||||
eprintln!("{SKIP}: adapter {} reports no allocator usage", ctx.name);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Non-reclaimable memory (upload cache) over the 8 MiB cap must not trigger waits.
|
||||
let img = pattern(7, 512, 512); // 4 MiB in RGBA f32
|
||||
let held: Vec<_> = (0..3)
|
||||
.map(|i| {
|
||||
let b = std::sync::Arc::new(effectcraft_effects::Buf { img: img.clone(), offset: [i as f64, 0.0], scale: 1.0 });
|
||||
ctx.upload_buf(&b).expect("upload");
|
||||
b
|
||||
})
|
||||
.collect();
|
||||
let idx = ctx.queue.submit([]);
|
||||
let _ = ctx.poll_readbacks(wgpu::PollType::Wait { submission_index: Some(idx), timeout: Some(std::time::Duration::from_secs(1)) });
|
||||
assert!(ctx.allocated_bytes() > (8 << 20), "uploads should put the allocator over the 8 MiB cap (got {} B)", ctx.allocated_bytes());
|
||||
{
|
||||
let mut e = crate::context::Enc::new(ctx);
|
||||
for _ in 0..16 {
|
||||
e.mem_gate();
|
||||
}
|
||||
}
|
||||
assert_eq!(ctx.memcap_waits(), 0, "mem_gate must not wait when over-cap memory is held by live uploads");
|
||||
assert!(ctx.memcap_warned(), "uploads alone over the cap log the one-time warning");
|
||||
drop(held);
|
||||
ctx.clear_uploads();
|
||||
ctx.pool_frame_end();
|
||||
|
||||
// 2. Dropped working textures over the 2 MiB pool budget and 8 MiB cap trigger a wait and
|
||||
// free the dropped textures in wgpu's allocator.
|
||||
let mut e = crate::context::Enc::new(ctx);
|
||||
let mut peak_before_wait = 0u64;
|
||||
for k in 0..16u32 {
|
||||
let a = e.image(512 + k * 16, 256); // ~2 MiB each, distinct sizes
|
||||
let b = e.image(512 + k * 16, 256);
|
||||
e.copy_into(&a, &b, 0, 0);
|
||||
drop((a, b));
|
||||
peak_before_wait = peak_before_wait.max(ctx.allocated_bytes());
|
||||
e.mem_gate();
|
||||
}
|
||||
drop(e);
|
||||
assert!(peak_before_wait > (8 << 20), "working textures should push allocator over 8 MiB before a wait (peak {} B)", peak_before_wait);
|
||||
let waits = ctx.memcap_waits();
|
||||
assert!(waits > 0, "mem_gate should have waited once reclaimable bytes exceeded the cap");
|
||||
assert!(
|
||||
ctx.allocated_bytes() < peak_before_wait,
|
||||
"waiting should reclaim dropped textures (peak {} B, after {} B)",
|
||||
peak_before_wait,
|
||||
ctx.allocated_bytes()
|
||||
);
|
||||
assert!(ctx.check_health().is_ok());
|
||||
ctx.pool_frame_end();
|
||||
|
||||
// 3. A frame bigger than the cap: live GpuImages count in Pool::leased (kept for every owner),
|
||||
// so three live 512×512 RGBA f32 images (12 MiB) make kept alone exceed the 8 MiB cap.
|
||||
// Waiting cannot free them; mem_gate must not add waits (warning already asserted in section 1).
|
||||
let waits_before_big = ctx.memcap_waits();
|
||||
{
|
||||
let mut e = crate::context::Enc::new(ctx);
|
||||
let live = [e.image(512, 512), e.image(512, 512), e.image(512, 512)];
|
||||
for _ in 0..16 {
|
||||
e.mem_gate();
|
||||
}
|
||||
assert_eq!(ctx.memcap_waits(), waits_before_big, "a frame whose live images alone exceed the cap must not wait");
|
||||
assert!(ctx.check_health().is_ok());
|
||||
drop(live);
|
||||
}
|
||||
ctx.pool_frame_end();
|
||||
|
||||
// 4. Two frames that fit only one at a time: frame 1 creates ~6 MiB, copies, submits its
|
||||
// work and drops its images, so wgpu holds them until the submission is done and polled.
|
||||
// Frame 2 keeps ~6 MiB of new live images and waits for frame 1's reclaimable memory.
|
||||
{
|
||||
let mut e1 = crate::context::Enc::new(ctx);
|
||||
let hold = [e1.image(512, 384), e1.image(512, 384)]; // 6 MiB
|
||||
e1.copy_into(&hold[0], &hold[1], 0, 0);
|
||||
e1.submit();
|
||||
drop((hold, e1));
|
||||
}
|
||||
let allocated_after_f1 = ctx.allocated_bytes();
|
||||
let waits_before_pair = ctx.memcap_waits();
|
||||
let allocated_before_gate;
|
||||
let downloaded;
|
||||
{
|
||||
let mut e2 = crate::context::Enc::new(ctx);
|
||||
let a = e2.image(520, 384);
|
||||
let b = e2.image(504, 384); // ~6 MiB together, sizes distinct from each other and frame 1
|
||||
e2.copy_into(&b, &a, 0, 0);
|
||||
allocated_before_gate = ctx.allocated_bytes();
|
||||
assert!(
|
||||
allocated_before_gate > (8 << 20),
|
||||
"frame 1's unreclaimed memory ({allocated_after_f1} B) plus frame 2's live images should exceed the 8 MiB cap (got {allocated_before_gate} B)"
|
||||
);
|
||||
for _ in 0..16 {
|
||||
e2.mem_gate();
|
||||
}
|
||||
assert!(ctx.memcap_waits() > waits_before_pair, "frame 2 should wait for frame 1's reclaimable memory (its own images stay live)");
|
||||
assert!(
|
||||
ctx.allocated_bytes() < allocated_before_gate,
|
||||
"waiting should reclaim frame 1's dropped textures (before {} B, after {} B)",
|
||||
allocated_before_gate,
|
||||
ctx.allocated_bytes()
|
||||
);
|
||||
downloaded = e2.download(&a);
|
||||
}
|
||||
assert!(downloaded.is_some(), "frame 2 must finish with a correct download after waiting");
|
||||
ctx.pool_frame_end();
|
||||
|
||||
// 5. A failed / unfinished frame releases its budget: drop an Enc mid-flight, then a
|
||||
// following frame must not wait once the pool has drained.
|
||||
let baseline = ctx.allocated_bytes();
|
||||
{
|
||||
let mut e = crate::context::Enc::new(ctx);
|
||||
let _hold = [e.image(512, 512), e.image(512, 512), e.image(480, 480)];
|
||||
// Drop without finishing: same path as a frame that errors before submit.
|
||||
}
|
||||
ctx.pool_frame_end();
|
||||
let _ = ctx.poll_readbacks(wgpu::PollType::Poll);
|
||||
assert!(
|
||||
ctx.allocated_bytes() <= baseline + (2 << 20) + (1 << 20),
|
||||
"dropped frame must release to baseline + pool budget (2 MiB) with slack (got {} B, baseline {} B)",
|
||||
ctx.allocated_bytes(),
|
||||
baseline
|
||||
);
|
||||
let waits_after_drop = ctx.memcap_waits();
|
||||
{
|
||||
let mut e = crate::context::Enc::lent(ctx);
|
||||
for _ in 0..16 {
|
||||
e.mem_gate();
|
||||
}
|
||||
}
|
||||
assert_eq!(ctx.memcap_waits(), waits_after_drop, "a following frame must not wait after a dropped frame released its memory");
|
||||
|
||||
// 6. A zero-timeout wait must not retire the context or break subsequent renders.
|
||||
ctx.set_memcap_wait_timeout(std::time::Duration::ZERO);
|
||||
{
|
||||
let mut e = crate::context::Enc::lent(ctx);
|
||||
for k in 0..16u32 {
|
||||
let a = e.image(384 + k * 16, 256);
|
||||
let b = e.image(384 + k * 16, 256);
|
||||
e.copy_into(&a, &b, 0, 0);
|
||||
drop((a, b));
|
||||
e.mem_gate();
|
||||
}
|
||||
}
|
||||
assert!(ctx.memcap_waits() > waits_after_drop, "mem_gate should have fired again under zero timeout");
|
||||
assert!(ctx.check_health().is_ok(), "a mem_gate wait timeout must carry on without retiring the context");
|
||||
let s = blend_scene(BitDepth::Bpc8, BlendMode::Screen);
|
||||
let r = Renderer::new(&s.p, &Pattern, opts());
|
||||
assert!(g.render(&r, s.cid, Tick::ZERO).is_some(), "context renders normally after a zero-timeout mem_gate wait");
|
||||
|
||||
// 7. Non-blocking polls (`Gpu::poll`, `pool_frame_end`) skip when `poll_lock` is held by
|
||||
// another thread, and `read_buffer` waits for `poll_lock` without consuming its deadline.
|
||||
// Drain any dying pool textures first so the timed path takes Poll (try_lock) rather than Wait.
|
||||
ctx.set_memcap_wait_timeout(crate::readback::TIMEOUT);
|
||||
ctx.pool_frame_end();
|
||||
let (locked_tx, locked_rx) = std::sync::mpsc::channel();
|
||||
std::thread::scope(|sc| {
|
||||
sc.spawn(|| {
|
||||
let _guard = ctx.lock_poll_for_test();
|
||||
locked_tx.send(()).unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(60));
|
||||
});
|
||||
locked_rx.recv().unwrap();
|
||||
let t0 = std::time::Instant::now();
|
||||
g.poll();
|
||||
ctx.pool_frame_end();
|
||||
assert!(t0.elapsed() < std::time::Duration::from_millis(30), "non-blocking polls must not wait on a contended poll_lock (took {:?})", t0.elapsed());
|
||||
assert!(g.render(&r, s.cid, Tick::ZERO).is_some(), "readback waits for poll_lock and succeeds without retiring the context");
|
||||
});
|
||||
}
|
||||
@@ -46,6 +46,66 @@ fn posterize_thresholds_strobe_vignette() {
|
||||
effect_case("ec.stylize.ccvignette", &[("amount", n(-60.0)), ("pinHighlights", n(50.0)), ("angleOfView", n(120.0)), ("center", pt(20.0, 15.0))]);
|
||||
}
|
||||
|
||||
/// Premultiplied, quantized footage often lands exactly on a posterization
|
||||
/// threshold after division by alpha. Exercise that input before any placement
|
||||
/// or compositing can hide a wrong bin behind the normal image tolerance.
|
||||
#[test]
|
||||
fn posterize_quantized_premultiplied_boundaries() {
|
||||
let Some(g) = crate::tests::gpu() else { return };
|
||||
let spec = effectcraft_effects::find("ec.stylize.posterize").unwrap();
|
||||
for level in [4.0, 9.0] {
|
||||
let params = effectcraft_effects::Params { values: [("level".into(), n(level))].into() };
|
||||
let ctx =
|
||||
|| effectcraft_effects::EffectCtx { params: ¶ms, time: 0.0, layer_size: [70.0, 44.0], seed: 11, adjustment: false, env: Default::default() };
|
||||
for seed in 1..=7 {
|
||||
let mut img = crate::tests::pattern(seed, 70, 44);
|
||||
effectcraft_render::color::quantize(&mut img, 255.0);
|
||||
let input = effectcraft_effects::Buf { img, offset: [0.0; 2], scale: 1.0 };
|
||||
let cpu = (spec.render)(&ctx(), input.clone());
|
||||
let gpu = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &input, None).unwrap();
|
||||
for (i, (want, got)) in cpu.img.data.iter().zip(&gpu.img.data).enumerate() {
|
||||
for c in 0..4 {
|
||||
assert!((want[c] - got[c]).abs() <= 1e-6, "level {level} seed {seed} pixel {i}: input {:?}, CPU {want:?}, GPU {got:?}", input.img.data[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapped ramps have a large discontinuity at their end. Check signed and
|
||||
/// fractional phases, including a wrap a few double-precision ulps above zero.
|
||||
#[test]
|
||||
fn arbitrary_map_signed_fractional_and_near_zero_wraps() {
|
||||
let Some(g) = crate::tests::gpu() else { return };
|
||||
let spec = effectcraft_effects::find("ec.color.psarbitrarymap").unwrap();
|
||||
for phase in [-1000.0, -20.5, 0.0, 40.0, 1000.0, 255.99999999999997] {
|
||||
let params = effectcraft_effects::Params {
|
||||
values: [
|
||||
("map".into(), Value::Str((0..256).map(|n| n.to_string()).collect::<Vec<_>>().join(","))),
|
||||
("phase".into(), n(phase)),
|
||||
("applyPhaseMapToAlpha".into(), on()),
|
||||
]
|
||||
.into(),
|
||||
};
|
||||
let ctx =
|
||||
|| effectcraft_effects::EffectCtx { params: ¶ms, time: 0.0, layer_size: [1024.0, 1.0], seed: 7, adjustment: false, env: Default::default() };
|
||||
let mut img = effectcraft_raster::Image::new(1024, 1);
|
||||
for (i, px) in img.data.iter_mut().enumerate() {
|
||||
let v = if i < 512 { i as f32 * 1e-18 } else { (i - 512) as f32 / 511.0 };
|
||||
let a = [1.0f32, 0.1, 0.3, 6.0 / 255.0][i % 4];
|
||||
*px = [v * a, v * a, v * a, a];
|
||||
}
|
||||
let input = effectcraft_effects::Buf { img, offset: [0.0; 2], scale: 1.0 };
|
||||
let cpu = (spec.render)(&ctx(), input.clone());
|
||||
let gpu = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &input, None).unwrap();
|
||||
for (i, (want, got)) in cpu.img.data.iter().zip(&gpu.img.data).enumerate() {
|
||||
for c in 0..4 {
|
||||
assert!((want[c] - got[c]).abs() <= 1e-6, "phase {phase}, pixel {i}: CPU {want:?}, GPU {got:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scatter_brush_strokes() {
|
||||
effect_case("ec.stylize.scatter", &[("amount", n(5.0))]);
|
||||
@@ -228,6 +288,36 @@ fn transform_motion_blur() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #1605: Posterize matches the CPU exactly on every 8 bpc premultiplied colour and alpha. A
|
||||
/// straight value of 5/6 at level 4 rounds to exactly 2.5 on the CPU; drivers that fused the
|
||||
/// product into `round_away`'s subtraction (AMD) rounded it down a level.
|
||||
#[test]
|
||||
fn posterize_matches_the_cpu_on_every_8bpc_pair() {
|
||||
let Some(g) = crate::tests::gpu() else { return };
|
||||
let spec = effectcraft_effects::find("ec.stylize.posterize").unwrap();
|
||||
let size = [256.0, 255.0];
|
||||
let mut params =
|
||||
effectcraft_effects::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, size))).collect() };
|
||||
for level in [4.0, 7.0, 9.0] {
|
||||
params.values.insert("level".into(), n(level));
|
||||
// Pixel (x, a - 1): premultiplied colour min(x, a)/255 on alpha a/255.
|
||||
let mut img = effectcraft_raster::Image::new(256, 255);
|
||||
for a in 1..=255u32 {
|
||||
for x in 0..256u32 {
|
||||
let v = x.min(a) as f32 / 255.0;
|
||||
img.data[((a - 1) * 256 + x) as usize] = [v, v, v, a as f32 / 255.0];
|
||||
}
|
||||
}
|
||||
let buf = effectcraft_effects::Buf { img, offset: [0.0, 0.0], scale: 1.0 };
|
||||
let ctx = || effectcraft_effects::EffectCtx { params: ¶ms, time: 0.0, layer_size: size, seed: 1, adjustment: false, env: Default::default() };
|
||||
let cpu = (spec.render)(&ctx(), buf.clone());
|
||||
let out = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).expect("the GPU runs the effect");
|
||||
let bad: Vec<(usize, usize)> =
|
||||
cpu.img.data.iter().zip(&out.img.data).enumerate().filter(|(_, (c, o))| c != o).map(|(i, _)| ((i % 256).min(i / 256 + 1), i / 256 + 1)).collect();
|
||||
assert!(bad.is_empty(), "level {level}: {} (x, a) pairs differ from the CPU, first {:?}", bad.len(), &bad[..bad.len().min(8)]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Motion Tile's Output size on an adjustment layer with a Transform after it that scales the
|
||||
/// tiles into view, and cropping (Output under 100 %): the stack's grown or cropped frame goes
|
||||
/// back onto the canvas as on the CPU (#1727).
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
//! GPU Content-Aware Fill vs the CPU reference: the CPU's fill is the oracle.
|
||||
//!
|
||||
//! Parity here is a quality contract, not bit-exactness (see `inpaint.rs`): PatchMatch's
|
||||
//! scan-order propagation becomes a two-pass checkerboard. So the tests compare both
|
||||
//! syntheses against the ground truth they are reconstructing, and check the GPU is not the
|
||||
//! worse of the two.
|
||||
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_raster::inpaint::{FillBackend, FillInput, FillOpts, fill_sequence_with, membrane_fill, patch_fill, psnr};
|
||||
|
||||
use crate::tests::gpu;
|
||||
|
||||
/// A textured background (periodic stripes + blocky noise), shifted by `dx` (the CPU tests'
|
||||
/// scene, so the numbers here are comparable with theirs).
|
||||
fn background(w: u32, h: u32, dx: f32) -> Image {
|
||||
let mut img = Image::new(w, h);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let fx = x as f32 + dx;
|
||||
let s = 0.5 + 0.3 * (fx * std::f32::consts::TAU / 10.0).sin();
|
||||
let n = effectcraft_raster::hash_noise(((fx.floor() as i64).rem_euclid(20) / 4) as u32, (y % 20) / 4, 3);
|
||||
img.set(x, y, [s, 0.3 + 0.4 * n, 0.6 - 0.3 * s * n, 1.0]);
|
||||
}
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
fn cut(img: &Image, hole: &[bool]) -> Image {
|
||||
let mut o = img.clone();
|
||||
for (p, h) in o.data.iter_mut().zip(hole) {
|
||||
if *h {
|
||||
*p = [0.0; 4];
|
||||
}
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
fn rect_hole(w: u32, h: u32, x0: u32, y0: u32, s: u32) -> Vec<bool> {
|
||||
(0..w * h).map(|i| (x0..x0 + s).contains(&(i % w)) && (y0..y0 + s).contains(&(i / w))).collect()
|
||||
}
|
||||
|
||||
/// The kernels must exist: a shader that fails to build falls back to the CPU silently, which
|
||||
/// would make every quality test below pass while testing nothing.
|
||||
#[test]
|
||||
fn the_inpaint_kernels_are_available() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let bad: Vec<&str> = g.ctx.unavailable_kernels().iter().filter(|(e, _)| crate::inpaint::KERNELS.contains(e)).map(|(e, _)| *e).collect();
|
||||
assert!(bad.is_empty(), "inpaint kernels did not build on {}: {bad:?}", g.ctx.name);
|
||||
}
|
||||
|
||||
/// The device path (not the CPU fallback) is what the quality tests measure.
|
||||
#[test]
|
||||
fn the_gpu_runs_the_fill_itself() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let (w, h) = (96u32, 72u32);
|
||||
let gt = background(w, h, 0.0);
|
||||
let hole = rect_hole(w, h, 40, 28, 16);
|
||||
let src = cut(>, &hole);
|
||||
assert!(crate::inpaint::patch_image(&g.ctx, &src, &hole, None, 7, 1).is_some(), "PatchMatch fell back to the CPU");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patchmatch_beats_the_membrane_on_texture() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let (w, h) = (96u32, 72u32);
|
||||
let gt = background(w, h, 0.0);
|
||||
let hole = rect_hole(w, h, 40, 28, 16);
|
||||
let src = cut(>, &hole);
|
||||
let mem = membrane_fill(&src, &hole);
|
||||
let pm = g.patch(&src, &hole, None, 7, 1);
|
||||
let (pm_db, mem_db) = (psnr(&pm, >, Some(&hole)), psnr(&mem, >, Some(&hole)));
|
||||
assert!(pm_db > mem_db + 3.0 && pm_db > 20.0, "PatchMatch {pm_db:.1} dB vs membrane {mem_db:.1} dB");
|
||||
for i in 0..hole.len() {
|
||||
if !hole[i] {
|
||||
assert_eq!(pm.data[i], gt.data[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patchmatch_agrees_with_the_cpu() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let (w, h) = (96u32, 72u32);
|
||||
let gt = background(w, h, 0.0);
|
||||
let hole = rect_hole(w, h, 40, 28, 16);
|
||||
let src = cut(>, &hole);
|
||||
let (cpu, gpu_pm) = (patch_fill(&src, &hole, None, 7, 1), g.patch(&src, &hole, None, 7, 1));
|
||||
// Both are syntheses of the same hole: neither is the other's ground truth, so they are
|
||||
// compared as two reconstructions of the same thing. On this periodic background both are
|
||||
// near-exact, so a relative dB check at machine precision says nothing; the device must be a
|
||||
// good reconstruction *and* agree with the CPU (a wrong fill that still looks plausible would
|
||||
// pass the first and fail the second).
|
||||
let gpu_db = psnr(&gpu_pm, >, Some(&hole));
|
||||
assert!(gpu_db > 25.0, "GPU {gpu_db:.1} dB");
|
||||
assert!(psnr(&gpu_pm, &cpu, Some(&hole)) > 30.0, "the two syntheses disagree");
|
||||
}
|
||||
|
||||
/// The `init` path (a frame refined from the previous frame's warped fill) is the finest level
|
||||
/// only, from two rounds of four iterations (`patch_fill`).
|
||||
#[test]
|
||||
fn patchmatch_refines_an_init_frame() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let (w, h) = (96u32, 72u32);
|
||||
let gt = background(w, h, 0.0);
|
||||
let hole = rect_hole(w, h, 40, 28, 16);
|
||||
let src = cut(>, &hole);
|
||||
let init = gt.clone();
|
||||
let (cpu, gpu_pm) = (patch_fill(&src, &hole, Some(&init), 7, 1), g.patch(&src, &hole, Some(&init), 7, 1));
|
||||
assert!(psnr(&gpu_pm, >, Some(&hole)) > 25.0, "{:.1} dB", psnr(&gpu_pm, >, Some(&hole)));
|
||||
assert!(psnr(&gpu_pm, &cpu, Some(&hole)) > 20.0, "the two syntheses disagree");
|
||||
}
|
||||
|
||||
/// A hole across the whole multi-scale pyramid (the coarse levels' membrane start included):
|
||||
/// every level must come back finite and the pyramid must beat a single-level solve.
|
||||
#[test]
|
||||
fn patchmatch_survives_a_large_hole_across_levels() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let (w, h) = (256u32, 192u32);
|
||||
let gt = background(w, h, 0.0);
|
||||
let hole = rect_hole(w, h, 90, 70, 80);
|
||||
let src = cut(>, &hole);
|
||||
let pm = g.patch(&src, &hole, None, 7, 1);
|
||||
assert!(pm.data.iter().flatten().all(|v| v.is_finite()), "the synthesis left a non-finite value");
|
||||
let db = psnr(&pm, >, Some(&hole));
|
||||
assert!(db > 20.0, "{db:.1} dB");
|
||||
for i in 0..hole.len() {
|
||||
if !hole[i] {
|
||||
assert_eq!(pm.data[i], gt.data[i], "known pixel {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A moving object removed from a (static or panning) textured background, filled frame by
|
||||
/// frame through the device backend the sequence driver uses.
|
||||
#[test]
|
||||
fn object_fill_recovers_background_and_is_stable() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let (w, h) = (96u32, 64u32);
|
||||
for pan in [0.0f32, 1.0] {
|
||||
let mut frames = vec![];
|
||||
let mut holes = vec![];
|
||||
let mut gts = vec![];
|
||||
for t in 0..8u32 {
|
||||
let gt = background(w, h, t as f32 * pan);
|
||||
let hole = rect_hole(w, h, 10 + 8 * t, 22, 18);
|
||||
frames.push(cut(>, &hole));
|
||||
holes.push(hole);
|
||||
gts.push(gt);
|
||||
}
|
||||
let input = FillInput { frames, holes, references: vec![] };
|
||||
let out = fill_sequence_with(&input, &FillOpts::default(), g, &mut |_, _| true).expect("fill");
|
||||
for t in 0..out.len() {
|
||||
let db = psnr(&out[t], >s[t], Some(&input.holes[t]));
|
||||
assert!(db > 26.0, "frame {t} pan {pan}: {db:.1} dB");
|
||||
assert_eq!(out[t].data[0][3], 1.0);
|
||||
}
|
||||
if pan == 0.0 {
|
||||
for t in 1..out.len() {
|
||||
let both: Vec<bool> = input.holes[t].iter().zip(&input.holes[t - 1]).map(|(a, b)| *a || *b).collect();
|
||||
let db = psnr(&out[t], &out[t - 1], Some(&both));
|
||||
assert!(db > 30.0, "frames {}–{t} differ: {db:.1} dB", t - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The membrane stays on the CPU (see [`crate::inpaint`]): Edge Blend must still get the CPU's
|
||||
/// result through the backend, not a device one.
|
||||
#[test]
|
||||
fn edge_blend_takes_the_cpu_membrane() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let (w, h) = (80u32, 60u32);
|
||||
let gt = background(w, h, 0.0);
|
||||
let hole = rect_hole(w, h, 30, 20, 14);
|
||||
let input = FillInput { frames: vec![cut(>, &hole); 4], holes: vec![hole.clone(); 4], references: vec![] };
|
||||
let opts = FillOpts { method: effectcraft_raster::inpaint::FillMethod::EdgeBlend, ..Default::default() };
|
||||
let edge = fill_sequence_with(&input, &opts, g, &mut |_, _| true).expect("fill");
|
||||
for t in 1..4 {
|
||||
assert!(psnr(&edge[t], &edge[0], Some(&hole)) > 40.0, "frame {t}");
|
||||
}
|
||||
assert!(psnr(&edge[0], &membrane_fill(&input.frames[0], &hole), Some(&hole)) > 60.0);
|
||||
}
|
||||
|
||||
/// The dilation the level plans are built from (kept honest: the flags' target bit is `near`).
|
||||
#[test]
|
||||
fn the_target_band_is_the_dilated_hole() {
|
||||
let (w, h) = (24u32, 18u32);
|
||||
let hole = rect_hole(w, h, 8, 6, 4);
|
||||
let near = effectcraft_raster::inpaint::dilate(&hole, w as usize, h as usize, 3);
|
||||
assert!(near.iter().filter(|b| **b).count() > hole.iter().filter(|b| **b).count());
|
||||
assert!(near.iter().zip(&hole).all(|(n, h)| !*h || *n));
|
||||
}
|
||||
|
||||
/// The flow's block matching runs on the device and finds the same motion the CPU does: the two
|
||||
/// frames are the membrane-filled neighbours the sequence driver builds, with a hole in each.
|
||||
#[test]
|
||||
fn flow_agrees_with_the_cpu() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let (w, h) = (192u32, 128u32);
|
||||
let a_gt = background(w, h, 0.0);
|
||||
let b_gt = background(w, h, 5.0);
|
||||
let hole_a = rect_hole(w, h, 30, 40, 40);
|
||||
let mut hole_b = vec![false; (w * h) as usize];
|
||||
for y in 50..90 {
|
||||
for x in 120..160 {
|
||||
hole_b[(y * w + x) as usize] = true;
|
||||
}
|
||||
}
|
||||
let a = membrane_fill(&cut(&a_gt, &hole_a), &hole_a);
|
||||
let b = membrane_fill(&cut(&b_gt, &hole_b), &hole_b);
|
||||
assert!(crate::inpaint::flow_image(&g.ctx, &a, &b).is_some(), "the flow fell back to the CPU");
|
||||
let cpu = effectcraft_raster::inpaint::CpuFill.flow(&a, &b, &hole_a, &hole_b);
|
||||
let dev = g.flow(&a, &b, &hole_a, &hole_b);
|
||||
assert_eq!((dev.cols, dev.rows, dev.block), (cpu.cols, cpu.rows, cpu.block));
|
||||
// The field is the CPU's to a float rounding: the luma is the CPU's (`luma_pair`), the
|
||||
// pyramid is exact additions, and the search enumerates candidates in the CPU's order.
|
||||
let diff = cpu.v.iter().zip(&dev.v).map(|(c, d)| (c[0] - d[0]).abs().max((c[1] - d[1]).abs())).fold(0.0f32, f32::max);
|
||||
assert!(diff <= 1e-6, "the flow fields differ by {diff}");
|
||||
// The motion is real: the panning background's 5 px shift survives the completion. `b`'s
|
||||
// content sits 5 px left of `a`'s (`background` adds `dx` inside the sample), so a → b is −5.
|
||||
let m = dev.at(96.0, 64.0);
|
||||
assert!((m[0] + 5.0).abs() < 0.5 && m[1].abs() < 0.5, "flow at centre: {m:?}");
|
||||
}
|
||||
|
||||
/// The vote's σ² comes from a histogram that must count *every* target: the old per-thread
|
||||
/// strided pass kept only the pixels whose bin selected that thread (a biased ~1/256 subsample),
|
||||
/// which left small holes with a `total < 1` fallback and near-uniform vote weights.
|
||||
#[test]
|
||||
fn the_distance_histogram_counts_every_target() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let (w, h) = (48u32, 32u32);
|
||||
// Every target has the same distance, so the histogram is one spike. 0.5 is the SDR range
|
||||
// (bin 128); 1600 is above it, which is reachable from a 32-bpc frame (the reviewer's example:
|
||||
// clamping it to bin 1023 reported σ² ≈ 4, and the vote's weights then rounded to zero).
|
||||
for (d, want) in [(0.5f32, 128.5f32 / 256.0), (1600.0, 1600.0)] {
|
||||
let (mut field, near) = (Image::new(w, h), vec![true; (w * h) as usize]);
|
||||
for px in field.data.iter_mut() {
|
||||
*px = [0.0, 0.0, d, 1.0];
|
||||
}
|
||||
let (sigma2, total) = crate::inpaint::distance_histogram(&g.ctx, &field, &near, w, h).expect("histogram");
|
||||
assert_eq!(total as usize, (w * h) as usize, "distance {d}: the histogram kept {total} of {} samples", w * h);
|
||||
let tol = (want * 1e-3).max(1e-3);
|
||||
assert!((sigma2 - want).abs() <= tol, "distance {d}: sigma2 {sigma2}, want {want}");
|
||||
}
|
||||
}
|
||||
|
||||
/// HDR frames (channels above 1, as a 32-bpc image has) must still fill like the CPU. Their patch
|
||||
/// distances are far past the SDR 0…4 range the histogram used to clamp at, so this covers the
|
||||
/// rescaled binning end to end; `the_distance_histogram_counts_every_target` pins the estimator
|
||||
/// itself (the old clamp read σ² ≈ 4 for a distance of 1600).
|
||||
#[test]
|
||||
fn hdr_frames_fill_like_the_cpu() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let (w, h) = (96u32, 72u32);
|
||||
let mut gt = background(w, h, 0.0);
|
||||
for px in &mut gt.data {
|
||||
// A 32-bpc frame's values are unbounded: at 24× the patch distances run into the thousands.
|
||||
// The old fixed 0…4 histogram reported σ² ≈ 4 for those, `exp(-d/(2σ²))` rounded to zero,
|
||||
// and the vote kept the prior image instead of synthesising.
|
||||
for c in 0..3 {
|
||||
px[c] *= 24.0;
|
||||
}
|
||||
}
|
||||
let hole = rect_hole(w, h, 40, 28, 16);
|
||||
let src = cut(>, &hole);
|
||||
let (cpu, gpu_pm) = (patch_fill(&src, &hole, None, 7, 1), g.patch(&src, &hole, None, 7, 1));
|
||||
let gpu_db = psnr(&gpu_pm, >, Some(&hole));
|
||||
assert!(gpu_db > 25.0, "GPU {gpu_db:.1} dB");
|
||||
assert!(psnr(&gpu_pm, &cpu, Some(&hole)) > 30.0, "the two syntheses disagree");
|
||||
}
|
||||
|
||||
/// A frame too small for one block is the CPU's zero field, on the device path too.
|
||||
#[test]
|
||||
fn small_frames_get_a_zero_flow() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let (w, h) = (6u32, 6u32);
|
||||
let a = background(w, h, 0.0);
|
||||
let b = background(w, h, 2.0);
|
||||
let none = vec![false; (w * h) as usize];
|
||||
let f = g.flow(&a, &b, &none, &none);
|
||||
assert_eq!((f.cols, f.rows), (1, 1));
|
||||
assert_eq!(f.v, vec![[0.0, 0.0]]);
|
||||
}
|
||||
|
||||
/// The flow the sequence driver uses (through the `Gpu` backend) keeps the fill stable: the same
|
||||
/// frames filled with the CPU backend and the device backend agree, so the propagation sees the
|
||||
/// same motion.
|
||||
#[test]
|
||||
fn sequence_fill_with_the_device_flow_matches_the_cpu() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let (w, h) = (96u32, 64u32);
|
||||
let cpu = effectcraft_raster::inpaint::CpuFill;
|
||||
for pan in [0.0f32, 2.0] {
|
||||
let mut frames = vec![];
|
||||
let mut holes = vec![];
|
||||
for t in 0..6u32 {
|
||||
let gt = background(w, h, t as f32 * pan);
|
||||
let hole = rect_hole(w, h, 12 + 6 * t, 22, 18);
|
||||
frames.push(cut(>, &hole));
|
||||
holes.push(hole);
|
||||
}
|
||||
let input = FillInput { frames, holes, references: vec![] };
|
||||
let dev = fill_sequence_with(&input, &FillOpts::default(), g, &mut |_, _| true).expect("gpu fill");
|
||||
let ref_cpu = fill_sequence_with(&input, &FillOpts::default(), &cpu, &mut |_, _| true).expect("cpu fill");
|
||||
for t in 0..dev.len() {
|
||||
let db = psnr(&dev[t], &ref_cpu[t], Some(&input.holes[t]));
|
||||
assert!(db > 40.0, "frame {t} pan {pan}: device vs CPU {db:.1} dB");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -214,6 +214,12 @@ fn draw_collapsed<'a>(
|
||||
}
|
||||
|
||||
fn draw_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut GpuImage) -> Option<()> {
|
||||
let out = draw_layer_on(e, r, ctx, layer, canvas);
|
||||
e.mem_gate();
|
||||
out
|
||||
}
|
||||
|
||||
fn draw_layer_on(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut GpuImage) -> Option<()> {
|
||||
if layer.switches.adjustment {
|
||||
if draw_adjustment(e, r, ctx, layer, canvas).is_some() {
|
||||
return Some(());
|
||||
|
||||
@@ -130,7 +130,7 @@ pub(crate) fn record(e: &mut Enc, yp: &YuvPipe, img: &GpuImage, bg: [f32; 3]) ->
|
||||
],
|
||||
});
|
||||
{
|
||||
let enc = e.encoder();
|
||||
let enc = e.record(&[img]);
|
||||
{
|
||||
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("yuv420"), timestamp_writes: None });
|
||||
pass.set_pipeline(&yp.pipe);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -69,14 +69,18 @@ fn rust_plugins_apply_by_id() {
|
||||
/// Build the example plug-in (skipped when the wasm32 target isn't installed).
|
||||
fn build_example() -> Option<std::path::PathBuf> {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/plugins/posterize-bands");
|
||||
let target = std::env::var_os("CARGO_TARGET_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| root.join("../../../target")).join("plugin-example");
|
||||
let target = if target.is_absolute() { target } else { root.join("../../..").join(target) };
|
||||
let st = std::process::Command::new(env!("CARGO"))
|
||||
.current_dir(&root)
|
||||
.args(["build", "--release", "--target", "wasm32-unknown-unknown", "--target-dir"])
|
||||
.arg(&target)
|
||||
.status()
|
||||
.ok()?;
|
||||
let workspace = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let assigned = std::env::var_os("CARGO_TARGET_DIR");
|
||||
let target = assigned.as_ref().map(std::path::PathBuf::from).unwrap_or_else(|| workspace.join("target"));
|
||||
let target = if target.is_absolute() { target } else { workspace.join(target) };
|
||||
// The plug-in is part of this test's build. Inherit the assigned target and
|
||||
// compiler guards rather than creating a second target at its root.
|
||||
let mut command = std::process::Command::new(env!("CARGO"));
|
||||
command.current_dir(&workspace).args(["build", "--release", "--target", "wasm32-unknown-unknown", "--manifest-path"]).arg(root.join("Cargo.toml"));
|
||||
if assigned.is_none() {
|
||||
command.env("CARGO_TARGET_DIR", &target);
|
||||
}
|
||||
let st = command.status().ok()?;
|
||||
if !st.success() {
|
||||
eprintln!("skipping: the example plug-in didn't build (rustup target add wasm32-unknown-unknown)");
|
||||
return None;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -51,6 +51,21 @@ impl Flow {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `Plane::luma` of `a` and `b` packed into one image (`a` in the red channel, `b` in the
|
||||
/// green; the rest zero). A device block matcher takes this as its input rather than computing
|
||||
/// the luma itself: the weighted sum is not reproducible on a device, which may contract
|
||||
/// `a·b + c` into a fused multiply-add, and the search's near-tie choices would then differ.
|
||||
pub fn luma_pair(a: &Image, b: &Image) -> Image {
|
||||
let luma = |p: &[f32; 4]| 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2] + 0.25 * p[3];
|
||||
let mut out = Image::new(a.width, a.height);
|
||||
out.data.par_iter_mut().enumerate().for_each(|(i, q)| {
|
||||
let la = a.data.get(i).map(luma).unwrap_or(0.0);
|
||||
let lb = b.data.get(i).map(luma).unwrap_or(0.0);
|
||||
*q = [la, lb, 0.0, 0.0];
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// A single-channel plane.
|
||||
struct Plane {
|
||||
w: usize,
|
||||
|
||||
@@ -562,23 +562,31 @@ pub fn patch_fill(img: &Image, hole: &[bool], init: Option<&Image>, patch: usize
|
||||
|
||||
// ------------------------------------------------------------------ flow-guided propagation
|
||||
|
||||
/// Block flow from `a` to `b` with the tiles that touch either hole replaced by the membrane
|
||||
/// interpolation of the surrounding motion (flow completion).
|
||||
/// Block flow `a` → `b` (tile 8, search radius 6) with the tiles that touch either hole replaced
|
||||
/// by the membrane interpolation of the surrounding motion (flow completion).
|
||||
fn completed_flow(a: &Image, b: &Image, hole_a: &[bool], hole_b: &[bool]) -> Flow {
|
||||
let mut f = block_flow(a, b, 8, 6);
|
||||
let f = block_flow(a, b, 8, 6);
|
||||
complete_flow(f, hole_a, hole_b, a.width as usize, a.height as usize)
|
||||
}
|
||||
|
||||
/// The completion half of `completed_flow`: the tiles of `f` (a `block_flow` of `w × h`) that
|
||||
/// touch either hole become the harmonic interpolation of the surrounding motion. Split out so a
|
||||
/// hardware backend can compute the block matching itself and hand the raw field here; the
|
||||
/// completion is cheap (the field is a `cols × rows` grid) and stays on the CPU.
|
||||
pub fn complete_flow(mut f: Flow, hole_a: &[bool], hole_b: &[bool], w: usize, h: usize) -> Flow {
|
||||
if f.v.is_empty() {
|
||||
return f;
|
||||
}
|
||||
let (w, bs) = (a.width as usize, f.block as usize);
|
||||
let bs = f.block as usize;
|
||||
let mut known = vec![true; f.v.len()];
|
||||
for (i, k) in known.iter_mut().enumerate() {
|
||||
let (c, r) = (i % f.cols, i / f.cols);
|
||||
// A tile is unreliable when any pixel within it (and a block's margin) is a hole.
|
||||
let (x0, y0) = ((c * bs).saturating_sub(bs / 2), (r * bs).saturating_sub(bs / 2));
|
||||
let (x1, y1) = (((c + 1) * bs + bs / 2).min(w), ((r + 1) * bs + bs / 2).min(a.height as usize));
|
||||
let (x1, y1) = (((c + 1) * bs + bs / 2).min(w), ((r + 1) * bs + bs / 2).min(h));
|
||||
'tile: for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
if hole_a[y * w + x] || hole_b[y * w + x] {
|
||||
if hole_a.get(y * w + x).copied().unwrap_or(true) || hole_b.get(y * w + x).copied().unwrap_or(true) {
|
||||
*k = false;
|
||||
break 'tile;
|
||||
}
|
||||
@@ -660,8 +668,54 @@ fn trace(context: &TraceContext<'_>, start: TraceStart) -> Option<([f32; 4], usi
|
||||
None
|
||||
}
|
||||
|
||||
/// Fill a range of frames. `progress(done, total)` returns `false` to cancel (→ `None`).
|
||||
/// A hardware backend for the fill's stages (the GPU compositor implements it; see
|
||||
/// `effectcraft_render::Accelerator::inpaint`). Every method has the CPU implementation as its
|
||||
/// default, so a backend that cannot take a stage returns the CPU's work and the fill stays
|
||||
/// correct — only slower.
|
||||
///
|
||||
/// The orchestration ([`fill_sequence_with`]) stays on the CPU: the sequence's frame-to-frame
|
||||
/// coupling is sequential, and only the per-frame and per-pair work is worth offloading.
|
||||
///
|
||||
/// **A backend must be safe to call concurrently.** [`fill_sequence_with`] runs the stages of
|
||||
/// independent frames and frame pairs in parallel (`rayon`), so `membrane`, `flow` and `patch`
|
||||
/// may all be entered from several threads at once — a backend that owns a device must serialise
|
||||
/// its use of it (the GPU compositor's per-call encoder does; a backend whose device cannot be
|
||||
/// used from several threads must lock).
|
||||
pub trait FillBackend: Send + Sync {
|
||||
/// [`membrane_fill`]: the `hole` pixels take the harmonic interpolation of their border.
|
||||
fn membrane(&self, img: &Image, hole: &[bool]) -> Image {
|
||||
membrane_fill(img, hole)
|
||||
}
|
||||
/// `membrane_core`: harmonic interpolation of `data`'s unknown pixels in place (`known` is
|
||||
/// true where the pixel is known).
|
||||
fn membrane_known(&self, data: &mut [[f32; 4]], known: &[bool], w: usize, h: usize) {
|
||||
membrane_core(data, known, w, h)
|
||||
}
|
||||
/// `completed_flow`: block flow `a` → `b` with the tiles touching either hole replaced by
|
||||
/// the membrane interpolation of the surrounding motion.
|
||||
fn flow(&self, a: &Image, b: &Image, hole_a: &[bool], hole_b: &[bool]) -> Flow {
|
||||
completed_flow(a, b, hole_a, hole_b)
|
||||
}
|
||||
/// [`patch_fill`]: PatchMatch synthesis of `img`'s `hole` (`init`: a starting guess, e.g. the
|
||||
/// previous frame's fill warped here).
|
||||
fn patch(&self, img: &Image, hole: &[bool], init: Option<&Image>, patch: usize, seed: u32) -> Image {
|
||||
patch_fill(img, hole, init, patch, seed)
|
||||
}
|
||||
}
|
||||
|
||||
/// The CPU backend: every stage is the reference implementation.
|
||||
pub struct CpuFill;
|
||||
|
||||
impl FillBackend for CpuFill {}
|
||||
|
||||
/// Fill a range of frames on the CPU. `progress(done, total)` returns `false` to cancel
|
||||
/// (→ `None`).
|
||||
pub fn fill_sequence(input: &FillInput, o: &FillOpts, progress: &mut dyn FnMut(usize, usize) -> bool) -> Option<Vec<Image>> {
|
||||
fill_sequence_with(input, o, &CpuFill, progress)
|
||||
}
|
||||
|
||||
/// [`fill_sequence`] with a hardware backend for the stages it can take (see [`FillBackend`]).
|
||||
pub fn fill_sequence_with(input: &FillInput, o: &FillOpts, backend: &dyn FillBackend, progress: &mut dyn FnMut(usize, usize) -> bool) -> Option<Vec<Image>> {
|
||||
let n = input.frames.len();
|
||||
if n == 0 {
|
||||
return Some(vec![]);
|
||||
@@ -672,7 +726,7 @@ pub fn fill_sequence(input: &FillInput, o: &FillOpts, progress: &mut dyn FnMut(u
|
||||
if o.method == FillMethod::EdgeBlend {
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for (i, (f, hl)) in input.frames.iter().zip(&input.holes).enumerate() {
|
||||
out.push(opaque_fill(membrane_fill(f, hl), hl));
|
||||
out.push(opaque_fill(backend.membrane(f, hl), hl));
|
||||
if !progress(i + 1, n) {
|
||||
return None;
|
||||
}
|
||||
@@ -691,10 +745,10 @@ pub fn fill_sequence(input: &FillInput, o: &FillOpts, progress: &mut dyn FnMut(u
|
||||
}
|
||||
}
|
||||
// 1. Flows between membrane-filled neighbours, completed inside the holes.
|
||||
let pre: Vec<Image> = frames.par_iter().zip(&holes).map(|(f, hl)| membrane_fill(f, hl)).collect();
|
||||
let pre: Vec<Image> = frames.par_iter().zip(&holes).map(|(f, hl)| backend.membrane(f, hl)).collect();
|
||||
let pairs: Vec<(Flow, Flow)> = (0..n.saturating_sub(1))
|
||||
.into_par_iter()
|
||||
.map(|i| (completed_flow(&pre[i], &pre[i + 1], &holes[i], &holes[i + 1]), completed_flow(&pre[i + 1], &pre[i], &holes[i + 1], &holes[i])))
|
||||
.map(|i| (backend.flow(&pre[i], &pre[i + 1], &holes[i], &holes[i + 1]), backend.flow(&pre[i + 1], &pre[i], &holes[i + 1], &holes[i])))
|
||||
.collect();
|
||||
let (fwd, bwd): (Vec<Flow>, Vec<Flow>) = pairs.into_iter().unzip();
|
||||
if !progress(1, total) {
|
||||
@@ -746,7 +800,7 @@ pub fn fill_sequence(input: &FillInput, o: &FillOpts, progress: &mut dyn FnMut(u
|
||||
diff[i] = std::array::from_fn(|c| if c < 3 { a[c] - v[c] } else { 0.0 });
|
||||
}
|
||||
}
|
||||
membrane_core(&mut diff, &dk, w, h);
|
||||
backend.membrane_known(&mut diff, &dk, w, h);
|
||||
for (i, _) in &filled {
|
||||
for c in 0..3 {
|
||||
img.data[*i][c] += o.lighting * diff[*i][c];
|
||||
@@ -770,7 +824,7 @@ pub fn fill_sequence(input: &FillInput, o: &FillOpts, progress: &mut dyn FnMut(u
|
||||
*slot = Some(propagated[t].0.clone());
|
||||
}
|
||||
let mut done = 2;
|
||||
out[t0] = Some(patch_fill(&propagated[t0].0, &propagated[t0].1, None, o.patch, o.seed));
|
||||
out[t0] = Some(backend.patch(&propagated[t0].0, &propagated[t0].1, None, o.patch, o.seed));
|
||||
done += 1;
|
||||
if !progress(done, total) {
|
||||
return None;
|
||||
@@ -783,7 +837,7 @@ pub fn fill_sequence(input: &FillInput, o: &FillOpts, progress: &mut dyn FnMut(u
|
||||
// Warp the previous result here along the backward flow (t → t − 1).
|
||||
// Frames after `t0` are filled in order, so the previous one is set.
|
||||
let Some(prev) = out[t - 1].as_ref() else {
|
||||
out[t] = Some(patch_fill(img, left, None, o.patch, o.seed));
|
||||
out[t] = Some(backend.patch(img, left, None, o.patch, o.seed));
|
||||
continue;
|
||||
};
|
||||
let mut warped = img.clone();
|
||||
@@ -796,7 +850,7 @@ pub fn fill_sequence(input: &FillInput, o: &FillOpts, progress: &mut dyn FnMut(u
|
||||
}
|
||||
out[t] = Some(match o.method {
|
||||
FillMethod::Surface => warped,
|
||||
_ => patch_fill(img, left, Some(&warped), o.patch, o.seed),
|
||||
_ => backend.patch(img, left, Some(&warped), o.patch, o.seed),
|
||||
});
|
||||
}
|
||||
done += 1;
|
||||
|
||||
@@ -76,6 +76,16 @@ pub trait Accelerator: Send + Sync {
|
||||
fn particles(&self) -> Option<&dyn effectcraft_effects::psim::ParticleSim> {
|
||||
None
|
||||
}
|
||||
/// A hardware backend for Content-Aware Fill's stages (the GPU compositor's kernels), with
|
||||
/// the CPU fill's semantics. `None` = the fill runs on the CPU.
|
||||
///
|
||||
/// The fill's input frames are read back to the CPU before it runs (analyses render on the
|
||||
/// CPU by design, see `effectcraft_engine::background::offload::job_accel`), so a backend
|
||||
/// here is about the fill's own work — PatchMatch and the flow's block matching today, one
|
||||
/// stage at a time as each pays (`effectcraft_gpu::inpaint`) — not about compositing.
|
||||
fn inpaint(&self) -> Option<&dyn effectcraft_raster::inpaint::FillBackend> {
|
||||
None
|
||||
}
|
||||
/// Timing history [`Backend::Auto`] uses to send each comp's top-level frames to the
|
||||
/// faster compositor. `None` = Auto always tries the accelerator first.
|
||||
fn auto_pick(&self) -> Option<&AutoPick> {
|
||||
@@ -96,6 +106,19 @@ pub trait Accelerator: Send + Sync {
|
||||
/// results, `pass_begin` clears the miss. Synchronous accelerators never miss.
|
||||
fn frame_begin(&self) {}
|
||||
fn pass_begin(&self) {}
|
||||
/// A rendered comp frame is done (the GPU pool ages sizes no frame asks for, and evicts
|
||||
/// only those once full). The multi-frame render paths call it once per comp frame:
|
||||
/// [`crate::passes::comp_frame`] (exports; a field-rendered output frame is two), the
|
||||
/// export's 4:2:0 frames ([`Accelerator::comp_frame_yuv420`]), remote frames and the
|
||||
/// preview workers. One-off renders (`Session::render`, single-frame export,
|
||||
/// thumbnails) don't, so they don't advance the pool's clock. Without it the pool never
|
||||
/// finds a stale size and keeps its first sizes, dropping newcomers.
|
||||
fn frame_end(&self) {}
|
||||
/// A render of many frames (an export) is done: drop what is kept only to speed up the
|
||||
/// next frame (pooled working textures). Exports call it, even when they fail, panic or
|
||||
/// are dropped; other multi-frame paths (preview, remote frames) don't, so there the pool
|
||||
/// keeps up to its budget until the next export ends.
|
||||
fn render_end(&self) {}
|
||||
fn pass_missed(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
@@ -59,11 +59,25 @@ pub async fn in_passes<'a, T>(accel: Option<&'a dyn Accelerator>, mut render: im
|
||||
Passed { value: render(None), passes: MAX_PASSES + 1, accelerated: false }
|
||||
}
|
||||
|
||||
/// Ends the frame for the accelerator ([`Accelerator::frame_end`]) when dropped: also when the
|
||||
/// render panics or the future is dropped between passes.
|
||||
struct FrameEnd<'a>(&'a dyn Accelerator);
|
||||
impl Drop for FrameEnd<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.0.frame_end();
|
||||
}
|
||||
}
|
||||
|
||||
/// A top-level comp frame through [`in_passes`] ([`Renderer::comp_frame`] in passes). Backend
|
||||
/// Auto picks CPU or GPU for the whole frame (all its passes) and is timed as a whole, as the
|
||||
/// frame workers do.
|
||||
/// frame workers do. Ends the frame for the accelerator ([`Accelerator::frame_end`]) on every
|
||||
/// exit, including a panic.
|
||||
pub async fn comp_frame(r: &Renderer<'_>, comp: ItemId, t: Tick) -> Image {
|
||||
let Some(a) = r.accel.filter(|a| a.miss_gate().is_some()) else { return r.comp_frame(comp, t) };
|
||||
let Some(a) = r.accel else { return r.comp_frame(comp, t) };
|
||||
let _end = FrameEnd(a);
|
||||
if a.miss_gate().is_none() {
|
||||
return r.comp_frame(comp, t);
|
||||
}
|
||||
let mut opts = r.opts;
|
||||
let mut auto = None;
|
||||
if opts.backend == Backend::Auto {
|
||||
|
||||
@@ -838,6 +838,50 @@ impl crate::Accelerator for ProbeAccel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts [`Accelerator::frame_end`] and optionally panics inside [`Accelerator::comp_frame`].
|
||||
struct EndAccel {
|
||||
ends: std::sync::atomic::AtomicUsize,
|
||||
panic: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
impl crate::Accelerator for EndAccel {
|
||||
fn name(&self) -> String {
|
||||
"end".into()
|
||||
}
|
||||
fn comp_frame(&self, _r: &crate::Renderer, _comp: ItemId, _t: Tick) -> Option<crate::Image> {
|
||||
if self.panic.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
panic!("comp_frame");
|
||||
}
|
||||
Some(crate::Image::new(1, 1))
|
||||
}
|
||||
fn supports_effect(&self, _id: &str) -> bool {
|
||||
false
|
||||
}
|
||||
fn effects(&self, _chain: &[crate::FxStep], _buf: &crate::Buf, _levels: Option<f32>) -> Option<crate::Buf> {
|
||||
None
|
||||
}
|
||||
fn frame_end(&self) {
|
||||
self.ends.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comp_frame_ends_the_frame_when_the_render_panics() {
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::sync::atomic::Ordering;
|
||||
let (mut p, cid, comp) = setup();
|
||||
let l = solid(&mut p, &comp, [1.0, 0.0, 0.0], 50, 50);
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
let a = EndAccel { ends: Default::default(), panic: true.into() };
|
||||
let mut r = crate::Renderer::new(&p, &crate::NoFootage, crate::RenderOpts { backend: crate::Backend::Gpu, ..Default::default() });
|
||||
r.accel = Some(&a);
|
||||
assert!(catch_unwind(AssertUnwindSafe(|| crate::passes::block_on(crate::passes::comp_frame(&r, cid, Tick::ZERO)))).is_err());
|
||||
assert_eq!(a.ends.load(Ordering::SeqCst), 1);
|
||||
a.panic.store(false, Ordering::SeqCst);
|
||||
a.ends.store(0, Ordering::SeqCst);
|
||||
let _ = crate::passes::block_on(crate::passes::comp_frame(&r, cid, Tick::ZERO));
|
||||
assert_eq!(a.ends.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_backend_sends_2d_and_3d_comps_to_the_gpu() {
|
||||
let (mut p, cid, comp) = setup();
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user