Merge remote-tracking branch 'origin/main' into review-2162

This commit is contained in:
MarekSpodBiedry committed 2026-11-04 07:52:17 +01:00
commit 7b5786fdb6
190 files changed
+14220 -996

No files matched your search

Generated
+89 -39
View File
@@ -1094,6 +1094,16 @@ name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
[[package]]
name = "craft-ecproj-format"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=ca1b5e2edb8568335c6c14f390307bd5beae8238#ca1b5e2edb8568335c6c14f390307bd5beae8238"
dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
@@ -1402,7 +1412,7 @@ dependencies = [
[[package]]
name = "effectcraft"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"cpal",
"craft-update",
@@ -1428,7 +1438,7 @@ dependencies = [
[[package]]
name = "effectcraft-automation"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"effectcraft-engine",
"effectcraft-host",
@@ -1441,14 +1451,14 @@ dependencies = [
[[package]]
name = "effectcraft-av1enc"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"filmcraft-av1",
]
[[package]]
name = "effectcraft-cli"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"cpu-time",
"effectcraft-automation",
@@ -1467,7 +1477,7 @@ dependencies = [
[[package]]
name = "effectcraft-color"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"proptest",
"serde",
@@ -1475,7 +1485,7 @@ dependencies = [
[[package]]
name = "effectcraft-effects"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"chrono",
"effectcraft-color",
@@ -1493,7 +1503,7 @@ dependencies = [
[[package]]
name = "effectcraft-engine"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"chrono",
"craft-docpath",
@@ -1531,7 +1541,7 @@ dependencies = [
[[package]]
name = "effectcraft-export"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"effectcraft-av1enc",
"effectcraft-color",
@@ -1545,6 +1555,7 @@ dependencies = [
"effectcraft-render",
"effectcraft-time",
"effectcraft-vp9enc",
"exr",
"filmcraft-aac",
"filmcraft-h264enc",
"filmcraft-isobmff",
@@ -1561,7 +1572,7 @@ dependencies = [
[[package]]
name = "effectcraft-expr"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"boa_engine",
"effectcraft-color",
@@ -1578,7 +1589,7 @@ dependencies = [
[[package]]
name = "effectcraft-geom"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"proptest",
"serde",
@@ -1586,7 +1597,7 @@ dependencies = [
[[package]]
name = "effectcraft-gpu"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"bytemuck",
"effectcraft-color",
@@ -1601,6 +1612,7 @@ dependencies = [
"js-sys",
"log",
"pollster 1.0.1",
"rayon",
"wasm-bindgen",
"web-time",
"wgpu",
@@ -1608,14 +1620,14 @@ dependencies = [
[[package]]
name = "effectcraft-hevcenc"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"filmcraft-hevc",
]
[[package]]
name = "effectcraft-host"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@@ -1640,7 +1652,7 @@ dependencies = [
[[package]]
name = "effectcraft-interchange"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"effectcraft-color",
"effectcraft-keyframe",
@@ -1659,7 +1671,7 @@ dependencies = [
[[package]]
name = "effectcraft-keyframe"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1671,7 +1683,7 @@ dependencies = [
[[package]]
name = "effectcraft-lottie"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"craft-docpath",
"effectcraft-color",
@@ -1689,7 +1701,7 @@ dependencies = [
[[package]]
name = "effectcraft-media"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"craft-docpath",
"effectcraft-color",
@@ -1721,7 +1733,7 @@ dependencies = [
[[package]]
name = "effectcraft-model"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"effectcraft-geom",
"image",
@@ -1734,26 +1746,26 @@ dependencies = [
[[package]]
name = "effectcraft-opusenc"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"filmcraft-opus",
]
[[package]]
name = "effectcraft-path"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"effectcraft-geom",
"effectcraft-keyframe",
"effectcraft-raster",
"kurbo",
"linesweeper",
"tiny-skia",
"tiny-skia 0.12.0",
]
[[package]]
name = "effectcraft-pdf"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"craft-pdf",
"effectcraft-raster",
@@ -1769,7 +1781,7 @@ dependencies = [
[[package]]
name = "effectcraft-plugin"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"effectcraft-effects",
"effectcraft-keyframe",
@@ -1781,8 +1793,9 @@ dependencies = [
[[package]]
name = "effectcraft-project"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"craft-ecproj-format",
"effectcraft-color",
"effectcraft-geom",
"effectcraft-keyframe",
@@ -1794,14 +1807,14 @@ dependencies = [
[[package]]
name = "effectcraft-psd"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"miniz_oxide 0.9.1",
]
[[package]]
name = "effectcraft-raster"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1810,7 +1823,7 @@ dependencies = [
[[package]]
name = "effectcraft-render"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@@ -1832,7 +1845,7 @@ dependencies = [
[[package]]
name = "effectcraft-sam2"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"bytemuck",
"candle-core",
@@ -1847,7 +1860,7 @@ dependencies = [
[[package]]
name = "effectcraft-script"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"boa_engine",
"effectcraft-engine",
@@ -1865,7 +1878,7 @@ dependencies = [
[[package]]
name = "effectcraft-segment"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"miniz_oxide 0.9.1",
"ndarray",
@@ -1874,7 +1887,7 @@ dependencies = [
[[package]]
name = "effectcraft-svg"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"effectcraft-geom",
"effectcraft-path",
@@ -1886,7 +1899,7 @@ dependencies = [
[[package]]
name = "effectcraft-text"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"effectcraft-keyframe",
"harfrust",
@@ -1898,7 +1911,7 @@ dependencies = [
[[package]]
name = "effectcraft-time"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"proptest",
"serde",
@@ -1906,7 +1919,7 @@ dependencies = [
[[package]]
name = "effectcraft-track"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"effectcraft-raster",
"effectcraft-segment",
@@ -1918,7 +1931,7 @@ dependencies = [
[[package]]
name = "effectcraft-ui-egui"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"chrono",
"craft-quit",
@@ -1954,11 +1967,11 @@ dependencies = [
[[package]]
name = "effectcraft-vp9enc"
version = "0.19.0"
version = "0.21.0"
[[package]]
name = "effectcraft-web"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@@ -5369,6 +5382,17 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sctk-adwaita"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
dependencies = [
"log",
"smithay-client-toolkit 0.19.2",
"tiny-skia 0.11.4",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -6098,6 +6122,20 @@ dependencies = [
"time-core",
]
[[package]]
name = "tiny-skia"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
dependencies = [
"arrayref",
"arrayvec",
"bytemuck",
"cfg-if",
"log",
"tiny-skia-path 0.11.4",
]
[[package]]
name = "tiny-skia"
version = "0.12.0"
@@ -6109,7 +6147,18 @@ dependencies = [
"bytemuck",
"cfg-if",
"log",
"tiny-skia-path",
"tiny-skia-path 0.12.0",
]
[[package]]
name = "tiny-skia-path"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
dependencies = [
"arrayref",
"bytemuck",
"strict-num",
]
[[package]]
@@ -7418,6 +7467,7 @@ dependencies = [
"raw-window-handle",
"redox_syscall 0.4.1",
"rustix 0.38.44",
"sctk-adwaita",
"smithay-client-toolkit 0.19.2",
"smol_str",
"tracing",
@@ -7575,7 +7625,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"chrono",
"pkg-config",
+2 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*"]
[workspace.package]
version = "0.19.0"
version = "0.21.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -14,6 +14,7 @@ repository = "https://github.com/storytold/effectcraft"
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["read"] }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-quit = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-ecproj-format = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "ca1b5e2edb8568335c6c14f390307bd5beae8238" }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
# internal
+31
View File
@@ -27,6 +27,37 @@ The shared patched syntax source is centralized in storytold/craft-libs, transfe
PdfCraft revision 9ed3abe4907a127d36b12fbfeee71d7e26248da1. This parser-only use embeds no
Hayro fonts, CMaps or image codecs; EffectCraft keeps its existing fonts and drawing filters.
Linux Wayland window decorations
-------------------------------
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
for vector window controls. Its title-font features are disabled: no Cantarell
font is embedded by this dependency and no font file is added to this repo.
sctk-adwaita's MIT licence:
MIT License
Copyright (c) 2022 Bartłomiej Maryńczak
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
SIL Open Font License 1.1
Applies to Inter (assets/fonts/Inter-*.ttf), Noto Serif (assets/fonts/NotoSerif-Regular.ttf),
+10 -1
View File
@@ -344,8 +344,17 @@ stand on its own.
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | **Motion graphics and visual effects · you are here** | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
+16 -1
View File
@@ -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::*;
+150
View File
@@ -0,0 +1,150 @@
//! One saving run at a time per project file.
//!
//! A `--save` run reads the project, edits it and writes the whole file back, so two at once would
//! silently drop the edits of whichever saved first (#2851). Each run holds an OS lock from before
//! its read until it exits; the next one waits, then reads what the first one saved. This is how
//! filmcraft-cli does it (storytold/filmcraft#3926).
//!
//! The lock is on a file next to the project, not on the project: a save replaces the project's
//! inode, and on Windows a locked file can't be read by other processes. The OS drops the lock when
//! the process ends, however it ends, so a crash leaves nothing stale. The file itself stays, since
//! removing it would let one run wait on the old file while another locks a new one.
use crate::{Args, Failure};
use std::fs::{File, TryLockError};
use std::io::Write;
use std::path::{Path, PathBuf};
/// Most bytes of the project's file name kept in the lock's name; the rest of the 255-byte limit
/// is left for the leading dot and `.lock`.
const MAX_NAME_BYTES: usize = 200;
fn lock_path(project: &Path) -> PathBuf {
let name = project.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
project.with_file_name(format!(".{}.lock", &name[..name.floor_char_boundary(MAX_NAME_BYTES)]))
}
/// Do two paths name one existing file?
fn same_file(a: &str, b: &str) -> bool {
a == b || matches!((std::fs::canonicalize(a), std::fs::canonicalize(b)), (Ok(a), Ok(b)) if a == b)
}
/// The path this run saves over, when that is the file it opens. `--save-as` somewhere else replaces
/// that file whatever it held, so there is no edit of its to lose.
///
/// The lock goes by this path as written, not by where a symlink points: runs that name the
/// project the same way share one lock.
///
/// A missing project is an error here, before anything else, the same one opening it would give.
/// If the run went on, another one could create the project before it is read, and this run would
/// edit and save it with no lock. Checking first also means the paths compared below exist, so
/// `canonicalize` tells exactly whether they are one file, however they are spelt. A project that
/// exists but isn't a file gets no lock file beside it, and opening it reports why.
fn saved_in_place(a: &Args) -> Result<Option<&Path>, String> {
// `--bridge` with a project is a usage error, which `backend` reports
let Some(project) = a.project.as_deref().filter(|_| a.opt("--bridge").is_none()) else { return Ok(None) };
match Path::new(project).metadata() {
Ok(meta) if meta.is_file() => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(format!("cannot read {project}: {e}")),
_ => return Ok(None),
}
match a.opt("--save-as") {
Some(target) if same_file(target, project) => Ok(Some(Path::new(target))),
None if a.flag("--save") => Ok(Some(Path::new(project))),
_ => Ok(None),
}
}
/// Lock `project`, calling `waiting` first if another run holds the lock.
fn lock(project: &Path, waiting: impl FnOnce()) -> std::io::Result<File> {
let file = File::options().create(true).truncate(false).read(true).write(true).open(lock_path(project))?;
match file.try_lock() {
Ok(()) => {}
Err(TryLockError::WouldBlock) => {
waiting();
file.lock()?;
}
Err(TryLockError::Error(e)) => return Err(e),
}
Ok(file)
}
/// Take the project's lock if this run saves over the file it opens, or fail if the project is
/// missing. Call it before opening the project and keep the result until the save is done. Blocks
/// while another run holds the lock, which is fine here: nothing else is running yet. A lock that
/// can't be had (a read-only folder, a file system without locks) is a warning and not an error, so
/// that saving works where it did.
pub(crate) fn for_save(a: &Args) -> Result<Option<File>, Failure> {
let Some(project) = saved_in_place(a).map_err(Failure::Error)? else { return Ok(None) };
let shown = project.display();
let waiting = || {
let _ = writeln!(std::io::stderr(), "effectcraft-cli: waiting for another run saving {shown}");
};
match lock(project, waiting) {
Ok(file) => Ok(Some(file)),
Err(e) => {
let _ = writeln!(std::io::stderr(), "effectcraft-cli: warning: can't lock {shown} ({e}); another run saving it now could overwrite these edits");
Ok(None)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn args(list: &[&str]) -> Args {
Args::parse(list.iter().map(|s| s.to_string()).collect()).unwrap()
}
#[test]
fn the_lock_sits_next_to_the_project() {
assert_eq!(lock_path(Path::new("/a/b/p.ecproj")), Path::new("/a/b/.p.ecproj.lock"));
assert_eq!(lock_path(Path::new("p.ecproj")), Path::new(".p.ecproj.lock"));
}
#[test]
fn a_long_project_name_still_gets_a_lock_name_the_file_system_takes() {
for name in ["a".repeat(255), "é".repeat(127)] {
let lock = lock_path(Path::new(&name));
let lock = lock.to_str().unwrap();
assert!(lock.len() <= 255, "{} bytes", lock.len());
assert!(lock.ends_with(".lock"));
}
}
/// The moment between this check and opening the project can't be hit on demand, so this pins
/// the decision that closes it: a run never goes on to open a project that wasn't there, however
/// its paths are spelt and whatever it saves.
#[test]
fn a_missing_project_stops_the_run_before_anything_opens_it() {
let name = format!("ec-cli-gone-{}.ecproj", std::process::id());
let gone = std::env::temp_dir().join(&name);
let gone = gone.to_str().unwrap();
let alias = std::env::temp_dir().join(".").join(&name);
let alias = alias.to_str().unwrap();
let dotted = format!("./{name}");
// in a folder that doesn't exist either, so no part of either spelling can be resolved
let new_dir = std::env::temp_dir().join(format!("ec-cli-new-dir-{}", std::process::id()));
let in_new_dir = new_dir.join("p.ecproj");
let in_new_dir = in_new_dir.to_str().unwrap();
let in_new_dir_alias = new_dir.join(".").join("p.ecproj");
let in_new_dir_alias = in_new_dir_alias.to_str().unwrap();
for (project, list) in [
(gone, &["exec", "--project", gone, "--save"][..]),
(gone, &["exec", gone, "--save"]),
(gone, &["exec", "--project", gone, "--save-as", gone]),
(gone, &["exec", "--project", gone, "--save-as", alias]),
(name.as_str(), &["exec", "--project", &name, "--save-as", &dotted]),
(in_new_dir, &["exec", "--project", in_new_dir, "--save-as", in_new_dir_alias]),
(gone, &["exec", "--project", gone, "--save-as", "other.ecproj"]),
(gone, &["exec", "--project", gone]),
] {
let error = saved_in_place(&args(list)).unwrap_err();
assert_eq!(error.split_once(": ").map(|(head, _)| head), Some(format!("cannot read {project}").as_str()), "{list:?}: {error}");
}
// a project with --bridge is a usage error, which `backend` reports; no project, no lock
assert_eq!(saved_in_place(&args(&["exec", "--bridge", "9877", "--project", gone, "--save"])), Ok(None));
assert_eq!(saved_in_place(&args(&["exec", "--demo", "--save-as", gone])), Ok(None));
}
}
+199 -1
View File
@@ -110,6 +110,197 @@ fn exec_set_get_with_saved_project() {
assert_eq!(v[1]["result"]["time"], v[0]["result"]["time"], "{v}");
}
/// #2928: the text listing gives an enum's label next to its number (0-based, unlike After
/// Effects scripting), the way `props --json` lists its `options`.
#[test]
fn props_lists_enum_labels() {
let proj = tmp("enum-labels.ecproj");
let p = proj.to_str().unwrap();
let anim = r#"{"layer":"t","properties":["opacity"]}"#;
ok_json(&[
"run",
"comp.new",
r#"{"name":"T","width":64,"height":36}"#,
"layer.newText",
r#"{"text":"AB","name":"t"}"#,
"layer.addTextAnimator",
anim,
"--empty",
"--save-as",
p,
]);
ok_json(&["set", "T", "t", "text/animators/animator/selectors/rangeSelector/advanced/shape", "1", p, "--save"]);
let out = bin().args(["props", "T", "t", p]).output().unwrap();
let text = String::from_utf8_lossy(&out.stdout);
let shape = text.lines().find(|l| l.contains("advanced/shape")).unwrap_or_else(|| panic!("no shape line in:\n{text}"));
assert!(shape.trim_end().ends_with("enum 1 (Ramp Up)"), "{shape}");
let units = text.lines().find(|l| l.contains("advanced/units")).unwrap_or_default();
assert!(units.trim_end().ends_with("0 (Percentage)"), "{units}");
let size = text.lines().find(|l| l.contains("transform/opacity")).unwrap_or_default();
assert!(size.trim_end().ends_with("100.0"), "a number without options stays bare: {size}");
}
/// A project in a new folder of its own, for the save lock tests (#2851).
fn lock_test_project(tag: &str) -> (PathBuf, String) {
let dir = tmp(&format!("lock-{tag}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let proj = dir.join("p.ecproj").to_str().unwrap().to_string();
ok_json(&["exec", "project.newFolder", r#"{"name":"start"}"#, "--empty", "--save-as", &proj]);
(dir, proj)
}
/// The lock of the project file `name`, held by this process.
fn hold_lock(dir: &std::path::Path, name: &str) -> std::fs::File {
let lock = std::fs::File::options().create(true).truncate(false).read(true).write(true).open(dir.join(format!(".{name}.lock"))).unwrap();
lock.lock().unwrap();
lock
}
/// Start a run that has to wait for a lock this process holds; returns once it says it is waiting.
fn start_waiting_run(args: &[&str]) -> std::process::Child {
let mut child = bin().args(args).stdout(Stdio::null()).stderr(Stdio::piped()).spawn().unwrap();
let mut stderr = BufReader::new(child.stderr.take().unwrap());
let mut line = String::new();
while !line.contains("waiting for another run saving") {
line.clear();
assert_ne!(stderr.read_line(&mut line).unwrap(), 0, "{args:?} ended without waiting");
}
assert!(child.try_wait().unwrap().is_none(), "{args:?} did not wait");
child
}
/// Folder names in a saved project.
fn has_folder(proj: &str, name: &str) -> bool {
std::fs::read_to_string(proj).unwrap().contains(&format!("\"{name}\""))
}
#[test]
fn parallel_saves_keep_every_edit() {
let (dir, proj) = lock_test_project("parallel");
let names = |writer: usize| (0..300).map(move |i| format!("edit-{writer}-{i}"));
let mut writers: Vec<_> = (0..4)
.map(|_| {
bin()
.args(["run", "--project", &proj, "--commands", "-", "--save"])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap()
})
.collect();
// Each writer reads its whole script before it opens the project, so closing the stdins together
// starts all the edits at once, and long scripts keep each read-to-save span over the others.
for (n, writer) in writers.iter_mut().enumerate() {
let script: String = names(n).map(|name| format!("{}\n", json!({"command": "project.newFolder", "params": {"name": name}}))).collect();
writer.stdin.take().unwrap().write_all(script.as_bytes()).unwrap();
}
for mut writer in writers {
assert!(writer.wait().unwrap().success());
}
let lost: Vec<String> = (0..4).flat_map(names).filter(|name| !has_folder(&proj, name)).collect();
assert!(lost.is_empty(), "lost {} edits: {lost:?}", lost.len());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_save_in_place_waits_for_the_lock_then_edits_the_latest_project() {
let (dir, proj) = lock_test_project("wait");
for save in [&["--save"][..], &["--save-as", proj.as_str()]] {
let held = hold_lock(&dir, "p.ecproj");
let before = std::fs::read(&proj).unwrap();
let params = json!({"name": format!("waited-{}", save.len())}).to_string();
let args = [&["exec", "project.newFolder", &params, "--project", &proj][..], save].concat();
let mut child = start_waiting_run(&args);
assert_eq!(std::fs::read(&proj).unwrap(), before, "{save:?} wrote while another run held the lock");
// an edit made meanwhile by the holder must survive: the waiter reads only after the lock is free
let held_params = json!({"name": format!("held-{}", save.len())}).to_string();
ok_json(&["exec", "project.newFolder", &held_params, "--project", &proj, "--save-as", &format!("{proj}.tmp")]);
std::fs::rename(format!("{proj}.tmp"), &proj).unwrap();
drop(held);
assert!(child.wait().unwrap().success(), "{save:?}");
for want in [format!("waited-{}", save.len()), format!("held-{}", save.len())] {
assert!(has_folder(&proj, &want), "{save:?} lost {want}");
}
}
let _ = std::fs::remove_dir_all(&dir);
}
/// Runs through a symlink take the same lock before and after a save, or a run still waiting on
/// the old one could overwrite a later run's save.
#[cfg(unix)]
#[test]
fn a_symlinked_project_keeps_its_lock_after_a_save() {
let (dir, proj) = lock_test_project("symlink");
let link = dir.join("link.ecproj");
std::os::unix::fs::symlink(&proj, &link).unwrap();
for name in [r#"{"name":"first"}"#, r#"{"name":"second"}"#] {
let held = hold_lock(&dir, "link.ecproj");
let mut child = start_waiting_run(&["exec", "project.newFolder", name, "--project", link.to_str().unwrap(), "--save"]);
drop(held);
assert!(child.wait().unwrap().success(), "{name}");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn runs_that_do_not_save_in_place_ignore_the_lock() {
let (dir, proj) = lock_test_project("ignore");
let other = dir.join("other.ecproj").to_str().unwrap().to_string();
let _held = hold_lock(&dir, "p.ecproj");
let params = r#"{"name":"copy-only"}"#;
let cases: [&[&str]; 3] = [
&["info", "--project", &proj],
&["exec", "project.newFolder", params, "--project", &proj],
&["exec", "project.newFolder", params, "--project", &proj, "--save-as", &other],
];
for args in cases {
let mut child = bin().args(args).stdout(Stdio::null()).stderr(Stdio::null()).spawn().unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
let status = loop {
if let Some(status) = child.try_wait().unwrap() {
break status;
}
if std::time::Instant::now() > deadline {
let _ = child.kill();
panic!("{args:?} is waiting for a lock it doesn't need");
}
std::thread::sleep(std::time::Duration::from_millis(10));
};
assert!(status.success(), "{args:?}");
}
assert!(!has_folder(&proj, "copy-only"));
assert!(has_folder(&other, "copy-only"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_missing_project_gets_no_lock_file_and_keeps_its_error() {
let (dir, _) = lock_test_project("missing");
let gone = dir.join("gone.ecproj").to_str().unwrap().to_string();
let other = dir.join("other.ecproj").to_str().unwrap().to_string();
// what opening it says, after the `effectcraft-cli <subcommand>: ` prefix
let error = |args: &[&str]| {
let out = bin().args(args).output().unwrap();
assert_eq!(out.status.code(), Some(1), "{args:?}");
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
stderr.split_once(": ").map(|(_, e)| e.to_string()).unwrap_or(stderr)
};
let plain = error(&["info", "--project", &gone]);
assert!(plain.contains("gone.ecproj"), "{plain}");
// saving in place, saving elsewhere, or not saving: the error is the one opening gives
for save in [&["--save"][..], &["--save-as", gone.as_str()], &["--save-as", other.as_str()], &[]] {
let args = [&["exec", "project.newFolder", r#"{"name":"copy-only"}"#, "--project", &gone][..], save].concat();
assert_eq!(error(&args), plain, "{save:?}");
}
assert!(!dir.join(".gone.ecproj.lock").exists());
assert!(!dir.join("other.ecproj").exists());
// a project with --bridge stays a usage error
assert_eq!(bin().args(["exec", "project.newFolder", "--bridge", "9877", "--project", &gone, "--save"]).output().unwrap().status.code(), Some(2));
let _ = std::fs::remove_dir_all(&dir);
}
/// `run --args-file` reads its command / params pairs from a file, one per line: a batch longer
/// than a Windows command line (32 767 characters, os error 206) still runs.
#[test]
@@ -136,7 +327,14 @@ fn run_reads_arguments_from_a_file() {
/// Run with `stdin` piped in; returns (exit code, stdout, stderr).
fn run_with_stdin(args: &[&str], stdin: &str) -> (i32, String, String) {
let mut child = bin().args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
child.stdin.take().unwrap().write_all(stdin.as_bytes()).unwrap();
// The child may exit before it has read all of standard input (the usage-error cases below
// exit with code 2 without draining stdin), so a broken pipe while writing is that early
// exit, not a failure of this helper; the assertions on the returned output still hold.
let mut stdin_pipe = child.stdin.take().unwrap();
if let Err(err) = stdin_pipe.write_all(stdin.as_bytes()) {
assert_eq!(err.kind(), std::io::ErrorKind::BrokenPipe, "writing the child's standard input: {err}");
}
drop(stdin_pipe);
let out = child.wait_with_output().unwrap();
(out.status.code().unwrap_or(-1), String::from_utf8_lossy(&out.stdout).into_owned(), String::from_utf8_lossy(&out.stderr).into_owned())
}
+18 -2
View File
@@ -213,8 +213,24 @@ fn install_hooks(app: &mut EffectcraftApp) {
}
None
}));
// Saving downloads the file: the chosen name is all a "save dialog" needs.
app.hooks.pick_save = Some(Box::new(|name: &str| Some(files::path_for(if name.is_empty() { "Untitled Project.ecproj" } else { name }))));
// Saving downloads the file: the chosen name is all a "save dialog" needs. A project (Save As,
// Save a Copy) asks for the name, starting from the open project's (#2788): without asking,
// Save As would replace the open project's file; Cancel cancels. A name that is already in
// browser storage asks before replacing it, as the desktop's save dialogs do.
app.hooks.pick_save = Some(Box::new(|name: &str| {
let name = if name.is_empty() { "Untitled Project.ecproj" } else { name };
if !std::path::Path::new(name).extension().is_some_and(|e| e.eq_ignore_ascii_case("ecproj") || e.eq_ignore_ascii_case("ecprojx")) {
return Some(files::path_for(name));
}
let window = web_sys::window()?;
let typed = window.prompt_with_message_and_default("Save project as:", name).ok().flatten();
let typed = crate::store::typed_save_name(name, typed)?;
let path = files::path_for(&typed);
if files::get(&path).is_some() && !window.confirm_with_message(&format!("{typed} already exists. Replace it?")).unwrap_or(false) {
return None;
}
Some(path)
}));
app.hooks.audio_device = Some(Box::new(audio::open));
app.hooks.audio_devices = Some(Box::new(|| vec!["Browser default output".into()]));
}
+32
View File
@@ -152,6 +152,24 @@ pub fn key_path(key: &str) -> Option<&str> {
key.strip_prefix(FILES).filter(|p| p.starts_with('/'))
}
/// The project file name typed in the web's Save As prompt (#2788): `None` when it was cancelled or
/// left empty; folders are dropped (the file table is flat) and a name without the default's
/// extension gets it (`Intro copy` → `Intro copy.ecproj`, `Intro.v2` → `Intro.v2.ecproj`), so the
/// browser still opens and keeps it as a project.
pub fn typed_save_name(default: &str, typed: Option<String>) -> Option<String> {
let typed = typed?;
let name = typed.trim().rsplit(['/', '\\']).next().unwrap_or("").trim();
if name.is_empty() {
return None;
}
let ext = Path::new(default).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
if ext.is_empty() || Path::new(name).extension().is_some_and(|e| e.eq_ignore_ascii_case(&ext)) {
Some(name.to_string())
} else {
Some(format!("{name}.{ext}"))
}
}
/// FNV-1a 64. Computed when a disk-cache key first needs the file, not on every write.
fn content_hash(data: &[u8]) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
@@ -436,6 +454,20 @@ pub struct SnapshotMeta {
mod tests {
use super::*;
#[test]
fn typed_save_names() {
let t = |s: &str| Some(s.to_string());
assert_eq!(typed_save_name("Intro.ecproj", t("Intro copy")).as_deref(), Some("Intro copy.ecproj"));
assert_eq!(typed_save_name("Intro.ecproj", t(" Other.ecproj ")).as_deref(), Some("Other.ecproj"));
assert_eq!(typed_save_name("Intro.ecproj", t("a/b\\Shot.ecproj")).as_deref(), Some("Shot.ecproj"));
assert_eq!(typed_save_name("Intro.ecproj", t("Intro.v2")).as_deref(), Some("Intro.v2.ecproj"));
assert_eq!(typed_save_name("Intro.ecproj", t("Intro.ECPROJ")).as_deref(), Some("Intro.ECPROJ"));
assert_eq!(typed_save_name("Intro.ecprojx", t("Intro")).as_deref(), Some("Intro.ecprojx"));
assert_eq!(typed_save_name("Intro.ecproj", t(" ")), None);
assert_eq!(typed_save_name("Intro.ecproj", t("folder/")), None);
assert_eq!(typed_save_name("Intro.ecproj", None), None);
}
fn store() -> Arc<Store> {
let s = Arc::new(Store::default());
let _ = s.clock.set(|| 1_000.0);
+3
View File
@@ -27,6 +27,9 @@ cpal = { workspace = true }
log = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies]
# Native GNOME/Wayland controls without a bundled title font.
winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] }
# Settings ▸ Appearance ▸ Sync with System: the XDG desktop portal's colour scheme. Already in
# Cargo.lock (accesskit, through eframe), so it adds no new crates.
zbus = "5.19"
+4
View File
@@ -123,3 +123,7 @@ sync_github_name = true
real_name = "Arshiya Rahgozar"
display_name = "Arshiya"
sync_github_name = false
[people.JonRC]
real_name = "Jonathan Cardoso"
display_name = "Jonathan Cardoso"
+21 -4
View File
@@ -52,11 +52,28 @@ pub struct EncoderConfig {
pub keyint: u32,
/// BT.709 limited range is signalled unless this is set.
pub full_range: bool,
/// The colour description of `color_config` (ITU-T H.273 code points); BT.709 by default.
pub color_primaries: u8,
pub transfer_characteristics: u8,
pub matrix_coefficients: u8,
}
impl EncoderConfig {
pub fn new(width: u32, height: u32, fps_num: u32, fps_den: u32) -> Self {
EncoderConfig { width, height, fps_num, fps_den, bit_depth: 8, level_idx: None, rate: RateControl::ConstantQ(100), keyint: 60, full_range: false }
EncoderConfig {
width,
height,
fps_num,
fps_den,
bit_depth: 8,
level_idx: None,
rate: RateControl::ConstantQ(100),
keyint: 60,
full_range: false,
color_primaries: 1,
transfer_characteristics: 1,
matrix_coefficients: 1,
}
}
}
@@ -364,9 +381,9 @@ impl Encoder {
w.f(1, (c.bit_depth > 8) as u32); // high_bitdepth
w.f(1, 0); // mono_chrome
w.f(1, 1); // color_description_present_flag
w.f(8, 1); // color_primaries: BT.709
w.f(8, 1); // transfer_characteristics: BT.709
w.f(8, 1); // matrix_coefficients: BT.709
w.f(8, c.color_primaries as u32); // color_primaries
w.f(8, c.transfer_characteristics as u32); // transfer_characteristics
w.f(8, c.matrix_coefficients as u32); // matrix_coefficients
w.f(1, c.full_range as u32); // color_range
w.f(2, 0); // chroma_sample_position: unknown
w.f(1, 0); // separate_uv_delta_q
+2 -2
View File
@@ -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.
+27 -5
View File
@@ -254,8 +254,14 @@ pub(crate) fn preview_compose(ctx: &EffectCtx, b: &mut Buf, orig: &Image) {
});
}
/// How far the grain statistics read around a sample box: their high-pass subtracts a Gaussian
/// of sigma 1.5, three box passes of radius 1 (`noise2::grain_stats_boxes`).
const SAMPLE_REACH: usize = 3;
/// Boxes (x, y, size in buffer px) where grain is sampled: the flattest of a grid of
/// candidate boxes (real grain shows best where the picture has no detail).
/// candidate boxes (real grain shows best where the picture has no detail). A box and the
/// [`SAMPLE_REACH`] px around it are opaque: a transparent pixel there is black once
/// unpremultiplied, and the high-pass would measure that edge as grain.
pub(crate) fn sample_boxes(img: &Image, count: usize, size: usize) -> Vec<(usize, usize, usize)> {
let (w, h) = (img.width as usize, img.height as usize);
let size = size.clamp(3, w.min(h).max(3));
@@ -264,6 +270,19 @@ pub(crate) fn sample_boxes(img: &Image, count: usize, size: usize) -> Vec<(usize
}
let low = Plane::luma(img);
let step = (size / 2).max(1);
// Transparent pixels (alpha 0.5 or less) in a summed-area table: `sat[y * (w + 1) + x]`
// counts them above and left of (x, y).
let sw = w + 1;
let mut sat = vec![0usize; sw * (h + 1)];
for y in 0..h {
let mut run = 0;
for x in 0..w {
let opaque = img.data[y * w + x][3] > 0.5;
run += usize::from(!opaque);
sat[(y + 1) * sw + x + 1] = sat[y * sw + x + 1] + run;
}
}
let sat = sat.as_slice();
// Candidate rows in parallel; the sort below is a total order (variance, y, x), so the
// result does not depend on the collection order.
let rows: Vec<usize> = (0..=h - size).step_by(step).collect();
@@ -271,20 +290,23 @@ pub(crate) fn sample_boxes(img: &Image, count: usize, size: usize) -> Vec<(usize
.par_iter()
.flat_map_iter(|&y| {
let data = low.data.as_slice();
let alpha = img.data.as_slice();
(0..=w - size).step_by(step).filter_map(move |x| {
let (x0, y0) = (x.saturating_sub(SAMPLE_REACH), y.saturating_sub(SAMPLE_REACH));
let (x1, y1) = ((x + size + SAMPLE_REACH).min(w), (y + size + SAMPLE_REACH).min(h));
if sat[y1 * sw + x1] + sat[y0 * sw + x0] != sat[y0 * sw + x1] + sat[y1 * sw + x0] {
return None;
}
// Variance of the picture inside the box (the flattest boxes hold the least detail).
let (mut s, mut sq, mut n, mut opaque) = (0.0f32, 0.0f32, 0.0f32, true);
let (mut s, mut sq, mut n) = (0.0f32, 0.0f32, 0.0f32);
for yy in y..y + size {
for xx in x..x + size {
let v = data[yy * w + xx];
s += v;
sq += v * v;
n += 1.0;
opaque &= alpha[yy * w + xx][3] > 0.5;
}
}
opaque.then(|| (sq / n - (s / n).powi(2), x, y))
Some((sq / n - (s / n).powi(2), x, y))
})
})
.collect();
+54 -7
View File
@@ -190,15 +190,14 @@ fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf {
b.img = img;
return b;
}
let src_stats = grain_stats_boxes(&source, &src_boxes);
// No box with an opaque surround (a small layer inside transparent pixels) means no clean
// sample: no grain to match, as After Effects applies none when its samples hold transparent
// areas. The whole-image fallback of `grain_stats_boxes` would measure the edge again.
let clean_stats = |img: &Image, boxes: &[(usize, usize, usize)]| if boxes.is_empty() { GrainStats::default() } else { grain_stats_boxes(img, boxes) };
let src_stats = clean_stats(&source, &src_boxes);
let comp = (ctx.params.f("compensateForExistingNoise") / 100.0) as f32;
// The own-layer boxes only matter for the compensation term; skip them when it is off.
let own = if comp > 0.0 {
let own_boxes = sampling_boxes(ctx, &b.img, b.scale);
grain_stats_boxes(&b.img, &own_boxes)
} else {
GrainStats::default()
};
let own = if comp > 0.0 { clean_stats(&b.img, &sampling_boxes(ctx, &b.img, b.scale)) } else { GrainStats::default() };
let orig = (view == 0).then(|| b.img.clone());
let look = GrainLook::from_params(ctx);
let amp: [f32; 3] = [0, 1, 2].map(|k| {
@@ -464,6 +463,54 @@ mod tests {
assert_eq!(out.img.data, again.img.data);
}
/// #2919: a sample box beside transparent pixels measured the edge as grain. Its own
/// pixels are opaque, but the high-pass blur reads 3 px around it, where transparent pixels
/// are black once unpremultiplied. An earlier blur pads the layer with transparent pixels,
/// so a clean plate got blocky colour grain on the frames whose flattest boxes sat at the
/// edge, and fine grain on the others.
#[test]
fn match_grain_ignores_the_edge_of_a_transparent_border() {
// A flat colour inside the 2 px transparent frame of a padded buffer.
let framed = |w: u32, h: u32, c: [f32; 4]| {
let mut img = Image::filled(w, h, c);
for y in 0..h {
for x in 0..w {
if x < 2 || y < 2 || x >= w - 2 || y >= h - 2 {
img.set(x, y, [0.0; 4]);
}
}
}
img
};
let most_change = |a: &Image, b: &Image| a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..3).map(move |k| (p[k] - q[k]).abs())).fold(0.0f32, f32::max);
// A noiseless sky. At 16 px no 9 px box has an opaque 3 px surround, so there is no
// clean sample at all (the whole-image fallback would measure the edge).
for (w, h, clean_boxes) in [(64u32, 48u32, true), (16, 16, false)] {
let sky = framed(w, h, [0.4, 0.6, 0.9, 1.0]);
let boxes = crate::noise::sample_boxes(&sky, 80, 9);
assert_eq!(!boxes.is_empty(), clean_boxes, "{w}x{h}: {boxes:?}");
if clean_boxes {
let st = grain_stats_boxes(&sky, &boxes);
assert!(st.std.iter().all(|s| *s < 1e-5), "{w}x{h}: {st:?} from {boxes:?}");
}
// So Match Grain finds no grain to add.
let host = FakeHost(sky);
let env = EffectEnv { host: Some(&host), ..Default::default() };
let target = Image::filled(w, h, [0.2, 0.3, 0.5, 1.0]);
let out = run_fx("ec.noise.matchgrain", &[("noiseSourceLayer", Value::Layer(Some(7)))], target.clone(), 0.5, env);
let most = most_change(&out.img, &target);
assert!(most < 1e-5, "{w}x{h}: Match Grain added {most}");
}
// Compensate for Existing Noise has no clean sample of such a layer either, so its edge
// doesn't cancel the source's grain.
let host = FakeHost(noisy(16, 16, 0.1, 3));
let env = EffectEnv { host: Some(&host), ..Default::default() };
let target = framed(16, 16, [0.9, 0.9, 0.9, 1.0]);
let params = [("noiseSourceLayer", Value::Layer(Some(7))), ("compensateForExistingNoise", crate::num(100.0))];
let out = run_fx("ec.noise.matchgrain", &params, target.clone(), 0.5, env);
assert!(most_change(&out.img, &target) > 0.01, "the layer's own edge cancelled the grain");
}
#[test]
fn median_legacy_radius_zero_identity_and_removes_speck() {
let mut img = Image::filled(9, 9, [0.2, 0.2, 0.2, 1.0]);
+22 -7
View File
@@ -57,7 +57,7 @@ pub fn parse_spi1d(text: &str) -> Option<Lut> {
}
}
// A 1D table is interpolated by index, so cap it like the lattices (2²⁴ entries).
if !(2..=1 << 24).contains(&len)
if !(2..=MAX_1D).contains(&len)
|| !matches!(comps, 1 | 3)
|| values.len() != len.checked_mul(comps)?
|| from[1] <= from[0]
@@ -79,7 +79,7 @@ pub fn parse_spi3d(text: &str) -> Option<Lut> {
lines.next()?;
let size: Vec<usize> = lines.next()?.split_whitespace().map(|t| t.parse().ok()).collect::<Option<_>>()?;
let n = *size.first()?;
if size.len() != 3 || size.iter().any(|s| *s != n) || !(2..=129).contains(&n) {
if size.len() != 3 || size.iter().any(|s| *s != n) || !(2..=MAX_LATTICE).contains(&n) {
return None;
}
let mut d3 = vec![[0.0f32; 3]; n * n * n];
@@ -154,6 +154,11 @@ pub fn parse_3dl(text: &str) -> Option<Lut> {
Some(Lut { n1: 0, d1: vec![], n3: n, d3, dmin: [0.0; 3], dmax: [1.0; 3] })
}
/// Largest lattice side the lattice readers accept (`.spi3d`, `.csp`).
const MAX_LATTICE: usize = 129;
/// Most entries of a 1D table (interpolated by index, so capped like the lattices: 2²⁴).
const MAX_1D: usize = 1 << 24;
/// Parse a cineSpace `.csp` LUT (1D or 3D, with its three pre-LUT shapers).
pub fn parse_csp(text: &str) -> Option<(Lut, [Vec<(f32, f32)>; 3])> {
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
@@ -185,8 +190,15 @@ pub fn parse_csp(text: &str) -> Option<(Lut, [Vec<(f32, f32)>; 3])> {
if sz.len() != 3 || sz[0] != sz[1] || sz[1] != sz[2] {
return None;
}
let n = sz[0] as usize;
let mut d3 = Vec::with_capacity(n * n * n);
// The size comes from the file: an integer lattice size the other lattice readers accept
// (2..=129), checked before anything is allocated. Rows are read before they are stored,
// so a short file never reserves the table it claims.
let n = sz[0];
if n.fract() != 0.0 || !(2.0..=MAX_LATTICE as f32).contains(&n) {
return None;
}
let n = n as usize;
let mut d3 = Vec::new();
for _ in 0..n * n * n {
let v = nums(lines.next()?)?;
if v.len() != 3 {
@@ -194,10 +206,13 @@ pub fn parse_csp(text: &str) -> Option<(Lut, [Vec<(f32, f32)>; 3])> {
}
d3.push([v[0], v[1], v[2]]);
}
(n >= 2).then_some((Lut { n1: 0, d1: vec![], n3: n, d3, dmin: [0.0; 3], dmax: [1.0; 3] }, shaper))
Some((Lut { n1: 0, d1: vec![], n3: n, d3, dmin: [0.0; 3], dmax: [1.0; 3] }, shaper))
} else {
let n: usize = lines.next()?.parse().ok()?;
let mut d1 = Vec::with_capacity(n);
if !(2..=MAX_1D).contains(&n) {
return None;
}
let mut d1 = Vec::new();
for _ in 0..n {
let v = nums(lines.next()?)?;
if v.len() != 3 {
@@ -205,7 +220,7 @@ pub fn parse_csp(text: &str) -> Option<(Lut, [Vec<(f32, f32)>; 3])> {
}
d1.push([v[0], v[1], v[2]]);
}
(n >= 2).then_some((Lut { n1: n, d1, n3: 0, d3: vec![], dmin: [0.0; 3], dmax: [1.0; 3] }, shaper))
Some((Lut { n1: n, d1, n3: 0, d3: vec![], dmin: [0.0; 3], dmax: [1.0; 3] }, shaper))
}
}
+8
View File
@@ -179,6 +179,14 @@ fn lut_files_parse_and_interpolate() {
assert!((o[0] - 0.25).abs() < 1e-6 && (o[1] - 0.4).abs() < 1e-6, "{o:?}");
let inv = x.apply(o, 2, true, "");
assert!((inv[0] - 0.5).abs() < 1e-4, "{inv:?}");
// A .csp whose header claims a huge or fractional table is refused before anything is reserved.
let head = "CSPLUTV100\n{k}\n\n2\n0 1\n0 1\n2\n0 1\n0 1\n2\n0 1\n0 1\n\n";
for (k, size) in [("3D", "100000 100000 100000"), ("3D", "130 130 130"), ("3D", "2.5 2.5 2.5"), ("1D", "4000000000000")] {
let text = head.replace("{k}", k) + size + "\n0 0 0\n";
assert!(parse_file(&text, "csp").is_none(), "{k} {size}");
}
// A short file that claims a valid size is refused once its rows run out.
assert!(parse_file(&(head.replace("{k}", "3D") + "129 129 129\n0 0 0\n"), "csp").is_none());
// CDL collection: pick by id.
let ccc = r#"<ColorCorrectionCollection><ColorCorrection id="a"><SOPNode><Slope>2 2 2</Slope><Offset>0 0 0</Offset><Power>1 1 1</Power></SOPNode></ColorCorrection>
<ColorCorrection id='b'><SOPNode><Slope>0.5 0.5 0.5</Slope></SOPNode><SatNode><Saturation>1</Saturation></SatNode></ColorCorrection></ColorCorrectionCollection>"#;
+50 -13
View File
@@ -168,19 +168,29 @@ fn split_key(s: &str) -> Option<(String, &str)> {
None
}
/// Bracket balance of a flow fragment (outside quotes).
fn balance(s: &str) -> i32 {
let (mut sq, mut dq, mut d) = (false, false, 0i32);
for c in s.chars() {
match c {
'\'' if !dq => sq = !sq,
'"' if !sq => dq = !dq,
'{' | '[' if !sq && !dq => d = d.saturating_add(1),
'}' | ']' if !sq && !dq => d = d.saturating_sub(1),
_ => {}
/// Bracket balance of a flow fragment (outside quotes), kept as lines are appended so each
/// character is looked at once however many lines a collection spans.
#[derive(Default)]
struct Balance {
sq: bool,
dq: bool,
d: i32,
}
impl Balance {
/// Takes in the next piece of the fragment and returns the balance so far.
fn feed(&mut self, s: &str) -> i32 {
for c in s.chars() {
match c {
'\'' if !self.dq => self.sq = !self.sq,
'"' if !self.sq => self.dq = !self.dq,
'{' | '[' if !self.sq && !self.dq => self.d = self.d.saturating_add(1),
'}' | ']' if !self.sq && !self.dq => self.d = self.d.saturating_sub(1),
_ => {}
}
}
self.d
}
d
}
struct Flow<'a> {
@@ -223,12 +233,19 @@ impl Flow<'_> {
self.i += 1;
break;
}
// A `]` cannot close a mapping: the text is malformed, so the rest is
// skipped (as past the depth limit) instead of read again and again.
Some(b']') => {
self.i = self.s.len();
break;
}
Some(b',') => {
self.i += 1;
continue;
}
_ => {}
}
let start = self.i;
let k = self.scalar(true);
self.ws();
if self.peek() == Some(b':') {
@@ -236,6 +253,11 @@ impl Flow<'_> {
}
let v = self.nested();
m.push((unquote(&k), v));
// Every entry consumes text; one that did not would repeat forever.
if self.i == start {
self.i = self.s.len();
break;
}
}
Yaml::Map(m)
}
@@ -250,11 +272,23 @@ impl Flow<'_> {
self.i += 1;
break;
}
// A `}` cannot close a sequence: skip the malformed rest (see above).
Some(b'}') => {
self.i = self.s.len();
break;
}
Some(b',') => {
self.i += 1;
continue;
}
_ => v.push(self.nested()),
_ => {
let start = self.i;
v.push(self.nested());
if self.i == start {
self.i = self.s.len();
break;
}
}
}
}
Yaml::Seq(v)
@@ -364,11 +398,14 @@ impl Lines {
if rest.starts_with('{') || rest.starts_with('[') || (rest.starts_with('!') && (rest.contains('{') || rest.contains('['))) {
// Flow collections may continue on the next lines.
let mut text = rest.to_string();
while balance(&text) > 0
let mut bal = Balance::default();
let mut open = bal.feed(&text);
while open > 0
&& let Some((_, next)) = self.v.get(*i)
{
text.push(' ');
text.push_str(next);
open = bal.feed(next);
*i += 1;
}
return flow(&text, depth);
+18
View File
@@ -94,6 +94,24 @@ fn yaml_nesting_is_bounded() {
assert!(Config::parse(&flow, Path::new(".")).is_err());
}
/// A closing bracket of the wrong kind ends the collection instead of being read forever, and a
/// flow collection spread over many lines is assembled in linear time.
#[test]
fn yaml_malformed_flow_is_bounded() {
for text in ["a: {x: ]\nb: 1\n", "a: [x, }\nb: 1\n", "a: [{k: ], 1]\n", "a: {[}: ]}\n"] {
let _ = parse_yaml(text);
}
assert!(matches!(parse_yaml("a: {x: ]\n"), Yaml::Map(_)));
let mut many = String::from("a: [\n");
for _ in 0..200_000 {
many.push_str(" 1,\n");
}
many.push_str("]\nb: 2\n");
let y = parse_yaml(&many);
assert_eq!(y.get("a").map(|a| a.seq().len()), Some(200_000));
assert_eq!(y.get("b").and_then(Yaml::str), Some("2"));
}
#[test]
fn parses_spaces_roles_displays_and_converts() {
let dir = temp_dir("basic");
+392 -26
View File
@@ -9,7 +9,8 @@
//! neighbouring frames with [`crate::EffectHost::self_at`]). Undo, redo and reopening a project
//! therefore never see stale mattes. Behind the memory cache an optional [`SegStore`] (the
//! persistent disk cache, set by the engine) keeps computed segmentations across restarts. **Freeze** stores the final mattes in the hidden **Frozen**
//! parameter ([`FrozenCache`]); they are used while their key still matches.
//! parameter ([`FrozenCache`]); they are used while their key still matches, whichever model the
//! session has (a session without the model that froze them uses them as they are).
//!
//! Rendering: binary segmentation → Roto Brush Matte (Shift Edge, Reduce Chatter, Feather,
//! Contrast) → Refine Edge matting inside the Refine Edge band (guided filter + closed-form
@@ -242,6 +243,11 @@ fn seed_with(params: &Params, layer_size: [f64; 2], fps: f64, model: &str) -> u6
h
}
/// Show Object: 0 shows every object, N object N alone.
fn show_object_of(params: &Params) -> u32 {
f(params, "rotoBrushMatte/object").round().clamp(0.0, 32.0) as u32
}
/// The layer frame shown at layer time `t` seconds.
pub fn frame_of(t: f64, fps: f64) -> i64 {
(t * fps + 1e-6).floor() as i64
@@ -415,7 +421,7 @@ impl Chain {
let data = data(params);
let keys = data.chain_keys(seed(params, layer_size, fps));
let saved = saved_for(params, &data, layer_size, fps);
let show_object = f(params, "rotoBrushMatte/object").round().clamp(0.0, 32.0) as u32;
let show_object = show_object_of(params);
Chain { refine_radius: data.refine_radius(), data, keys, opts: seg_opts(params, scale), model: model_for(params), saved, show_object }
}
@@ -543,25 +549,74 @@ impl Chain {
}
None
};
self.carry(f, None, frame, cancel, &lookup, &mut |g, seg| {
let k = *self.keys.get(&g)?;
store(k, sc, seg.clone());
if let Some(st) = seg_store() {
st.put_seg(k, sc, seg);
}
Some(())
})
}
/// [`Chain::ensure`]'s walk: the matte carried to `f` from the nearest frame towards the base that `lookup` has,
/// else from the root cut afresh from its strokes. `object: None` carries every stroke from the base frame (the shown
/// matte); `Some(o)` carries only `o`'s, from its foreground-stroked frame nearest the base on the way. `keep`
/// gets every frame made on the way.
fn carry(
&self,
f: i64,
object: Option<u32>,
frame: &mut dyn FnMut(i64) -> Option<Arc<Image>>,
cancel: &dyn Fn() -> bool,
lookup: &dyn Fn(i64) -> Option<Arc<FrameSeg>>,
keep: &mut dyn FnMut(i64, &Arc<FrameSeg>) -> Option<()>,
) -> Option<Arc<FrameSeg>> {
let strokes = |g: i64| match object {
Some(o) => self.data.strokes_of(g, o),
None => self.data.strokes_at(g),
};
if let Some(s) = lookup(f) {
return Some(s);
}
let base = self.data.base?;
let root = match object {
Some(o) => {
// one pass over the strokes, not one per frame on the way
let on_the_way = |g: i64| (g == base || g.cmp(&base) == f.cmp(&base)) && g.abs_diff(base) <= f.abs_diff(base);
let fg = self.data.strokes.iter().filter(|s| s.object == o && s.kind == StrokeKind::Fg && on_the_way(s.frame));
fg.map(|s| s.frame).min_by_key(|g| g.abs_diff(base)).unwrap_or(f)
}
None => base,
};
if !self.data.in_span(f) {
return None;
}
// Walk towards the base to the nearest available frame.
// A new first stroke can still have a valid tracked matte nearer the base. Look for it
// without reading those frames; without one, start afresh at the object's first stroke.
let mut path = vec![f];
let mut untracked_len = 1;
let mut g = f;
let mut start: Option<Arc<FrameSeg>> = None;
while let Some(src) = self.data.source_of(g) {
while g != base
&& let Some(src) = self.data.source_of(g)
{
if cancel() {
return None;
}
if let Some(s) = lookup(src) {
start = Some(s);
break;
}
path.push(src);
if src == root {
untracked_len = path.len();
}
g = src;
}
if start.is_none() {
path.truncate(untracked_len);
}
path.reverse();
let mut prev_img: Option<Arc<Image>> = None;
let mut cur = start;
@@ -570,8 +625,8 @@ impl Chain {
return None;
}
let img = frame(g)?;
let strokes = self.data.strokes_at(g);
let seg = match (&cur, g == base) {
let strokes = strokes(g);
let seg = match (&cur, object.is_none() && g == root) {
(_, true) | (None, _) => rb::segment_with(&img, &strokes, None, &self.opts, self.refine_radius, self.model.as_deref()),
(Some(p), false) => {
let src = self.data.source_of(g)?;
@@ -587,11 +642,7 @@ impl Chain {
}
};
let seg = Arc::new(seg);
let k = *self.keys.get(&g)?;
store(k, sc, seg.clone());
if let Some(st) = seg_store() {
st.put_seg(k, sc, &seg);
}
keep(g, &seg)?;
cur = Some(seg);
prev_img = Some(img);
}
@@ -674,7 +725,7 @@ impl Chain {
max_pending_bytes: usize,
) -> Result<Option<TrackSummary>, String> {
let Some(tracker) = self.model.as_ref().and_then(|m| m.tracker()) else { return Ok(None) };
let (Some(_), true) = (self.data.base, self.data.span[1] >= self.data.span[0]) else { return Err("no base frame".into()) };
let (Some(base), true) = (self.data.base, self.data.span[1] >= self.data.span[0]) else { return Err("no base frame".into()) };
// Frames of the span that already have a tracked result (this session's, or the saved
// tracking) are kept: only the rest is tracked. A correction stroke changes the keys of the
// frames from it on (both directions), so only those come back here.
@@ -686,26 +737,48 @@ impl Chain {
let (Some(&need_lo), Some(&need_hi)) = (needed.first(), needed.last()) else {
return Ok(Some(TrackSummary::default()));
};
// Each object has its own prompts: a few clicks from its strokes and a box around what the classic engine
// makes of them on that frame (clicks alone pick a part of the object, not all of it).
// Each object has its own prompts: a few clicks from its strokes and a box saying how far it reaches (clicks
// alone pick a part of the object). SAM 2 fills the box it is given, so the box surrounds the object's matte
// on that frame: the one shown for a lone object; with several, each object's own, carried with its own strokes
// as the shown one is (from its first prompted frame, or the nearest tracked or prompted frame), so a correction stroke on
// part of an object still boxes all of it. Those per-object mattes are not cached: their keys mean tracked results.
let objects = self.data.objects();
let several = objects.len() > 1;
let mut by_object: Vec<Vec<(i64, effectcraft_segment::Prompt)>> = vec![];
for &obj in &objects {
let mut prompts = vec![];
for f in self.data.stroke_frames().into_iter().filter(|f| self.data.in_span(*f)) {
let mut carried: Option<(i64, Arc<FrameSeg>)> = None;
// from the base outwards on each side, so each carry goes on from the one before
let mut frames: Vec<i64> = self.data.stroke_frames().into_iter().filter(|f| self.data.in_span(*f)).collect();
frames.sort_by_key(|f| (*f < base, f.abs_diff(base)));
for f in frames {
let strokes = self.data.strokes_of(f, obj);
if !strokes.iter().any(|s| s.kind == StrokeKind::Fg) {
continue;
}
let p = match frame(f) {
Some(img) => rb::tracker_prompt_boxed(&strokes, self.opts.scale, &rb::segment(&img, &strokes, None, &self.opts, self.refine_radius)),
None => return Err(format!("cannot read Roto Brush prompt frame {f}")),
let own = if several {
// a later correction frame carries on from the one before it (only the last can be on its way)
let lookup = |g| self.object_seg(g, obj).or_else(|| carried.as_ref().filter(|(k, _)| *k == g).map(|(_, s)| s.clone()));
let own = self.carry(f, Some(obj), &mut *frame, cancel, &lookup, &mut |_, _| Some(()));
carried = own.clone().map(|s| (f, s));
own
} else {
self.ensure(f, &mut *frame, cancel)
};
let matte = match own {
Some(own) => own,
None if cancel() => return Err("tracking cancelled".into()),
None => match frame(f) {
Some(img) => Arc::new(rb::segment_with(&img, &strokes, None, &self.opts, self.refine_radius, self.model.as_deref())),
None => return Err(format!("cannot read Roto Brush prompt frame {f}")),
},
};
let p = rb::tracker_prompt_boxed(&strokes, self.opts.scale, &matte);
if p.points.iter().any(|(_, fg)| *fg) {
prompts.push((f, p));
}
}
prompts.sort_by_key(|q| q.0);
by_object.push(prompts);
}
// objects without a foreground prompt cannot be followed
@@ -862,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 {
@@ -892,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()
@@ -1125,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;
@@ -1228,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);
@@ -1254,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::*;
@@ -1344,6 +1457,50 @@ mod tests {
assert!(o.data.iter().all(|p| p[3] == 0.0));
}
/// Mattes frozen with a model render as frozen in a session that has another model or none (a fresh headless
/// render), instead of being segmented again with the classic engine.
#[test]
fn frozen_mattes_survive_a_session_without_their_model() {
let mut d = RotoData::default();
d.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 3.0, points: vec![[35.0, 40.0], [45.0, 40.0]], object: 0 }, 2, [0, 5]);
let mut params = params_with(&d);
params.values.insert(INPUT_KEY.into(), Value::Str("frozen-without-the-model".into()));
let size = [100.0, 80.0];
assert!(model_for(&params).is_none(), "this session has no model");
// the frozen matte is the left half of the layer: no segmentation of the disk gives it
let alpha: Vec<f32> = (0..100 * 80).map(|i| f32::from(u8::from(i % 100 < 50))).collect();
let mut fc = FrozenCache { key: frozen_key_with(&params, size, 25.0, effectcraft_segment::SAM2_TINY.id), size: [100, 80], ..Default::default() };
fc.frames.insert(0, freeze_frame(&alpha, &[0; 100 * 80]));
assert_ne!(fc.key, frozen_key(&params, size, 25.0));
params.values.insert(FROZEN.into(), Value::Str(fc.to_json()));
let host = Seq;
let ctx = EffectCtx {
params: &params,
time: 0.001,
layer_size: size,
seed: 1,
adjustment: false,
env: EffectEnv { host: Some(&host), frame_rate: 25.0, ..Default::default() },
};
let out = crate::apply(&specs()[0], &ctx, Buf { img: frame(0.0), offset: [0.0; 2], scale: 1.0 }).img;
let a = |x: usize, y: usize| out.data[y * 100 + x][3];
assert!(a(10, 70) > 0.99 && a(90, 10) < 0.01, "the frozen matte is used: {} at (10, 70), {} at (90, 10)", a(10, 70), a(90, 10));
// freezing again in this session keeps the frozen matte instead of segmenting with the classic engine
let mut get = |g: i64| Some(Arc::new(frame(g as f64 / 25.0)));
let kept = applying_frozen(&params, size, 25.0).expect("the frozen mattes apply");
let (matte, band) = frame_matte(&params, size, 25.0, 0, Some(&kept), &mut get, &|| false).expect("frame 0's matte");
assert_eq!((matte, band.len()), (alpha.clone(), 100 * 80), "a second Freeze keeps the frozen frame");
let segmented = frame_matte(&params, size, 25.0, 1, Some(&kept), &mut get, &|| false);
assert!(segmented.is_some_and(|(a, _)| a[40 * 100 + 10] < 0.01), "a frame with none is segmented");
// a stroke edited after the freeze still makes it stale, whatever the model
let mut moved = d.clone();
moved.strokes[0].points[0] = [36.0, 40.0];
let mut edited = params.clone();
edited.values.insert(STROKES.into(), Value::Str(moved.to_json()));
assert!(!frozen_applies(&fc, &edited, size, 25.0));
assert!(applying_frozen(&edited, size, 25.0).is_none(), "a second Freeze then segments every frame");
}
/// A stand-in tracker for two objects: object 0 is the moving disk, object 1 a fixed square. It records how many
/// objects (and prompts per object) it was asked for.
struct TwoObjects(std::sync::Mutex<Vec<Vec<usize>>>);
@@ -1470,6 +1627,215 @@ mod tests {
}
}
/// A model that sees only the left part of the disk, and records the tracker's box prompts.
#[derive(Default)]
struct LeftPart(std::sync::Mutex<Vec<(i64, Option<[f32; 4]>)>>);
impl effectcraft_segment::MaskModel for LeftPart {
fn info(&self) -> &'static effectcraft_segment::ModelInfo {
&effectcraft_segment::SAM2_TINY
}
fn segment(&self, _: &[[f32; 3]], w: usize, h: usize, _: &effectcraft_segment::Prompt) -> effectcraft_segment::Result<Vec<f32>> {
Ok((0..w * h).map(|i| f32::from((26..36).contains(&(i % w)) && (30..50).contains(&(i / w)))).collect())
}
fn tracker(&self) -> Option<&dyn effectcraft_segment::ObjectTracker> {
Some(self)
}
}
impl effectcraft_segment::ObjectTracker for LeftPart {
fn track(
&self,
req: &effectcraft_segment::TrackRequest,
_: &mut dyn FnMut(i64) -> Option<Arc<ClipFrame>>,
_: &dyn Fn() -> bool,
_: &mut dyn FnMut(usize, i64, effectcraft_segment::TrackedFrame),
) -> effectcraft_segment::Result<()> {
*self.0.lock().unwrap() = req.objects.iter().flatten().map(|(f, p)| (*f, p.bbox)).collect();
Ok(())
}
}
#[test]
fn the_tracker_box_surrounds_the_matte_shown_on_each_stroked_frame() {
let mut data = RotoData::default();
data.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 3.0, points: vec![[30.0, 40.0]], object: 0 }, 3, [0, 3]);
data.add(Stroke { kind: StrokeKind::Fg, frame: 2, radius: 3.0, points: vec![[50.0, 40.0]], object: 0 }, 3, [0, 3]);
data.span = [0, 3];
let mut params = params_with(&data);
params.values.insert(INPUT_KEY.into(), Value::Str("tracker-box-from-shown-matte".into()));
let mut chain = Chain::new(&params, [100.0, 80.0], 25.0, 1.0);
let fake = Arc::new(LeftPart::default());
chain.model = Some(fake.clone());
chain.keys = data.chain_keys(seed_with(&params, [100.0, 80.0], 25.0, effectcraft_segment::SAM2_TINY.id));
let mut get = |f: i64| Some(Arc::new(frame(f as f64 / 25.0)));
let expected: Vec<(i64, Option<[f32; 4]>)> = [0, 2]
.into_iter()
.map(|f| (f, rb::tracker_prompt_boxed(&data.strokes_of(f, 0), 1.0, &chain.ensure(f, &mut get, &|| false).expect("a shown matte")).bbox))
.collect();
assert!(expected.iter().all(|(_, b)| b.is_some()));
assert!(expected[0].1.unwrap()[2] < 44.0, "the base frame shows the model's part of the disk: {expected:?}");
chain.track(&mut get, &|| false, &mut |_| {}).unwrap();
assert_eq!(*fake.0.lock().unwrap(), expected, "the base frame and the correction frame are boxed around what they show");
}
/// A model that cuts only a small square around each foreground click, and records every object's box prompts.
#[derive(Default)]
struct ClickSquares(std::sync::Mutex<Vec<Vec<(i64, Option<[f32; 4]>)>>>);
impl effectcraft_segment::MaskModel for ClickSquares {
fn info(&self) -> &'static effectcraft_segment::ModelInfo {
&effectcraft_segment::SAM2_TINY
}
fn segment(&self, _: &[[f32; 3]], w: usize, h: usize, p: &effectcraft_segment::Prompt) -> effectcraft_segment::Result<Vec<f32>> {
let near = |x: f32, y: f32| p.points.iter().any(|(c, fg)| *fg && (c[0] - x).abs() <= 4.0 && (c[1] - y).abs() <= 4.0);
Ok((0..w * h).map(|i| f32::from(near((i % w) as f32 + 0.5, (i / w) as f32 + 0.5))).collect())
}
fn tracker(&self) -> Option<&dyn effectcraft_segment::ObjectTracker> {
Some(self)
}
}
impl effectcraft_segment::ObjectTracker for ClickSquares {
fn track(
&self,
req: &effectcraft_segment::TrackRequest,
_: &mut dyn FnMut(i64) -> Option<Arc<ClipFrame>>,
_: &dyn Fn() -> bool,
_: &mut dyn FnMut(usize, i64, effectcraft_segment::TrackedFrame),
) -> effectcraft_segment::Result<()> {
*self.0.lock().unwrap() = req.objects.iter().map(|o| o.iter().map(|(f, p)| (*f, p.bbox)).collect()).collect();
Ok(())
}
}
#[test]
fn a_correction_stroke_on_part_of_one_of_several_objects_boxes_the_whole_object() {
let mut data = RotoData::default();
data.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 3.0, points: vec![[30.0, 40.0], [50.0, 40.0]], object: 0 }, 3, [0, 3]);
data.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 3.0, points: vec![[88.0, 72.0]], object: 1 }, 3, [0, 3]);
data.add(Stroke { kind: StrokeKind::Fg, frame: 2, radius: 2.0, points: vec![[54.0, 40.0]], object: 0 }, 3, [0, 3]);
data.span = [0, 3];
let mut params = params_with(&data);
params.values.insert(INPUT_KEY.into(), Value::Str("object-correction-box".into()));
let mut chain = Chain::new(&params, [100.0, 80.0], 25.0, 1.0);
let fake = Arc::new(ClickSquares::default());
chain.model = Some(fake.clone());
chain.keys = data.chain_keys(seed_with(&params, [100.0, 80.0], 25.0, effectcraft_segment::SAM2_TINY.id));
chain.track(&mut |f| Some(Arc::new(frame(f as f64 / 25.0))), &|| false, &mut |_| {}).unwrap();
let boxes = fake.0.lock().unwrap().clone();
let box_of = |o: usize, f: i64| boxes[o].iter().find(|(g, _)| *g == f).and_then(|(_, b)| *b).expect("a box prompt");
let (base, correction, other) = (box_of(0, 0), box_of(0, 2), box_of(1, 0));
assert!(correction[0] <= base[0] + 6.0, "the correction frame boxes the object carried from frame 0 {base:?}, not only the stroke: {correction:?}");
assert!(correction[2] >= 54.0 && correction[2] - correction[0] >= base[2] - base[0], "the box holds the whole object and the stroke: {correction:?}");
assert!(correction[2] < other[0], "object 1 {other:?} stays out of object 0's box {correction:?}");
}
#[test]
fn a_correction_after_propagate_carries_each_object_on_from_its_tracked_matte() {
let mut data = RotoData::default();
data.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 3.0, points: vec![[30.0, 40.0]], object: 0 }, 3, [0, 3]);
data.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 3.0, points: vec![[15.0, 15.0]], object: 1 }, 3, [0, 3]);
data.span = [0, 3];
let mut params = params_with(&data);
params.values.insert(INPUT_KEY.into(), Value::Str("object-correction-after-propagate".into()));
let mut chain = Chain::new(&params, [100.0, 80.0], 25.0, 1.0);
chain.model = Some(Arc::new(TwoObjects(Default::default())));
chain.keys = data.chain_keys(seed_with(&params, [100.0, 80.0], 25.0, effectcraft_segment::SAM2_TINY.id));
assert_eq!(chain.track(&mut |f| Some(Arc::new(frame(f as f64 / 25.0))), &|| false, &mut |_| {}).unwrap().unwrap().frames, 4);
data.add(Stroke { kind: StrokeKind::Fg, frame: 2, radius: 2.0, points: vec![[56.0, 40.0]], object: 0 }, 3, [0, 3]);
chain.data = Arc::new(data.clone());
chain.keys = data.chain_keys(seed_with(&params, [100.0, 80.0], 25.0, effectcraft_segment::SAM2_TINY.id));
let fake = Arc::new(ClickSquares::default());
chain.model = Some(fake.clone());
let mut prompt_reads = vec![];
let mut get = |f: i64| {
if fake.0.lock().unwrap().is_empty() {
prompt_reads.push(f);
}
Some(Arc::new(frame(f as f64 / 25.0)))
};
chain.track(&mut get, &|| false, &mut |_| {}).unwrap();
assert_eq!(prompt_reads, [2, 1], "frame 2 carries on from the tracked frame 1; tracked frame 0 boxes both objects as it is");
let boxes = fake.0.lock().unwrap().clone();
let correction = boxes[0].iter().find(|(g, _)| *g == 2).and_then(|(_, b)| *b).expect("a box prompt");
// the tracked disk carried to frame 2 spans x 29..59
assert!(correction[0] <= 29.0 && correction[2] >= 59.0, "the box holds the whole disk: {correction:?}");
}
#[test]
fn a_correction_before_an_objects_first_stroke_keeps_its_tracked_matte() {
for base in [0, 4] {
let first = if base == 0 { 3 } else { 1 };
let prior = if base == 0 { 1 } else { 3 };
let mut data = RotoData::default();
data.add(Stroke { kind: StrokeKind::Fg, frame: base, radius: 3.0, points: vec![[40.0, 40.0]], object: 0 }, 4, [0, 4]);
data.add(Stroke { kind: StrokeKind::Fg, frame: first, radius: 3.0, points: vec![[15.0, 15.0]], object: 1 }, 4, [0, 4]);
data.span = [0, 4];
let mut params = params_with(&data);
params.values.insert(INPUT_KEY.into(), Value::Str(format!("earlier-object-correction-{base}")));
let mut chain = Chain::new(&params, [100.0, 80.0], 25.0, 1.0);
chain.model = Some(Arc::new(TwoObjects(Default::default())));
let seed = seed_with(&params, [100.0, 80.0], 25.0, effectcraft_segment::SAM2_TINY.id);
chain.keys = data.chain_keys(seed);
let image = |f: i64| {
let mut img = frame(f as f64 / 25.0);
// Match the second object's tracked square, so the carry has a real image edge.
for y in 8..22 {
for x in 8..22 {
img.set(x, y, [0.95, 0.9, 0.1, 1.0]);
}
}
Arc::new(img)
};
assert_eq!(chain.track(&mut |f| Some(image(f)), &|| false, &mut |_| {}).unwrap().unwrap().frames, 5);
let prior_key = chain.keys[&prior];
assert!(chain.object_seg(prior, 1).is_some());
data.add(Stroke { kind: StrokeKind::Fg, frame: 2, radius: 1.0, points: vec![[19.0, 15.0]], object: 1 }, 4, [0, 4]);
chain.data = Arc::new(data.clone());
chain.keys = data.chain_keys(seed);
assert_eq!(chain.keys[&prior], prior_key, "the nearer tracked frame is still valid");
let fake = Arc::new(ClickSquares::default());
chain.model = Some(fake.clone());
let mut prompt_reads = vec![];
let mut get = |f: i64| {
if fake.0.lock().unwrap().is_empty() {
prompt_reads.push(f);
}
Some(image(f))
};
chain.track(&mut get, &|| false, &mut |_| {}).unwrap();
assert_eq!(prompt_reads, [2, prior, first, 2], "corrections carry from the valid tracked matte in either direction");
let boxes = fake.0.lock().unwrap().clone();
let correction = boxes[1].iter().find(|(g, _)| *g == 2).and_then(|(_, b)| *b).expect("a correction box");
assert!(correction[0] <= 10.0 && correction[2] >= 22.0, "the box keeps the whole tracked square, not only the correction: {correction:?}");
assert!(correction[2] < 30.0, "the other object's disk stays outside this box: {correction:?}");
}
}
#[test]
fn corrections_before_the_base_carry_on_from_each_other() {
let mut data = RotoData::default();
for (frame, object) in [(3, 0), (3, 1), (2, 0), (1, 0)] {
data.add(Stroke { kind: StrokeKind::Fg, frame, radius: 3.0, points: vec![[35.0, 40.0]], object }, 3, [0, 3]);
}
data.span = [0, 3];
assert_eq!(data.base, Some(3));
let mut params = params_with(&data);
params.values.insert(INPUT_KEY.into(), Value::Str("corrections-before-the-base".into()));
let mut chain = Chain::new(&params, [100.0, 80.0], 25.0, 1.0);
let fake = Arc::new(ClickSquares::default());
chain.model = Some(fake.clone());
chain.keys = data.chain_keys(seed_with(&params, [100.0, 80.0], 25.0, effectcraft_segment::SAM2_TINY.id));
let mut prompt_reads = vec![];
let mut get = |f: i64| {
if fake.0.lock().unwrap().is_empty() {
prompt_reads.push(f);
}
Some(Arc::new(frame(f as f64 / 25.0)))
};
chain.track(&mut get, &|| false, &mut |_| {}).unwrap();
assert_eq!(prompt_reads, [3, 2, 3, 1, 2, 3], "object 0: the base, then 2 from 3, then 1 from 2; object 1: the base");
let frames: Vec<Vec<i64>> = fake.0.lock().unwrap().iter().map(|o| o.iter().map(|q| q.0).collect()).collect();
assert_eq!(frames, [vec![1, 2, 3], vec![3]], "prompts reach the tracker in frame order");
}
#[test]
fn prompt_segmentation_reads_only_frames_with_that_objects_foreground() {
let mut data = RotoData::default();
+36 -1
View File
@@ -457,7 +457,11 @@ fn motion_tile(ctx: &EffectCtx, b: Buf) -> Buf {
fy = 1.0 - fy;
}
}
*px = src.sample_bilinear(fx * lw * s + b.offset[0], fy * lh * s + b.offset[1]);
// Clamped: a tile edge lands exactly on a pixel centre whenever the boundary does
// (#2820's animated Tile Center walks it across the grid), and the plain sampler
// would mix in the transparent outside at the source's geometric edge, leaving a
// half-faded seam pixel along the fold.
*px = src.sample_bilinear_clamped(fx * lw * s + b.offset[0], fy * lh * s + b.offset[1]);
}
});
Buf { img: out, offset: noff, scale: s }
@@ -808,6 +812,37 @@ mod tests {
}
}
#[test]
fn motion_tile_mirror_folds_stay_opaque() {
// A fold that lands exactly on a pixel centre samples the source's geometric edge, where
// the plain bilinear tap reaches half a texel into the transparent outside — a faint
// seam line on the mirrored boundary that sweeps the tile while Tile Center animates
// (#2820). The fold must repeat the edge texel instead: opaque, and exactly the pixel
// it mirrors.
let img = ramp(10, 8);
let out = run_at(
"ec.stylize.motiontile",
&[("tileCenter", Value::Vec2([5.0, 4.0])), ("tileWidth", num(50.0)), ("mirrorEdges", Value::Bool(true))],
img.clone(),
0.0,
);
assert_eq!((out.img.width, out.img.height), (10, 8));
for (x, mirrored) in [(2i64, 0i64), (7, 9)] {
for y in 0..i64::from(out.img.height) {
let a = out.img.get(x, y);
assert!((a[3] - 1.0).abs() < 1e-5, "fold x={x},y={y} faded to alpha {}", a[3]);
let b = img.get(mirrored, y);
for c in 0..4 {
assert!((a[c] - b[c]).abs() < 1e-5, "fold x={x},y={y} channel {c}");
}
}
}
// And no pixel anywhere in the tile faded either.
for px in out.img.data.iter() {
assert!((px[3] - 1.0).abs() < 1e-5, "tile pixel faded to alpha {}", px[3]);
}
}
#[test]
fn strobe_by_time() {
assert!(strobe_on(0.02, 0.05, 1.0, 0.0, 1));
+44 -22
View File
@@ -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);
}
});
+151 -3
View File
@@ -451,11 +451,76 @@ fn time_displacement(ctx: &EffectCtx, b: Buf) -> Buf {
// ---------------------------------------------------------------- Timewarp
/// Source layer time Timewarp shows at layer time `t`.
/// Source layer time Timewarp shows at layer time `t` when Speed is the same at every time:
/// Source Frame maps directly, Speed (a rate in %) gives `t × speed / 100`. An animated Speed
/// is integrated instead (see [`timewarp_integrate_speed`]).
pub fn timewarp_source_time(t: f64, by_frame: bool, speed: f64, source_frame: f64, fps: f64) -> f64 {
if by_frame { source_frame / fps } else { t * speed / 100.0 }
}
/// Speed samples per composition frame when integrating an animated Speed.
const TW_SPEED_SAMPLES_PER_FRAME: f64 = 2.0;
/// Most Speed samples one integration takes (longer spans widen the step instead).
const TW_SPEED_MAX_SAMPLES: f64 = 50_000.0;
/// Source layer time of Adjust Time By = Speed at layer time `t` with an animated Speed: Speed
/// is a rate (100 % = real time), so the source time is the integral of `speed / 100` over layer
/// time from 0 to `t` (toward 0 with the sign for negative `t`). `speed_at(lt)` is Speed at layer
/// time `lt`; a missing or non-finite sample counts as `now` (the current Speed), or 0 when that
/// is not finite either. Midpoint rule on a fixed grid from 0, [`TW_SPEED_SAMPLES_PER_FRAME`]
/// samples per frame at `fps`, at most [`TW_SPEED_MAX_SAMPLES`] samples. A non-finite result falls back to
/// `t × now / 100`, then to 0.
pub fn timewarp_integrate_speed(t: f64, fps: f64, now: f64, speed_at: impl Fn(f64) -> Option<f64>) -> f64 {
let flat = t * now / 100.0;
let fallback = |v: f64| {
if v.is_finite() {
v
} else if flat.is_finite() {
flat
} else {
0.0
}
};
if !t.is_finite() || t == 0.0 {
return fallback(flat);
}
let fps = if fps.is_finite() && fps > 0.0 { fps } else { 30.0 };
let span = t.abs();
// Cells of a fixed step anchored at 0 (whole cells plus a partial last one), so successive
// frames share their samples and a Hold key's jump lands the same way on every frame; only a
// span past the sample budget widens the step.
let mut h = 1.0 / (fps * TW_SPEED_SAMPLES_PER_FRAME);
if !(span / h).is_finite() || span / h >= TW_SPEED_MAX_SAMPLES {
h = span / TW_SPEED_MAX_SAMPLES;
}
if !h.is_finite() || h <= 0.0 {
return fallback(flat);
}
let whole = (span / h).floor().clamp(0.0, TW_SPEED_MAX_SAMPLES);
let rest = (span - whole * h).max(0.0);
let sign = t.signum();
let held = if now.is_finite() { now } else { 0.0 };
let sample = |lt: f64| speed_at(sign * lt).filter(|v| v.is_finite()).unwrap_or(held);
// `whole` is a whole number in 0..=TW_SPEED_MAX_SAMPLES, so the count is exact.
let full: f64 = (0..whole as u64).map(|i| sample((i as f64 + 0.5) * h)).sum::<f64>() * h;
let part = if rest > 0.0 { sample(whole * h + rest * 0.5) * rest } else { 0.0 };
fallback(sign * (full + part) / 100.0)
}
/// Source layer time Timewarp shows at the frame: [`timewarp_source_time`], except that an
/// animated Speed (keyframes or an expression, read through the effect host) is integrated
/// from layer time 0 ([`timewarp_integrate_speed`]), so a speed ramp plays on from where the
/// footage is instead of jumping to another segment.
fn timewarp_source(ctx: &EffectCtx, by_frame: bool, speed: f64, fps: f64) -> f64 {
let direct = timewarp_source_time(ctx.time, by_frame, speed, ctx.params.f("sourceFrame"), fps);
if by_frame {
return direct;
}
let idx = ctx.env.effect_index;
let Some(host) = ctx.env.host.filter(|h| !h.param_is_static(idx, "speed")) else { return direct };
timewarp_integrate_speed(ctx.time, fps, speed, |lt| host.param_at(idx, "speed", lt).map(|v| v.as_f64()))
}
/// Timewarp's Pixel Motion settings (the Tuning group and Source Crops).
#[derive(Clone, Copy, Debug)]
pub struct PixelMotion {
@@ -747,7 +812,7 @@ fn timewarp_instants(ctx: &EffectCtx) -> (Vec<(f64, f32)>, Vec<f64>, f64) {
let fps = ctx.fps();
let by_frame = ctx.params.e("adjustTimeBy") == 1;
let speed = ctx.params.f("speed");
let src = timewarp_source_time(ctx.time, by_frame, speed, ctx.params.f("sourceFrame"), fps);
let src = timewarp_source(ctx, by_frame, speed, fps);
let mut instants: Vec<(f64, f32)> = vec![];
if ctx.params.b("motionBlur/enableMotionBlur") {
let angle = if ctx.params.e("motionBlur/shutterControl") == 1 { ctx.params.f("motionBlur/shutterAngle") } else { 180.0 };
@@ -879,7 +944,8 @@ pub fn timewarp_plan(ctx: &EffectCtx) -> Option<TimewarpPlan> {
/// vectors (block-matching optical flow, [`effectcraft_raster::flow`]) tuned by the Tuning
/// group. A Matte Layer splits foreground and background so each moves on its own vectors; a
/// Warp Layer supplies the motion instead of the layer itself. Motion Blur averages Shutter
/// Samples instants across the shutter.
/// Samples instants across the shutter. Adjust Time By = Speed treats Speed as a rate
/// integrated from layer time 0 (keyframing it ramps the playback speed).
fn timewarp(ctx: &EffectCtx, b: Buf) -> Buf {
match timewarp_plan(ctx) {
Some(plan) => plan.render_cpu(),
@@ -1188,6 +1254,88 @@ mod tests {
assert!(out.img.data[0][0] > 1.0);
}
/// [`Clock`] whose Timewarp (effect 2) Speed is animated: `speed(lt)` at layer time `lt`.
struct Ramp {
speed: fn(f64) -> f64,
}
impl EffectHost for Ramp {
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn self_at(&self, t: f64, _: usize) -> Option<Buf> {
let v = t as f32;
Some(Buf { img: Image::filled(6, 4, [v, v, v, 1.0]), offset: [0.0; 2], scale: 1.0 })
}
/// Timewarp's (effect 2) Speed follows `speed`; every other parameter is unknown.
fn param_at(&self, effect_index: usize, id: &str, lt: f64) -> Option<Value> {
(effect_index == 2 && id == "speed").then(|| num((self.speed)(lt)))
}
/// Only Timewarp's (effect 2) Speed is animated.
fn param_is_static(&self, effect_index: usize, id: &str) -> bool {
!(effect_index == 2 && id == "speed")
}
}
fn run_ramp(vals: &[(&str, Value)], t: f64, host: &Ramp) -> Buf {
let env = EffectEnv { host: Some(host), frame_rate: 10.0, comp_time: t, effect_index: 2, ..Default::default() };
run_fx("ec.time.timewarp", vals, Image::filled(6, 4, [0.9, 0.0, 0.0, 1.0]), t, env)
}
/// #2803: an animated Speed is a rate integrated from layer time 0, so a speed ramp plays on
/// from where the footage is instead of jumping to another segment.
#[test]
fn timewarp_integrates_animated_speed() {
// Speed 100 % for the first second, then 50 %: at t = 2 s the source is 1 + 0.5 = 1.5 s
// (`t × speed` would jump back to 1.0 s).
let h = Ramp { speed: |lt| if lt < 1.0 { 100.0 } else { 50.0 } };
let vals = [("method", Value::Enum(0)), ("speed", num(50.0))];
let out = run_ramp(&vals, 2.0, &h);
assert!((out.img.data[0][0] - 1.5).abs() < 1e-4, "{}", out.img.data[0][0]);
// Continuous across the change: just past 1 s the source is just past 1 s.
let out = run_ramp(&vals, 1.2, &h);
assert!((out.img.data[0][0] - 1.1).abs() < 1e-4, "{}", out.img.data[0][0]);
// Negative layer time integrates toward 0.
let src = timewarp_integrate_speed(-2.0, 10.0, 50.0, |lt| Some(if lt > -1.0 { 100.0 } else { 50.0 }));
assert!((src + 1.5).abs() < 1e-9, "{src}");
// A Hold key between samples (1.03 s) lands the same way on every frame: past it, each
// 0.1 s frame advances the source by exactly 50 % of a frame.
let hold = |lt: f64| Some(if lt < 1.03 { 100.0 } else { 50.0 });
let at = |t: f64| timewarp_integrate_speed(t, 10.0, 50.0, hold);
for k in 12..40 {
let (a, b) = (at((k - 1) as f64 / 10.0), at(k as f64 / 10.0));
assert!((b - a - 0.05).abs() < 1e-9, "k={k}: {}", b - a);
}
// A static Speed keeps `t × speed / 100` exactly.
assert_eq!(timewarp_source_time(2.0, false, 37.0, 0.0, 10.0), 2.0 * 37.0 / 100.0);
// Source Frame mode ignores Speed.
let out = run_ramp(&[("method", Value::Enum(0)), ("adjustTimeBy", Value::Enum(1)), ("sourceFrame", num(3.0))], 2.0, &h);
assert!((out.img.data[0][0] - 0.3).abs() < 1e-5);
}
/// Never crash: non-finite or huge Speeds, times and frame rates give finite source times
/// and pixels.
#[test]
fn timewarp_animated_speed_survives_bad_values() {
// Non-finite samples count as the current Speed (or 0); the result is always finite.
for t in [0.0, 1.0, -3.0, 1e9, f64::MAX, f64::INFINITY, f64::NAN] {
for now in [50.0, f64::NAN, f64::INFINITY, 1e308] {
let src = timewarp_integrate_speed(t, 10.0, now, |lt| Some(if lt < 0.5 { f64::NAN } else { 1e308 }));
assert!(src.is_finite(), "t={t} now={now}: {src}");
let src = timewarp_integrate_speed(t, f64::NAN, now, |_| None);
assert!(src.is_finite(), "t={t} now={now}: {src}");
}
}
let h = Ramp { speed: |lt| if lt < 1.0 { f64::NAN } else { f64::INFINITY } };
let out = run_ramp(&[("method", Value::Enum(2)), ("speed", num(50.0)), ("motionBlur/enableMotionBlur", Value::Bool(true))], 2.0, &h);
assert!(out.img.data.iter().all(|p| p.iter().all(|c| c.is_finite())));
let h = Ramp { speed: |_| 1e308 };
let out = run_ramp(&[("method", Value::Enum(1)), ("speed", num(1e308))], 2.0, &h);
assert!(out.img.data.iter().all(|p| p.iter().all(|c| c.is_finite())));
}
#[test]
fn time_displacement_uses_luma_as_offset() {
// Static map from a bright self frame: luminance 1.0 → +max seconds.
+75 -42
View File
@@ -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
+3 -1
View File
@@ -489,6 +489,8 @@ async fn run_work(w: Work, shared: &RotoShared) {
// With the classic engine, Propagate leaves no saved tracking behind.
*lock(&shared.saved) = Some(String::new());
}
// Freezing again keeps the frames frozen already (resolved once: it reads the whole frozen cache).
let kept = if w.task == RotoTask::Freeze && !w.object_matte { fx::applying_frozen(&w.params, w.size, w.fps) } else { None };
for (k, f) in w.frames.iter().enumerate() {
if cancel() {
let mut s = lock(&shared.state);
@@ -524,7 +526,7 @@ async fn run_work(w: Work, shared: &RotoShared) {
}
}
(RotoTask::Propagate, false) => chain.ensure(*f, &mut get, &cancel).is_some(),
(RotoTask::Freeze, false) => match fx::frame_matte(&w.params, w.size, w.fps, *f, &mut get, &cancel) {
(RotoTask::Freeze, false) => match fx::frame_matte(&w.params, w.size, w.fps, *f, kept.as_deref(), &mut get, &cancel) {
Some((a, b)) => {
if let Some(img) = src.get(*f) {
frozen.size = [img.width as usize, img.height as usize];
+2
View File
@@ -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 |_| {},
)
+8
View File
@@ -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));
}
+2 -2
View File
@@ -18,8 +18,8 @@ pub mod content_fill;
mod create;
mod edit;
mod effect;
pub use effect::CryptoProbeJob;
pub(crate) use effect::{CryptoProbeSlot, crypto_probe_job, crypto_probe_ready, keep_crypto_probe, kept_crypto_probe};
pub use effect::{CryptoCoverage, CryptoProbeJob};
pub(crate) use effect::{CryptoProbeSlot, crypto_coverage, crypto_probe_job, crypto_probe_ready, keep_crypto_probe, kept_crypto_probe};
pub mod essential;
pub mod expr_tools;
mod file;
+9 -1
View File
@@ -555,7 +555,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_keys,
exponential_scale
),
cmd!("text.addSelector", "Add Text Selector", [], None, "{layer?, animator?: uid|index, kind: range|wiggly|expression}", has_text_layer, add_selector),
cmd!(
"text.addSelector",
"Add Text Selector",
[],
None,
"{layer?, animator?: uid|index (1-based; a uid match wins over an index), kind: range|wiggly|expression}",
has_text_layer,
add_selector
),
cmd!("text.removeAllAnimators", "Remove All Text Animators", ["Animation"], None, "{layers?}", has_text_layer, remove_all_animators),
cmd!("anim.reveal", "Reveal Properties", [], None, "{kind: keyframes|animation|modified}", has_comp, reveal),
]
+33 -2
View File
@@ -64,8 +64,9 @@ fn trace_frame(img: &Image, offset: [f64; 2], ch: Channel, invert: bool, blur: f
let mut out = trace(&field, img.width as usize, img.height as usize, o);
for t in &mut out {
for v in &mut t.path.vertices {
v[0] += offset[0];
v[1] += offset[1];
// Source buffers are layer pixels + offset: subtract it to get back to layer space.
v[0] -= offset[0];
v[1] -= offset[1];
}
}
out
@@ -211,3 +212,33 @@ pub fn specs() -> Vec<CommandSpec> {
run
)]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn trace_frame_converts_image_pixels_back_to_layer_space() {
// A synthetic cropped source avoids any dependency on installed fonts.
let mut img = Image::new(32, 32);
for y in 8..24 {
for x in 8..24 {
img.set(x, y, [1.0; 4]);
}
}
let opts = TraceOpts { corner_roundness: 0.0, ..Default::default() };
for offset in [[0.0, 0.0], [2.0, 58.0], [-12.5, 7.25]] {
let outlines = trace_frame(&img, offset, Channel::Alpha, false, 0.0, &opts);
assert_eq!(outlines.len(), 1);
let vertices = &outlines[0].path.vertices;
for axis in 0..2 {
// image = layer + offset, so the known image bounds map back
// to [8 - offset, 24 - offset] in layer space.
let lo = vertices.iter().map(|v| v[axis]).fold(f64::INFINITY, f64::min);
let hi = vertices.iter().map(|v| v[axis]).fold(f64::NEG_INFINITY, f64::max);
assert!((lo - (8.0 - offset[axis])).abs() < 0.01, "{offset:?}: {lo}");
assert!((hi - (24.0 - offset[axis])).abs() < 0.01, "{offset:?}: {hi}");
}
}
}
}
+7 -2
View File
@@ -246,13 +246,18 @@ fn status(s: &mut Session, p: &Value) -> Result<Value> {
out["deleted"] = json!(settings.deleted.len());
out["lensDistortionSolved"] = json!(settings.lens_distortion);
let solve = ct::solve(&params);
out["solved"] = json!(solve.is_some());
// The solver interpolates the frames it could not solve: only a solve of every frame is
// `solved` (what `camera.exportBlender` needs); one with gaps is `partial`.
let solved_frames = solve.as_ref().map_or(0, |sv| sv.frames.iter().filter(|c| c.solved).count());
let complete = solve.as_ref().is_some_and(|sv| solved_frames == sv.frames.len());
out["solved"] = json!(complete);
out["partial"] = json!(solve.is_some() && !complete);
if let Some(sv) = &solve {
let f = sv.focal();
out["methodUsed"] = json!(sv.method_used.label());
out["averageError"] = json!(sv.average_error);
out["points"] = json!(sv.points.len());
out["solvedFrames"] = json!(sv.frames.iter().filter(|c| c.solved).count());
out["solvedFrames"] = json!(solved_frames);
out["focalLength"] = json!(f);
out["horizontalAngleOfView"] = json!(sv.hfov(f));
out["groundPlane"] = json!(sv.ground.is_some());
+38 -4
View File
@@ -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]
+33
View File
@@ -500,6 +500,39 @@ pub(crate) fn crypto_probe_job(s: &Session, p: &Value) -> Result<Option<CryptoPr
})))
}
/// [`Session::crypto_coverage`].
pub(crate) fn crypto_coverage(s: &Session, p: &Value, hash: u32, max_side: usize) -> Option<CryptoCoverage> {
let (key, ..) = probe_key(s, p, "effect.cryptomatteAt").ok()?;
let probe = cached_probe(s, key)?;
let b = probe.grid.as_ref().ok()?;
let (aw, ah) = (b.aux.width as usize, b.aux.height as usize);
if aw == 0 || ah == 0 {
return None;
}
let step = aw.max(ah).div_ceil(max_side.max(1)).max(1);
let (w, h) = (aw.div_ceil(step), ah.div_ceil(step));
let planes = b.aux.crypto_rank_planes(&b.layer);
let sample = |j: usize| {
planes.iter().filter(|(id, _)| id.get(j).is_some_and(|v| v.to_bits() == hash)).map(|(_, c)| c.get(j).map_or(0.0, |c| c.max(0.0))).sum::<f32>().min(1.0)
};
// Each sample at the centre of its step × step cell; the last cells are cut by the layer.
let at = |x: usize, y: usize| sample((y * step + step / 2).min(ah - 1) * aw + (x * step + step / 2).min(aw - 1));
let coverage = (0..w * h).map(|i| at(i % w, i / w)).collect();
let (size, uv) = ([aw as f64 / b.aux.scale, ah as f64 / b.aux.scale], [aw as f64 / (w * step) as f64, ah as f64 / (h * step) as f64]);
Some(CryptoCoverage { width: w, height: h, coverage, size, uv })
}
/// An ID's coverage over a layer, for the eyedropper's hover highlight: `width` × `height`
/// samples (row-major, 0..1). The layer, `size` effect-space pixels from its origin, shows the
/// texture from (0, 0) to `uv`.
pub struct CryptoCoverage {
pub width: usize,
pub height: usize,
pub coverage: Vec<f32>,
pub size: [f64; 2],
pub uv: [f64; 2],
}
/// What a pick finds at a point.
enum Hit {
/// `probeOnly` and the probe isn't built yet.
+18 -1
View File
@@ -201,6 +201,11 @@ pub(super) fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
Ok(Value::Null)
}
/// "Pre-comp N" with the lowest N no item already uses.
fn unused_precomp_name(proj: &effectcraft_project::Project) -> String {
(1u32..).map(|n| format!("Pre-comp {n}")).find(|n| !proj.items.values().any(|i| &i.name == n)).unwrap_or_else(|| "Pre-comp".to_string())
}
/// Layer ▸ Pre-compose. `mode: move` (default; "Move all attributes into the new composition")
/// nests the selected layers as they are; `mode: leave` ("Leave all attributes in", one footage,
/// solid or precomp layer) nests only the layer's source, sized to it, and keeps the layer's
@@ -212,7 +217,10 @@ pub(super) fn precompose(s: &mut Session, p: &Value) -> Result<Value> {
if ids.is_empty() {
return Err(bad(c, "no layers"));
}
let name = str_p(p, "name").unwrap_or("Pre-comp 1").to_string();
let name = match str_p(p, "name") {
Some(n) => n.to_string(),
None => unused_precomp_name(&s.project),
};
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let leave = match str_p(p, "mode").unwrap_or("move") {
"move" => false,
@@ -280,6 +288,15 @@ pub(super) fn precompose(s: &mut Session, p: &Value) -> Result<Value> {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let at = c.layers.iter().position(|l| ids.contains(&l.id)).unwrap_or(0);
c.layers.retain(|l| !ids.contains(&l.id));
// Layers left behind must not keep pointing at layers that moved into the new comp.
for l in &mut c.layers {
if l.parent.is_some_and(|p| ids.contains(&p)) {
l.parent = None;
}
if l.track_matte.as_ref().is_some_and(|m| ids.contains(&m.layer)) {
l.track_matte = None;
}
}
let lid = l.id;
c.layers.insert(at.min(c.layers.len()), l);
st.selected_layers = vec![lid];
+74 -24
View File
@@ -171,9 +171,29 @@ fn kind_p(p: &Value, cmd: &str) -> Result<PrimitiveKind> {
PrimitiveKind::parse(k).ok_or_else(|| bad(cmd, format!("kind: cube|sphere|plane|torus|cone|cylinder, not `{k}`")))
}
/// Parse Material Options parameters onto a layer at comp time `t`.
fn apply_material(l: &mut Layer, p: &Value, t: Tick, cmd: &str) -> Result<usize> {
let mut n = 0;
/// The Material Options parameters [`apply_material`] reads. Which ones a layer has depends on
/// its kind (see [`material_mismatch`]).
const MATERIAL_PARAMS: [&str; 14] = [
"castsShadows",
"acceptsShadows",
"acceptsLights",
"appearsInReflections",
"baseColor",
"metallic",
"roughness",
"emissive",
"lightTransmission",
"ambient",
"diffuse",
"specularIntensity",
"specularShininess",
"metal",
];
/// Parse Material Options parameters onto a layer at comp time `t`. The parameters the layer
/// had a property for.
fn apply_material(l: &mut Layer, p: &Value, t: Tick, cmd: &str) -> Result<Vec<&'static str>> {
let mut set = Vec::new();
let tri = |v: &Value| -> Option<u32> {
match v {
Value::Bool(b) => Some(*b as u32),
@@ -187,28 +207,55 @@ fn apply_material(l: &mut Layer, p: &Value, t: Tick, cmd: &str) -> Result<usize>
_ => None,
}
};
let mut put = |l: &mut Layer, k: &'static str, v: KV| {
if set_at(l, &format!("materialOptions/{k}"), t, v) {
set.push(k);
}
};
for k in ["castsShadows", "acceptsShadows"] {
if let Some(v) = p.get(k) {
let e = tri(v).ok_or_else(|| bad(cmd, format!("{k}: off|on|only")))?;
n += set_at(l, &format!("materialOptions/{k}"), t, KV::Enum(e)) as usize;
put(l, k, KV::Enum(e));
}
}
// A value of the wrong type is an error, not a parameter left out.
for k in ["acceptsLights", "appearsInReflections"] {
if let Some(v) = b_p(p, k) {
n += set_at(l, &format!("materialOptions/{k}"), t, KV::Bool(v)) as usize;
if p.get(k).is_some() {
let v = b_p(p, k).ok_or_else(|| bad(cmd, format!("{k}: true|false")))?;
put(l, k, KV::Bool(v));
}
}
for k in ["lightTransmission", "ambient", "diffuse", "specularIntensity", "specularShininess", "metal", "metallic", "roughness"] {
if let Some(v) = f_p(p, k) {
n += set_at(l, &format!("materialOptions/{k}"), t, KV::Scalar(v)) as usize;
if p.get(k).is_some() {
let v = f_p(p, k).ok_or_else(|| bad(cmd, format!("{k}: a number")))?;
put(l, k, KV::Scalar(v));
}
}
for k in ["baseColor", "emissive"] {
if let Some(c) = color_p(p, k) {
n += set_at(l, &format!("materialOptions/{k}"), t, KV::Color([c[0] as f64, c[1] as f64, c[2] as f64, 1.0])) as usize;
if p.get(k).is_some() {
let c = color_p(p, k).ok_or_else(|| bad(cmd, format!("{k}: \"#rrggbb\" or [r, g, b]")))?;
put(l, k, KV::Color([c[0] as f64, c[1] as f64, c[2] as f64, 1.0]));
}
}
Ok(n)
Ok(set)
}
/// The error for Material Options parameters `missing` that none of the layers has: what the
/// first layer has instead, and which layers the missing ones belong to.
fn material_mismatch(l: Option<&Layer>, missing: &[&str]) -> EngineError {
let names = missing.iter().map(|k| format!("`{k}`")).collect::<Vec<_>>().join(", ");
let Some(l) = l else { return bad("material.set", format!("no layer has {names}")) };
let Some(g) = l.props.sub("materialOptions") else { return bad("material.set", format!("`{}` has no Material Options, so it has no {names}", l.name)) };
let has: Vec<&str> = g.children.iter().map(|c| c.match_id()).collect();
let why = if matches!(l.source, LayerSource::Model { .. }) { ": a 3D model layer keeps its model file's materials" } else { "" };
bad(
"material.set",
format!(
"`{}` has no {names}{why}. Its Material Options: {}. baseColor, metallic, roughness and emissive are for 3D primitive layers; lightTransmission, ambient, diffuse, specularIntensity, specularShininess and metal for footage, solid, text, shape and precomp layers",
l.name,
has.join(", ")
),
)
}
fn new_primitive(s: &mut Session, p: &Value) -> Result<Value> {
@@ -273,19 +320,22 @@ fn material_set(s: &mut Session, p: &Value) -> Result<Value> {
}
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let t = super::time_p(s, p, Some(&comp));
let n = s.edit("Material Options", None, |proj, _| {
let mut n = 0;
for id in &ids {
n += apply_material(layer_mut(proj, cid, *id)?, p, t, "material.set")?;
}
Ok(n)
})?;
if n == 0 {
return Err(bad(
"material.set",
"no Material Options property matched (castsShadows, acceptsShadows, acceptsLights, baseColor, metallic, roughness, …)",
));
if !MATERIAL_PARAMS.iter().any(|k| p.get(*k).is_some()) {
return Err(bad("material.set", format!("no Material Options parameter given ({})", MATERIAL_PARAMS.join(", "))));
}
// A parameter none of the layers has fails the whole command (the edit is dropped), so
// it is never skipped without a word.
let n = s.edit("Material Options", None, |proj, _| {
let mut set = Vec::new();
for id in &ids {
set.extend(apply_material(layer_mut(proj, cid, *id)?, p, t, "material.set")?);
}
let missing: Vec<&str> = MATERIAL_PARAMS.into_iter().filter(|k| p.get(*k).is_some() && !set.contains(k)).collect();
if !missing.is_empty() {
return Err(material_mismatch(ids.first().and_then(|id| proj.comp(cid)?.layer(*id)), &missing));
}
Ok(set.len())
})?;
Ok(json!({"set": n}))
}
@@ -411,7 +461,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Material Options",
[],
None,
"{layers?, castsShadows?: off|on|only, acceptsShadows?: off|on|only, acceptsLights?, appearsInReflections?, lightTransmission?, ambient?, diffuse?, specularIntensity?, specularShininess?, metal?, baseColor?, metallic?, roughness?, emissive?, time?}",
"{layers?, time?, castsShadows?: off|on|only, acceptsShadows?: off|on|only, acceptsLights?, appearsInReflections? (model and primitive layers), baseColor?, metallic?, roughness?, emissive? (primitive layers; a model layer keeps its file's materials), lightTransmission?, ambient?, diffuse?, specularIntensity?, specularShininess?, metal? (footage, solid, text, shape and precomp layers)}; a parameter none of the layers has is an error",
has_layers,
material_set
),
+12
View File
@@ -39,6 +39,18 @@ fn deselect_keys(s: &mut Session, _: &Value) -> Result<Value> {
Ok(Value::Null)
}
impl Session {
/// The selected mask (its group, or a property inside it) of a selected layer, the first
/// one: (layer, mask group).
pub fn selected_mask(&self) -> Option<(effectcraft_project::LayerId, effectcraft_project::Uid)> {
let comp = self.active_comp()?;
self.state.selected_props.iter().find_map(|(lid, uid)| {
let masks = comp.layer(*lid)?.masks()?;
masks.groups().find(|g| g.uid == *uid || g.find_group(*uid).is_some() || g.find(*uid).is_some()).map(|g| (*lid, g.uid))
})
}
}
/// Alt+` / Alt+Shift+`: select the next (previous) mask of the layer, wrapping around; the first
/// (last) one when none is selected.
fn step_mask(s: &mut Session, p: &Value, dir: i64) -> Result<Value> {
+32 -4
View File
@@ -7,7 +7,8 @@
//!
//! Pin positions are in effect space. A new pin is attached to the mesh under it: the click is
//! mapped back through the current deformation to the rest mesh (its hidden rest position), and
//! Position / Advanced pins get a Position keyframe at the current time, as in After Effects.
//! Position / Advanced pins get a Position keyframe at the current time, as in After Effects. Off
//! every mesh a pin starts a new mesh, unless its art already has one (#2865).
use effectcraft_effects::puppet::{self, MeshOpts, PinKind};
use effectcraft_effects::{Buf, Params};
@@ -62,6 +63,32 @@ fn layer_time(s: &Session, cid: ItemId, lid: LayerId, t: Tick) -> Result<Tick> {
Ok(l.layer_time(t))
}
/// Whether a pin at `at` (effect space) goes on this mesh as `def` deforms it: on it or beside it.
fn on_mesh(mesh: &puppet::Mesh, def: &[[f64; 2]], at: [f64; 2]) -> bool {
!mesh.tris.is_empty() && (puppet::contains(mesh, def, at) || def.iter().map(|v| (v[0] - at[0]).hypot(v[1] - at[1])).fold(f64::MAX, f64::min) < 12.0)
}
/// Whether a new mesh at `at` (effect space) would repeat a mesh of `params`: the art it would
/// outline is that mesh's own, even if a deformation has carried the mesh off it (#2865).
fn repeats_mesh(buf: &Buf, params: &Params, at: [f64; 2]) -> bool {
let (w, h) = (buf.img.width as usize, buf.img.height as usize);
let alpha: Vec<f32> = buf.img.data.iter().map(|p| p[3]).collect();
puppet::parse(params).iter().any(|m| {
let mut outline = puppet::mesh::outline(&alpha, w, h, buf.scale.max(1e-9), buf.offset, at, m.opts.expansion);
// As `triangulate` winds it.
if puppet::mesh::signed_area(&outline) < 0.0 {
outline.reverse();
}
puppet::mesh_for(buf, m).outline == outline
})
}
/// Whether `puppet.addPin` at `at` (effect space) places a pin on a layer with a Puppet effect.
pub fn places_pin(s: &Session, cid: ItemId, lid: LayerId, at: [f64; 2], t: Tick) -> bool {
let Some((buf, params)) = find_puppet(s, cid, lid).and_then(|fu| puppet_eval(s, cid, lid, fu, t)) else { return true };
!repeats_mesh(&buf, &params, at) || puppet::overlay(&buf, &params).iter().any(|(_, mesh, def, _)| on_mesh(mesh, def, at))
}
fn add_pin(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "puppet.addPin";
let (cid, lid) = layer_p(s, p, cmd)?;
@@ -93,9 +120,7 @@ fn add_pin(s: &mut Session, p: &Value) -> Result<Value> {
if want_mesh.is_some_and(|w| w != *uid) || mesh.tris.is_empty() {
continue;
}
let inside = puppet::contains(mesh, def, at);
let near = def.iter().map(|v| (v[0] - at[0]).hypot(v[1] - at[1])).fold(f64::MAX, f64::min) < 12.0;
if inside || near || want_mesh.is_some() {
if on_mesh(mesh, def, at) || want_mesh.is_some() {
let rest = puppet::unmap(mesh, def, at);
// A moving pin holds its nearest mesh vertex at `position + (vertex - rest)`
// (puppet::solve). Choose the position that keeps that vertex where it is now, so
@@ -120,6 +145,9 @@ fn add_pin(s: &mut Session, p: &Value) -> Result<Value> {
{
return Err(bad(cmd, format!("no mesh {w}")));
}
if target.is_none() && repeats_mesh(&buf, &params, at) {
return Ok(json!({"pin": null, "changed": false}));
}
}
let opts = MeshOpts {
density: f_p(p, "density").unwrap_or(s.state.puppet.density),
+11 -6
View File
@@ -49,8 +49,9 @@ fn per_char_on(l: &Layer) -> bool {
l.props.prop("text/perChar3d").is_some_and(|p| p.value.as_bool())
}
/// The animator a command targets: `animator` (uid or 1-based index), else the animator holding
/// a selected property, else the last one.
/// The animator a command targets: `animator` (uid or 1-based index; a uid match wins, so an
/// animator whose uid equals an index is picked over the one at that position), else the animator
/// holding a selected property, else the last one.
fn animator_uid(s: &Session, l: &Layer, p: &Value, cmd: &str) -> Result<u64> {
let anims = l.props.group("text/animators").ok_or_else(|| bad(cmd, "not a text layer"))?;
let list: Vec<&PropGroup> = anims.groups().collect();
@@ -59,7 +60,11 @@ fn animator_uid(s: &Session, l: &Layer, p: &Value, cmd: &str) -> Result<u64> {
if let Some(g) = list.iter().find(|g| g.uid == n) {
return Ok(g.uid);
}
return list.get((n as usize).wrapping_sub(1)).map(|g| g.uid).ok_or_else(|| bad(cmd, format!("no animator {n}")));
let count = if list.len() == 1 { "1 animator".to_string() } else { format!("{} animators", list.len()) };
return list
.get((n as usize).wrapping_sub(1))
.map(|g| g.uid)
.ok_or_else(|| bad(cmd, format!("no animator {n}: `animator` is a uid or a 1-based index, and the layer has {count}")));
}
for (lid, uid) in &s.state.selected_props {
if *lid != l.id {
@@ -454,7 +459,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Property",
[],
None,
"{layer?, animator?: uid|index, property: (see layer.addTextAnimator)}",
"{layer?, animator?: uid|index (1-based; a uid match wins over an index), property: (see layer.addTextAnimator)}",
has_animator,
add_animator_prop
),
@@ -463,7 +468,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Text Selector",
[],
None,
"{layer?, animator?: uid|index, kind: range|wiggly|expression}",
"{layer?, animator?: uid|index (1-based; a uid match wins over an index), kind: range|wiggly|expression}",
has_animator,
add_selector
),
@@ -482,7 +487,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Variable Font Axes",
["Animation", "Animate Text"],
None,
"{layer?, axis?: tag|name (omit to list the font's axes), animator?: uid|index}",
"{layer?, axis?: tag|name (omit to list the font's axes), animator?: uid|index (1-based; a uid match wins over an index)}",
has_text_layer,
font_axes
),
+7
View File
@@ -36,6 +36,13 @@ impl Session {
commands::crypto_probe_job(self, p)
}
/// The coverage of Cryptomatte ID `hash` (as `effect.cryptomatteAt` reports it, parsed) over
/// the layer of params `p`, at most `max_side` samples on the long side, from the kept probe:
/// `None` until [`Session::crypto_probe_job`] has built it.
pub fn crypto_coverage(&self, p: &Value, hash: u32, max_side: usize) -> Option<commands::CryptoCoverage> {
commands::crypto_coverage(self, p, hash, max_side)
}
/// Let go of the kept Cryptomatte probe (and its aux channels), and cancel a build in
/// flight: the eyedropper was disarmed. Edits, footage changes and Open / New do it too.
pub fn drop_crypto_probe(&self) {
@@ -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() {
+123 -10
View File
@@ -9,6 +9,11 @@
//! (as After Effects asks you to "Save As" a custom set). The shortcut dispatcher and the menus
//! read the active preset through [`ShortcutTable`].
//!
//! Scopes, narrowest first: a *context* ([`CONTEXT_SCOPES`]: a state of the app such as editing
//! text; its shortcuts work only while it is active), a *panel* (`Project`, `Timeline`,
//! `RenderQueue`…: the panel with the keyboard) and the application. One chord can be bound once
//! per scope; the narrowest active scope runs it ([`scope_rank`]).
//!
//! Shortcut text is `Ctrl+Cmd+Alt+Shift+Key` (`Cmd` is ⌘ on macOS and Ctrl elsewhere).
use std::collections::{BTreeMap, HashMap};
@@ -29,6 +34,97 @@ pub struct UiCommand {
pub id: String,
pub label: String,
pub shortcut: Option<String>,
/// Its scope; none: from its id (`timeline.*` → Timeline, `view.*` → Composition).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
}
/// Scopes that are a state of the app rather than a panel: editing text in the viewer, a paint
/// tool active, a mask selected, a camera tool active.
pub const CONTEXT_SCOPES: &[&str] = &["TextEditing", "Paint", "Mask", "Camera"];
/// Engine commands and menu entries whose shortcuts belong to one panel or context (binding key
/// → scope). Every other one is application-wide; frontend commands state their own.
const SCOPES: &[(&str, &str)] = &[
("file.rememberInterpretation", "Project"),
("file.applyInterpretation", "Project"),
("project.deleteWithoutConfirmation", "Project"),
("renderQueue.duplicate", "RenderQueue"),
];
/// Scope of an engine command or menu entry from its binding key.
pub fn scope_of(key: &str) -> &'static str {
SCOPES.iter().find(|(k, _)| *k == key).map_or(APP_SCOPE, |(_, s)| s)
}
pub fn is_context(scope: &str) -> bool {
CONTEXT_SCOPES.contains(&scope)
}
/// The Timeline and the Composition panel show the same composition and share their shortcuts:
/// the layer keys work with either one focused.
pub fn panels_share(a: &str, b: &str) -> bool {
matches!((a, b), ("Timeline", "Composition") | ("Composition", "Timeline"))
}
/// A scope as shown to people (`RenderQueue` → `Render Queue`).
pub fn scope_label(scope: &str) -> String {
let mut out = String::with_capacity(scope.len() + 2);
for (i, c) in scope.chars().enumerate() {
if i > 0 && c.is_uppercase() {
out.push(' ');
}
out.push(c);
}
out
}
/// How strongly a binding in `scope` claims a chord while the panel `focused` has the keyboard
/// and `contexts` are active: lower wins, `None` doesn't run at all. An active context, then the
/// focused panel, then the panel sharing its shortcuts, then the application. A shortcut of
/// another panel still runs when nothing nearer claims the chord (the reveal keys work from the
/// Effect Controls panel too); a context's shortcuts never run outside it. Two other panels (or
/// two active contexts) can claim a chord at the same rank: [`pick`] runs neither.
pub fn scope_rank(scope: &str, focused: &str, contexts: &[&str]) -> Option<u8> {
if is_context(scope) {
return contexts.contains(&scope).then_some(0);
}
Some(if scope == focused {
1
} else if panels_share(scope, focused) {
2
} else if scope == APP_SCOPE {
3
} else {
4
})
}
/// Which of the bindings matching one key press runs: `candidates` are (scope, physical,
/// binding), in the dispatcher's order. The narrowest scope ([`scope_rank`]) wins; at the same
/// rank an explicit physical-key binding wins, then the first one. `Err`: the best candidates
/// belong to different scopes of the same rank (two other panels, or two active contexts), so
/// none of them runs; it lists those scopes.
pub fn pick<'a, T>(candidates: impl IntoIterator<Item = (&'a str, bool, T)>, focused: &str, contexts: &[&str]) -> Result<Option<T>, Vec<String>> {
let mut best: Option<((u8, bool), &str, T)> = None;
let mut tied: Vec<String> = vec![];
for (scope, physical, b) in candidates {
let Some(rank) = scope_rank(scope, focused, contexts) else { continue };
let at = (rank, !physical);
match &best {
Some((r, s, _)) if *r < at || (*r == at && *s == scope) => {}
Some((r, _, _)) if *r == at => {
if !tied.iter().any(|t| t == scope) {
tied.push(scope.to_string());
}
}
_ => {
tied = vec![scope.to_string()];
best = Some((at, scope, b));
}
}
}
if tied.len() > 1 { Err(tied) } else { Ok(best.map(|(.., b)| b)) }
}
/// One thing a shortcut can run.
@@ -95,12 +191,12 @@ pub fn bindables(ui: &[UiCommand]) -> Vec<Bindable> {
e.shortcut.as_deref().into_iter().chain(e.alt_shortcuts.iter().map(String::as_str)).chain(alt.iter().copied()).filter_map(normalize).collect();
push(
Bindable {
scope: scope_of(&key).into(),
key,
command: e.command.clone(),
params: e.params.clone(),
label: e.label.clone(),
path: path.clone(),
scope: APP_SCOPE.into(),
defaults,
},
&mut out,
@@ -133,22 +229,22 @@ pub fn bindables(ui: &[UiCommand]) -> Vec<Bindable> {
params: Value::Null,
label: c.label.to_string(),
path: vec!["Commands".into()],
scope: APP_SCOPE.into(),
scope: scope_of(c.id).into(),
defaults,
},
&mut out,
);
}
for c in ui {
let scope = ui_scope(&c.id);
let cat = if scope == APP_SCOPE { "Tools & Panels" } else { scope };
let scope = c.scope.as_deref().unwrap_or_else(|| ui_scope(&c.id));
let cat = if scope == APP_SCOPE { "Tools & Panels".to_string() } else { scope_label(scope) };
push(
Bindable {
key: c.id.clone(),
command: c.id.clone(),
params: Value::Null,
label: c.label.clone(),
path: vec![cat.into()],
path: vec![cat],
scope: scope.into(),
defaults: c.shortcut.as_deref().and_then(normalize).into_iter().collect(),
},
@@ -290,10 +386,16 @@ pub struct Conflict {
pub key: String,
pub label: String,
pub scope: String,
/// The two are in different scopes, so both keep working: the narrower one runs while its
/// panel or context is active and the other one everywhere else. `false`: the same scope,
/// where only one of them can run.
pub overrides: bool,
}
/// Whether shortcuts of these scopes can be active at once. Two different panels can't, except
/// the pair sharing theirs; a context can be active in any panel.
fn scopes_overlap(a: &str, b: &str) -> bool {
a == b || a == APP_SCOPE || b == APP_SCOPE
a == b || a == APP_SCOPE || b == APP_SCOPE || panels_share(a, b) || is_context(a) || is_context(b)
}
impl Keymaps {
@@ -492,11 +594,19 @@ impl ShortcutTable {
self.bindings()
.into_iter()
.filter(|(k, o)| *k == keys && o.key != b.key && scopes_overlap(&o.scope, &b.scope))
.map(|(k, o)| Conflict { keys: k.to_string(), key: o.key.clone(), label: o.label.clone(), scope: o.scope.clone() })
.map(|(k, o)| Conflict {
keys: k.to_string(),
key: o.key.clone(),
label: o.label.clone(),
scope: o.scope.clone(),
overrides: o.scope != b.scope && !(is_context(&o.scope) && is_context(&b.scope)),
})
.collect()
}
/// Every pair of commands sharing keys in an overlapping scope.
/// Every pair of commands sharing keys in the same scope, or in two contexts (which can be
/// active at once): only one of the two can run, or neither ([`pick`]). (Other pairs in
/// different scopes are intended, see [`Conflict::overrides`].)
pub fn all_conflicts(&self) -> Vec<(String, Conflict)> {
let mut by_keys: BTreeMap<&str, Vec<&Bindable>> = BTreeMap::new();
for (k, b) in self.bindings() {
@@ -508,8 +618,11 @@ impl ShortcutTable {
for (k, bs) in by_keys {
for (i, a) in bs.iter().enumerate() {
for b in &bs[i + 1..] {
if scopes_overlap(&a.scope, &b.scope) {
out.push((a.key.clone(), Conflict { keys: k.to_string(), key: b.key.clone(), label: b.label.clone(), scope: b.scope.clone() }));
if a.scope == b.scope || (is_context(&a.scope) && is_context(&b.scope)) {
out.push((
a.key.clone(),
Conflict { keys: k.to_string(), key: b.key.clone(), label: b.label.clone(), scope: b.scope.clone(), overrides: false },
));
}
}
}
+17
View File
@@ -238,6 +238,23 @@ fn blender_export_rejects_malformed_solves_without_panicking() {
}
}
/// A solve with unsolved (interpolated) frames is partial, not solved, so the status agrees with
/// the export, which refuses it.
#[test]
fn solve_status_reports_unsolved_frames_as_partial() {
let (mut s, lid, uid) = fixture(false);
let st = s.execute("camera.solveStatus", json!({"layer": lid.0})).unwrap();
assert_eq!((&st["solved"], &st["partial"], &st["solvedFrames"]), (&json!(true), &json!(false), &json!(3)), "{st}");
let mut solve =
ct::solve(&crate::camera_track::static_params(s.active_comp().unwrap().layer(lid).unwrap().props.find_group(uid).unwrap())).unwrap().as_ref().clone();
solve.frames[1].solved = false;
s.execute("prop.set", json!({"layer": lid.0, "path": "effects/#1/solve", "value": solve.to_json()})).unwrap();
s.camera_pending.clear();
let st = s.execute("camera.solveStatus", json!({"layer": lid.0})).unwrap();
assert_eq!((&st["solved"], &st["partial"], &st["solvedFrames"]), (&json!(false), &json!(true), &json!(2)), "{st}");
assert!(s.execute_checked("camera.exportBlender", json!({"layer": lid.0, "path": path("partial.py")})).is_err());
}
#[test]
fn blender_export_respects_script_write_permission() {
let (mut s, lid, _) = fixture(false);
+103 -1
View File
@@ -431,7 +431,13 @@ fn hover_latency() {
format!("{:.3}", t0.elapsed().as_secs_f64() * 1e3)
})
.collect();
eprintln!("HOVER {name}: first {} ms, then {} ms", ms[0], ms[1..].join(" / "));
// The highlight of the hovered ID (built once per ID, at most 1024 samples wide).
let q = json!({"effect": "Cryptomatte", "x": 100.0, "y": 500.0});
let hash = u32::from_str_radix(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["hash"].as_str().unwrap(), 16).unwrap();
let t0 = std::time::Instant::now();
let c = s.crypto_coverage(&q, hash, 1024).unwrap();
let cov = t0.elapsed().as_secs_f64() * 1e3;
eprintln!("HOVER {name}: first {} ms, then {} ms; highlight {}x{} {cov:.2} ms", ms[0], ms[1..].join(" / "), c.width, c.height);
}
}
@@ -794,3 +800,99 @@ fn undoing_picks_keeps_the_probe_and_the_step_says_pick() {
s.execute_checked("edit.undo", json!({})).unwrap();
assert!(!s.crypto_probe_ready(&q).unwrap(), "an undone opacity change needs a new probe");
}
/// The hover highlight: an ID's coverage from the kept probe, one sample at the centre of each
/// cell of aux pixels, over the layer's extent (the last cells cut by it).
#[test]
fn coverage_of_an_id_from_the_kept_probe() {
let s = session();
let q = json!({"effect": 1, "x": 0.5, "y": 1.5});
let left = crypto_hash("left");
assert!(s.crypto_coverage(&q, left, 64).is_none(), "no coverage before the probe");
s.crypto_probe_job(&q).unwrap().unwrap()();
let c = s.crypto_coverage(&q, left, 64).unwrap();
assert_eq!((c.width, c.height, c.size, c.uv), (16, 8, [8.0, 4.0], [1.0, 1.0]), "the aux pixels over the layer");
assert!(c.coverage.iter().all(|v| (0.0..=1.0).contains(v)) && c.coverage.iter().any(|v| *v > 0.5) && c.coverage.contains(&0.0), "{:?}", c.coverage);
assert_eq!(c.coverage[0], 0.6, "left's coverage at the first aux pixel");
assert!(s.crypto_coverage(&q, crypto_hash("nobody"), 64).unwrap().coverage.iter().all(|v| *v == 0.0));
assert_eq!(s.crypto_coverage(&q, left, 2).map(|c| (c.width, c.height)), Some((2, 1)));
// An odd size: 2 × 2 cells, the last ones half outside the layer; samples at cell centres.
let mut s = cells_session(1919, 1079, false);
let q = json!({"effect": "Cryptomatte", "x": 40.0, "y": 10.0});
assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["name"], "c1_0");
let c = s.crypto_coverage(&q, crypto_hash("c1_0"), 1024).unwrap();
assert_eq!((c.width, c.height, c.size), (960, 540, [1919.0, 1079.0]));
assert_eq!(c.uv, [1919.0 / 1920.0, 1079.0 / 1080.0]);
// Sample 18 covers pixels 36-37: its centre (37) is in c1_0, its corner (36) in c0_0.
assert_eq!((c.coverage[17], c.coverage[18], c.coverage[37]), (0.0, 1.0, 0.0));
}
struct Ranked(u32, u32, std::sync::OnceLock<Arc<AuxChannels>>);
impl FootageSource for Ranked {
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
Some(Arc::new(Image::new(f.width, f.height)))
}
fn aux(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<AuxChannels>> {
Some(
self.2
.get_or_init(|| {
let (w, h) = (self.0 as usize, self.1 as usize);
let mut aux = AuxChannels::new(w as u32, h as u32, 1.0);
for part in 0..3 {
for (k, c) in ["R", "G", "B", "A"].iter().enumerate() {
let rank = part * 2 + k / 2;
let v: Vec<f32> = (0..w * h)
.map(|i| if k % 2 == 0 { crypto_float(crypto_hash(&format!("r{rank}_{}", Cells::name(i % w, i / w)))) } else { 1.0 / 6.0 })
.collect();
aux.channels.push((format!("CryptoObject{part:02}.{c}"), v));
}
}
Arc::new(aux)
})
.clone(),
)
}
}
fn rss_mb() -> f64 {
let s = std::fs::read_to_string("/proc/self/status").unwrap_or_default();
s.lines().find(|l| l.starts_with("VmRSS")).and_then(|l| l.split_whitespace().nth(1)).and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0) / 1024.0
}
/// Highlight cost with 6 ranks (Cryptomatte's default 3 parts) at 1080p, 4K and 8K, and the memory the
/// probe pins. Run with `--ignored --nocapture`; it measures, it doesn't assert.
#[test]
#[ignore]
fn highlight_cost_with_six_ranks() {
for (w, h) in [(1920u32, 1080u32), (3840, 2160), (7680, 4320)] {
let r0 = rss_mb();
let mut s = Session { footage: Arc::new(Ranked(w, h, Default::default())), importer: Some(Arc::new(CellsProbe(w, h))), ..Default::default() };
let item = s.execute("file.import", json!({"paths": ["/renders/ranked.exr"]})).unwrap()["items"][0].clone();
s.execute("comp.new", json!({"name": "C", "width": w, "height": h, "frameRate": 24, "duration": 2})).unwrap();
s.execute("layer.addItem", json!({"item": item})).unwrap();
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
let q = json!({"effect": "Cryptomatte", "x": 100.0, "y": 500.0});
let t0 = std::time::Instant::now();
let r = s.execute("effect.cryptomatteAt", q.clone()).unwrap();
let build = t0.elapsed().as_secs_f64() * 1e3;
let hash = u32::from_str_radix(r["hash"].as_str().unwrap(), 16).unwrap();
let mut ms = vec![];
for _ in 0..5 {
let t0 = std::time::Instant::now();
let c = s.crypto_coverage(&q, hash, 1024).unwrap();
ms.push(t0.elapsed().as_secs_f64() * 1e3);
assert!(c.coverage.iter().any(|v| *v > 0.0));
}
ms.sort_by(f64::total_cmp);
let aux = s.footage.aux(ItemId(0), &Footage::default(), Tick::ZERO).unwrap();
let bytes: usize = aux.channels.iter().map(|(_, v)| v.len() * 4).sum();
eprintln!(
"HIGHLIGHT 6 ranks {w}x{h}: aux {:.0} MB ({} planes), probe build {build:.1} ms, highlight median {:.2} ms (min {:.2}), RSS +{:.0} MB",
bytes as f64 / 1e6,
aux.channels.len(),
ms[2],
ms[0],
rss_mb() - r0
);
}
}
+30
View File
@@ -2035,3 +2035,33 @@ fn pasting_a_mask_onto_its_own_layer_drops_the_source_vertex_selection() {
s.execute("edit.clear", json!({})).unwrap();
assert_eq!(layer(&s, a).masks().unwrap().groups().count(), 1, "the pasted mask goes, the source stays");
}
#[test]
fn precompose_unlinks_layers_left_behind_and_numbers_default_names() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "frameRate": 30, "duration": 4})).unwrap();
let mk = |s: &mut Session, name: &str| {
s.execute("layer.newSolid", json!({"name": name, "color": "#fff", "width": 64, "height": 64})).unwrap()["layer"].as_u64().unwrap()
};
let (a, b, c, d) = (mk(&mut s, "A"), mk(&mut s, "B"), mk(&mut s, "C"), mk(&mut s, "D"));
s.execute("layer.setParent", json!({"layers": [c], "parent": a})).unwrap();
s.execute("layer.setTrackMatte", json!({"layers": [d], "matte": b, "kind": "alpha"})).unwrap();
let cid = s.active_comp_id().unwrap();
s.execute("layer.precompose", json!({"layers": [a, b]})).unwrap();
let comp = s.project.comp(cid).unwrap();
assert_eq!(comp.layers.len(), 3);
for l in &comp.layers {
assert!(l.parent.is_none_or(|p| comp.layer(p).is_some()), "{} has a dangling parent", l.name);
assert!(l.track_matte.as_ref().is_none_or(|m| comp.layer(m.layer).is_some()), "{} has a dangling matte", l.name);
}
s.execute("layer.precompose", json!({"layers": [c, d]})).unwrap();
let mut names: Vec<String> = s
.project
.items
.values()
.filter(|i| matches!(i.kind, effectcraft_project::ItemKind::Comp(_)) && i.name.starts_with("Pre-comp"))
.map(|i| i.name.clone())
.collect();
names.sort();
assert_eq!(names, ["Pre-comp 1", "Pre-comp 2"]);
}
+37
View File
@@ -232,6 +232,43 @@ fn material_commands_and_serde() {
assert_eq!(&back, &*s.project);
}
/// #2917: a 3D model layer keeps its file's materials, so it has no Base Color, Metallic or
/// Roughness. material.set refused them with "no Material Options property matched (…,
/// baseColor, metallic, roughness, …)", naming the very parameters it refused, and next to a
/// parameter the layer does have it dropped them without a word.
#[test]
fn material_set_names_the_parameters_a_layer_lacks() {
let mut s = session();
let r = s.execute("file.import", json!({"paths": [fixture("quad.glb")]})).unwrap();
let item = r["items"][0].as_u64().unwrap();
let m = s.execute("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap();
let e = s.execute_checked("material.set", json!({"layers": [m], "baseColor": "#3366ff", "roughness": 20})).unwrap_err().to_string();
assert!(e.contains("`baseColor`, `roughness`") && e.contains("model file") && e.contains("appearsInReflections"), "{e}");
assert!(!e.contains("`metallic`"), "{e}");
// A parameter the layer has doesn't hide one it lacks, and nothing is set.
assert!(s.execute_checked("material.set", json!({"layers": [m], "castsShadows": "on", "metallic": 50})).is_err());
assert_eq!(layer(&s, m).props.prop("materialOptions/castsShadows").unwrap().value, KV::Enum(0));
s.execute_checked("material.set", json!({"layers": [m], "castsShadows": "on"})).unwrap();
assert_eq!(layer(&s, m).props.prop("materialOptions/castsShadows").unwrap().value, KV::Enum(1));
// Across layers, a parameter goes to the ones that have it.
let cube = s.execute("layer.newPrimitive", json!({"kind": "cube"})).unwrap()["layer"].as_u64().unwrap();
assert_eq!(s.execute_checked("material.set", json!({"layers": [m, cube], "roughness": 20})).unwrap()["set"], 1);
assert_eq!(layer(&s, cube).props.prop("materialOptions/roughness").unwrap().value.as_f64(), 20.0);
// A solid has the classic shading, not the primitive's material.
let solid = s.execute("layer.newSolid", json!({"width": 64, "height": 32})).unwrap()["layer"].as_u64().unwrap();
let e = s.execute_checked("material.set", json!({"layers": [solid], "emissive": "#ffffff"})).unwrap_err().to_string();
assert!(e.contains("`emissive`") && e.contains("specularShininess"), "{e}");
assert!(s.execute_checked("material.set", json!({"layers": [m]})).is_err());
// The parameter list still names every parameter, or agents' calls are refused before they run.
let spec = crate::command_specs().iter().find(|c| c.id == "material.set").unwrap();
let keys = crate::commands::accepted_params(spec.params).unwrap();
let want = "layers time castsShadows acceptsShadows acceptsLights appearsInReflections baseColor metallic roughness emissive \
lightTransmission ambient diffuse specularIntensity specularShininess metal";
for k in want.split_whitespace() {
assert!(keys.iter().any(|a| a == k), "{k}: {keys:?}");
}
}
/// #264: only Advanced 3D draws models and primitives, and new comps are Classic 3D, so an
/// imported model was invisible. Adding one switches the comp to Advanced 3D (in the same undo
/// step, with a toast), as After Effects does.
+65
View File
@@ -225,6 +225,71 @@ fn a_pin_added_to_a_bent_mesh_moves_nothing() {
}
}
fn mesh_count(s: &mut Session, id: u64) -> usize {
s.execute("puppet.info", json!({"layer": id})).unwrap()["meshes"].as_array().unwrap().len()
}
/// #2865: where the deformed layer is empty a click makes nothing: no copy of the art it has moved
/// off, no undo step, no error.
#[test]
fn a_pin_where_the_deformed_layer_is_empty_creates_nothing() {
let (mut s, id) = setup();
s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap();
s.execute("puppet.addPin", json!({"layer": id, "position": [90, 30]})).unwrap();
s.set_time(Tick::from_seconds_f64(1.0));
s.execute("puppet.movePin", json!({"layer": id, "pin": "Puppet Pin 1", "position": [10, 70]})).unwrap();
s.execute("puppet.movePin", json!({"layer": id, "pin": "Puppet Pin 2", "position": [90, 70]})).unwrap();
let (cid, t) = (s.active_comp_id().unwrap(), Tick::from_seconds_f64(1.0));
let before = s.render(cid, t, RenderOpts::default());
let revision = s.revision;
let lid = effectcraft_project::LayerId(id);
// The solid's top, which the pins have carried 40 px down; and well off it.
for at in [[50, 10], [300, 300]] {
assert!(!crate::commands::puppet::places_pin(&s, cid, lid, [at[0] as f64, at[1] as f64], t), "{at:?}");
let r = s.execute("puppet.addPin", json!({"layer": id, "position": at})).unwrap();
assert_eq!(r["pin"], Value::Null, "{at:?}");
assert_eq!(s.revision, revision, "{at:?} changed the project");
}
assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (1, 2));
assert_eq!(s.render(cid, t, RenderOpts::default()), before);
// Whatever Expansion new meshes would now get, the art is the mesh's own.
s.state.puppet.expansion = 9.0;
assert_eq!(s.execute("puppet.addPin", json!({"layer": id, "position": [50, 10]})).unwrap()["pin"], Value::Null);
// On the deformed shape a pin still goes on the mesh.
assert!(crate::commands::puppet::places_pin(&s, cid, lid, [50.0, 70.0], t));
assert!(s.execute("puppet.addPin", json!({"layer": id, "position": [50, 70]})).unwrap()["pin"].is_u64());
assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (1, 3));
}
/// #2865: separate art still gets its own mesh, also from a near miss in the gap beside it (a click
/// off the art finds the art nearest it): only a repeat of a mesh is refused.
#[test]
fn separate_art_still_gets_its_own_mesh() {
let (mut s, id) = setup();
for x in [[0, 30], [60, 100]] {
s.execute("mask.new", json!({"layer": id, "vertices": [[x[0], 0], [x[1], 0], [x[1], 60], [x[0], 60]], "closed": true})).unwrap();
}
s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap();
assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (1, 1));
// In the gap, nearer the right piece and past reach of the left piece's mesh.
s.execute("puppet.addPin", json!({"layer": id, "position": [46, 30]})).unwrap();
assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (2, 2), "the near miss meshes the right piece");
s.undo();
s.execute("puppet.addPin", json!({"layer": id, "position": [80, 30]})).unwrap();
assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (2, 2), "the right piece gets its own mesh");
s.execute("puppet.addPin", json!({"layer": id, "position": [80, 50]})).unwrap();
assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (2, 3), "and the next pin goes on it");
}
/// `newMesh` asks for a new mesh wherever the click is, the art's own mesh or not.
#[test]
fn new_mesh_option_starts_a_mesh_on_meshed_art() {
let (mut s, id) = setup();
s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap();
s.execute("puppet.addPin", json!({"layer": id, "position": [50, 30], "newMesh": true})).unwrap();
assert_eq!((mesh_count(&mut s, id), pin_count(&s, id)), (2, 2));
}
#[test]
fn puppet_pin_kinds_and_mesh_options() {
let (mut s, id) = setup();
+61 -2
View File
@@ -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#"{
@@ -597,8 +613,8 @@ fn shortcut_presets_duplicate_rename_delete_export_import() {
fn frontend_commands_are_bindable_with_panel_scope() {
let mut s = Session::default();
s.set_ui_commands(vec![
crate::shortcuts::UiCommand { id: "timeline.zoomIn".into(), label: "Zoom In Time".into(), shortcut: Some("=".into()) },
crate::shortcuts::UiCommand { id: "tool.hand".into(), label: "Hand Tool".into(), shortcut: Some("H".into()) },
crate::shortcuts::UiCommand { id: "timeline.zoomIn".into(), label: "Zoom In Time".into(), shortcut: Some("=".into()), scope: None },
crate::shortcuts::UiCommand { id: "tool.hand".into(), label: "Hand Tool".into(), shortcut: Some("H".into()), scope: None },
]);
let b = s.shortcuts().find("timeline.zoomIn").unwrap().clone();
assert_eq!(b.scope, "Timeline");
@@ -606,6 +622,49 @@ fn frontend_commands_are_bindable_with_panel_scope() {
// A panel shortcut conflicts with an application shortcut on the same keys.
let c = s.shortcuts().conflicts(&b, "H");
assert!(c.iter().any(|c| c.key == "tool.hand"));
// A registry command that only the Project panel runs is scoped to it.
assert_eq!(s.shortcuts().find("project.deleteWithoutConfirmation").unwrap().scope, "Project");
}
/// The binding that runs a key press: the narrowest scope; a physical binding at the same rank;
/// nothing when two other panels (or two active contexts) claim it at the same rank.
#[test]
fn shortcut_dispatch_picks_the_narrowest_scope_deterministically() {
use crate::shortcuts::pick;
// Two unrelated panels, neither focused: neither runs, in either order.
let both = [("Project", false, "delete"), ("RenderQueue", false, "duplicate")];
assert_eq!(pick(both, "Timeline", &[]), Err(vec!["Project".to_string(), "RenderQueue".into()]));
assert_eq!(pick([both[1], both[0]], "Timeline", &[]), Err(vec!["RenderQueue".to_string(), "Project".into()]));
// The focused one of them wins.
assert_eq!(pick(both, "RenderQueue", &[]), Ok(Some("duplicate")));
// A single other panel's shortcut runs when nothing nearer claims the chord.
assert_eq!(pick([("Project", false, "delete")], "Timeline", &[]), Ok(Some("delete")));
// An application binding beats an unrelated panel's binding, also a physical one.
assert_eq!(pick([("RenderQueue", true, "duplicate"), ("Application", false, "app")], "Timeline", &[]), Ok(Some("app")));
// At the same rank a physical binding wins, in either order.
assert_eq!(pick([("Application", false, "logical"), ("Application", true, "physical")], "Timeline", &[]), Ok(Some("physical")));
assert_eq!(pick([("Application", true, "physical"), ("Application", false, "logical")], "Timeline", &[]), Ok(Some("physical")));
// A Composition binding runs in the Composition panel over the application's.
assert_eq!(pick([("Application", false, "app"), ("Composition", false, "comp")], "Composition", &[]), Ok(Some("comp")));
// Two active contexts tie; an inactive one doesn't run.
assert_eq!(pick([("Mask", false, "mask"), ("Paint", false, "paint")], "Composition", &["Paint", "Mask"]), Err(vec!["Mask".to_string(), "Paint".into()]));
assert_eq!(pick([("Mask", false, "mask"), ("Paint", false, "paint")], "Composition", &["Paint"]), Ok(Some("paint")));
// The same scope twice (a reported conflict): the first one.
assert_eq!(pick([("Timeline", false, "a"), ("Timeline", false, "b")], "Timeline", &[]), Ok(Some("a")));
}
/// Two contexts can be active at once, so their shortcuts on the same keys conflict.
#[test]
fn context_shortcuts_on_the_same_keys_conflict() {
let mut s = Session::default();
s.set_ui_commands(vec![
crate::shortcuts::UiCommand { id: "test.mask".into(), label: "Mask".into(), shortcut: Some("F9".into()), scope: Some("Mask".into()) },
crate::shortcuts::UiCommand { id: "test.paint".into(), label: "Paint".into(), shortcut: Some("F9".into()), scope: Some("Paint".into()) },
]);
let t = s.shortcuts();
assert!(t.all_conflicts().iter().any(|(a, c)| a == "test.mask" && c.key == "test.paint" && !c.overrides), "{:?}", t.all_conflicts());
let b = t.find("test.mask").unwrap();
assert!(t.conflicts(b, "F9").iter().any(|c| c.key == "test.paint" && !c.overrides));
}
// ---------------------------------------------------------------- auto-save / recovery
+32
View File
@@ -49,6 +49,38 @@ fn animate_text_adds_animators_with_companion_properties() {
let _ = s.render(cid, s.time(), Default::default());
}
/// `animator` counts from 1, as `#n` references do, and a uid match wins over an index: every
/// command that takes it says so, and 0 fails with an error that says so (agents reading
/// `uid|index` tried 0).
#[test]
fn animator_indices_count_from_1_and_say_so() {
let (mut s, t) = session();
let uid = s.execute("layer.addTextAnimator", json!({"layer": t, "property": "position"})).unwrap()["animator"].as_u64().unwrap();
for id in ["layer.addTextAnimatorProperty", "layer.addTextSelector", "text.addSelector", "text.animatorFontAxes"] {
let spec = crate::cmd::command_specs().iter().find(|c| c.id == id).unwrap();
assert!(spec.params.contains("animator?: uid|index (1-based; a uid match wins over an index)"), "{id}: {}", spec.params);
}
for (id, p) in [
("layer.addTextAnimatorProperty", json!({"layer": t, "animator": 0, "property": "fillHue"})),
("layer.addTextSelector", json!({"layer": t, "animator": 0, "kind": "wiggly"})),
] {
let e = s.execute(id, p).unwrap_err().to_string();
assert!(e.contains("no animator 0") && e.contains("1-based"), "{id}: {e}");
}
let r = s.execute("layer.addTextSelector", json!({"layer": t, "animator": 1, "kind": "wiggly"})).unwrap();
assert_eq!(r["animator"].as_u64(), Some(uid));
// A second animator whose uid is 1: `animator: 1` is that uid, not the first animator, and
// index 2 reaches the same animator.
s.execute("layer.addTextAnimator", json!({"layer": t, "property": "opacity"})).unwrap();
let cid = s.active_comp_id().unwrap();
let layer = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(t)).unwrap();
layer.props.group_mut("text/animators").unwrap().children[1].as_group_mut().unwrap().uid = 1;
for (id, animator) in [("layer.addTextSelector", 1), ("text.addSelector", 1), ("layer.addTextSelector", 2)] {
let r = s.execute(id, json!({"layer": t, "animator": animator, "kind": "wiggly"})).unwrap();
assert_eq!(r["animator"].as_u64(), Some(1), "{id} animator {animator}: {r}");
}
}
#[test]
fn add_property_and_selectors_with_undo_redo() {
let (mut s, t) = session();
+56
View File
@@ -60,6 +60,62 @@ fn snap_lines_snap_axes_independently_and_priority_breaks_ties() {
assert_eq!(snap(&[[0.0, 0.0]], &t3, 5.0).unwrap().hits[0].source, SnapSource::Vertex(LayerId(3)));
}
/// #2855: an edge midpoint lies on its edge line, so one line nearer than the point must not win
/// and leave the other axis free. Two lines do win when together they are nearer.
#[test]
fn snap_point_beats_one_line_and_loses_only_to_two_nearer_lines() {
let src = SnapSource::Layer(LayerId(1));
let mid = SnapTarget::point([100.0, 50.0], PRI_LAYER, src);
let top = SnapTarget::hline(50.0, PRI_LAYER, src);
// The line alone is nearer (1 against √10), and the point still snaps both axes.
let s = snap(&[[103.0, 51.0]], &[top, mid], 5.0).unwrap();
assert_eq!(s.at, [100.0, 50.0]);
assert_eq!(s.delta, [-3.0, -1.0]);
assert_eq!(s.hits, [mid]);
// Lines through the point itself tie with it: the point.
let centre = SnapTarget::vline(100.0, PRI_LAYER, src);
assert_eq!(snap(&[[103.0, 51.0]], &[top, centre, mid], 5.0).unwrap().hits, [mid]);
// Two lines together nearer than the point: both snap.
let far = SnapTarget::point([101.0, 55.0], PRI_LAYER, src);
let v = SnapTarget::vline(102.0, PRI_GUIDE, SnapSource::Guide);
let h = SnapTarget::hline(52.0, PRI_GUIDE, SnapSource::Guide);
let s = snap(&[[103.0, 51.0]], &[v, h, far], 5.0).unwrap();
assert_eq!(s.at, [102.0, 52.0]);
assert_eq!(s.hits, [v, h]);
}
/// #2855: an unrotated layer's centre lines follow its edges' span rule, reach the outer comp
/// through a collapsed precomp layer, and a rotated layer keeps its points only.
#[test]
fn layer_centre_lines_follow_the_edges() {
use crate::viewer::{SnapFeatures, SnapOptions, targets};
let mut s = comp();
// A 100×50 solid moved off the comp's centre lines: x 70–170, y 55–105, centre (120, 80).
let a = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 100, "height": 50})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [120, 80, 0]})).unwrap();
let cid = s.active_comp_id().unwrap();
let at = |s: &Session, f: SnapFeatures| {
let comp = s.project.comp(cid).unwrap().clone();
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, Tick::ZERO);
targets(&ctx, &[], SnapOptions { layers: true, features: f, guides: false, grid: false, grid_spacing: 0.0 })
};
let lines = |t: &[SnapTarget], l: LayerId| {
let of = |kind: SnapKind, i: usize, c: f64| t.iter().find(|t| t.kind == kind && t.source == SnapSource::Layer(l) && t.pos[i] == c).map(|t| t.span);
(of(SnapKind::VLine, 0, 120.0), of(SnapKind::HLine, 1, 80.0))
};
assert_eq!(lines(&at(&s, SnapFeatures::default()), LayerId(a)), (Some(None), Some(None)));
let off = SnapFeatures { edges_extended: false, ..Default::default() };
assert_eq!(lines(&at(&s, off), LayerId(a)), (Some(Some([55.0, 105.0])), Some(Some([70.0, 170.0]))));
// Through a collapsed precomp layer (it fills the comp, unmoved, so the numbers stay).
s.execute("layer.precompose", json!({"layers": [a], "name": "Inner"})).unwrap();
let pre = s.active_comp().unwrap().layers[0].id;
s.execute("layer.setSwitch", json!({"layers": [pre.0], "switch": "collapse", "value": true})).unwrap();
assert_eq!(lines(&at(&s, off), pre), (Some(Some([55.0, 105.0])), Some(Some([70.0, 170.0]))));
// Rotated: no lines at all.
s.execute("prop.set", json!({"layer": pre.0, "path": "transform/rotation", "value": 30})).unwrap();
assert_eq!(lines(&at(&s, off), pre), (None, None));
}
#[test]
fn snap_targets_cover_layers_comp_guides_and_grid() {
let mut s = comp();
+12 -11
View File
@@ -48,7 +48,7 @@ pub struct SnapTarget {
pub priority: u8,
pub source: SnapSource,
/// A line's extent along itself (y of a vertical line, x of a horizontal one): a layer's
/// edge without Snap Edges Extended. `None` runs across the whole comp.
/// edge or centre line without Snap Edges Extended. `None` runs across the whole comp.
#[serde(default)]
pub span: Option<[f64; 2]>,
}
@@ -70,7 +70,7 @@ impl SnapTarget {
}
/// Tools bar ▸ Snapping options, the two After Effects' Snapping menu has: Snap Edges Extended
/// (layer edges snap along their whole line, beyond the layer's bounds) and Snap to Features in
/// (layer edge and centre lines snap along their whole line, beyond the layer's bounds) and Snap to Features in
/// Collapsed Compositions and Text Layers (the layers inside a precomp layer with Collapse
/// Transformations on are targets too). Both are on by default. Every layer feature snaps
/// (corners, edge midpoints, centre, anchor point, mask and shape path points), as in After
@@ -126,9 +126,10 @@ fn better(a: (f64, u8), b: (f64, u8)) -> bool {
}
/// Snap feature points `sources` (comp pixels) to the nearest target within `tol` comp pixels.
/// A point target snaps both axes and wins when it is at least as near as the best lines;
/// otherwise the nearest vertical and horizontal lines snap x and y independently. Ties go to
/// the lower priority value.
/// A point target snaps both axes and wins unless a vertical and a horizontal line both resolve
/// and together lie nearer than it (one line alone leaves the other axis free, so it never beats
/// a point: a layer's edge midpoint lies on its edge line); otherwise the nearest vertical and
/// horizontal lines snap x and y independently. Ties go to the lower priority value.
pub fn snap(sources: &[[f64; 2]], targets: &[SnapTarget], tol: f64) -> Option<Snap> {
let mut best_pt: Option<((f64, u8), [f64; 2], SnapTarget)> = None;
let mut best_x: Option<((f64, u8), [f64; 2], SnapTarget)> = None;
@@ -150,9 +151,9 @@ pub fn snap(sources: &[[f64; 2]], targets: &[SnapTarget], tol: f64) -> Option<Sn
}
}
}
let line_best = [best_x.map(|b| b.0.0), best_y.map(|b| b.0.0)].into_iter().flatten().fold(f64::INFINITY, f64::min);
let both_lines = best_x.zip(best_y).map_or(f64::INFINITY, |(x, y)| x.0.0.hypot(y.0.0));
if let Some((k, s, t)) = best_pt
&& k.0 <= line_best + 1e-9
&& k.0 <= both_lines + 1e-9
{
return Some(Snap { delta: [t.pos[0] - s[0], t.pos[1] - s[1]], hits: vec![t], at: t.pos });
}
@@ -366,8 +367,8 @@ fn visible_layers<'a>(ctx: &EvalCtx<'a>, exclude: &'a [LayerId]) -> impl Iterato
comp.layers.iter().filter(move |l| l.is_active_at(time) && l.switches.video && (!any_solo || l.switches.solo) && !exclude.contains(&l.id))
}
/// A 2D layer's snap targets: its box points, its edges as lines while unrotated (across the
/// comp with Snap Edges Extended, along the layer otherwise), its anchor point unless `anchor` is
/// A 2D layer's snap targets: its box points, its edges and centre as lines while unrotated (across
/// the comp with Snap Edges Extended, along the layer otherwise), its anchor point unless `anchor` is
/// false (Pan Behind drags it, the box stays put), its mask and shape path vertices and, with
/// Snap to Features in Collapsed Compositions, the targets of the visible layers inside a
/// collapsed precomp layer. None for cameras, lights and 3D layers.
@@ -386,10 +387,10 @@ fn layer_targets_at(ctx: &EvalCtx, l: &Layer, anchor: bool, f: SnapFeatures, dep
let [nw, ne, _, sw] = b.corners;
if (nw[1] - ne[1]).abs() < 1e-6 && (nw[0] - sw[0]).abs() < 1e-6 {
let (xs, ys) = if f.edges_extended { (None, None) } else { (Some([nw[0], ne[0]]), Some([nw[1], sw[1]])) };
for x in [nw[0], ne[0]] {
for x in [nw[0], b.center[0], ne[0]] {
out.push(SnapTarget::vline(x, PRI_LAYER, src).within(ys));
}
for y in [nw[1], sw[1]] {
for y in [nw[1], b.center[1], sw[1]] {
out.push(SnapTarget::hline(y, PRI_LAYER, src).within(xs));
}
}
+1
View File
@@ -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 }
+64 -17
View File
@@ -4,8 +4,8 @@
use std::io::Write;
use effectcraft_project::Comp;
use effectcraft_project::render_queue::{AudioFormat, Channels, OutputFormat, ProResProfile};
use effectcraft_project::{Comp, ProjectSettings};
use effectcraft_time::{FrameRate, Tick};
use filmcraft_isobmff::{Brand, ColorInfo, FourCc, Mp4Writer, PcmConfig, SampleEntry, TrackConfig, WriteSample, WriterOptions};
use rayon::prelude::*;
@@ -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>> {
@@ -164,6 +193,13 @@ struct ProRes {
w: u32,
h: u32,
alpha: bool,
colr: ColorInfo,
}
/// 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 {
@@ -179,14 +215,20 @@ fn prores_profile(p: ProResProfile) -> filmcraft_prores::Profile {
}
impl ProRes {
fn new(w: u32, h: u32, profile: ProResProfile, channels: Channels) -> ProRes {
fn new(w: u32, h: u32, profile: ProResProfile, channels: Channels, colr: ColorInfo) -> ProRes {
// Alpha needs a 4444 flavour.
let profile = if channels == Channels::Rgba && !profile.is_4444() { ProResProfile::P4444 } else { profile };
let profile = prores_profile(profile);
let alpha = channels == Channels::Rgba;
let mut cfg = filmcraft_prores::EncoderConfig::new(profile, w, h);
cfg.encode_alpha = alpha;
ProRes { enc: filmcraft_prores::Encoder::with_config(cfg), profile, w, h, alpha }
// The frame header carries the same H.273 codes as the `colr` atom, so readers that trust
// either one agree.
if let ColorInfo::Nclc { primaries, transfer, matrix } = colr {
let code = |c| u8::try_from(c).unwrap_or(2);
cfg.color = filmcraft_prores::ColorInfo { primaries: code(primaries), transfer: code(transfer), matrix: code(matrix) };
}
ProRes { enc: filmcraft_prores::Encoder::with_config(cfg), profile, w, h, alpha, colr }
}
fn is_444(&self) -> bool {
matches!(self.profile, filmcraft_prores::Profile::P4444 | filmcraft_prores::Profile::P4444Xq)
@@ -195,7 +237,11 @@ impl ProRes {
impl VideoEncoder for ProRes {
fn sample_entry(&self) -> SampleEntry {
SampleEntry::prores(FourCc(self.profile.fourcc()), self.w as u16, self.h as u16)
let mut entry = SampleEntry::prores(FourCc(self.profile.fourcc()), self.w as u16, self.h as u16);
if let Some(v) = entry.video.as_mut() {
v.color = Some(self.colr.clone());
}
entry
}
fn encode(&mut self, rgba: &[u8], _index: u64) -> Result<Vec<Packet>> {
let chroma = if self.is_444() { filmcraft_prores::ChromaFormat::Yuv444 } else { filmcraft_prores::ChromaFormat::Yuv422 };
@@ -296,12 +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)?),
_ => Box::new(ProRes::new(w, h, job.output.prores_profile, if channels == Channels::Alpha { Channels::Rgb } else { channels })),
_ => Box::new(ProRes::new(w, h, job.output.prores_profile, if channels == Channels::Alpha { Channels::Rgb } else { channels }, prores_colr(codes))),
};
let brand = if matches!(fmt, OutputFormat::ProRes | OutputFormat::Uncompressed) { Brand::Mov } else { Brand::Mp4 };
let batch = batch_size();
+13 -9
View File
@@ -7,7 +7,7 @@ use effectcraft_time::FrameRate;
use filmcraft_isobmff::{Av1Config, CodecConfig, FourCc, HevcConfig, SampleEntry};
use rayon::prelude::*;
use crate::encode::{Packet, VideoEncoder};
use crate::encode::{Codes, Packet, VideoEncoder};
use crate::{ExportError, Result};
fn enc_err(e: impl std::fmt::Display) -> ExportError {
@@ -59,6 +59,7 @@ pub(crate) struct Hevc {
w: u32,
h: u32,
bits: u8,
codes: Codes,
planes: Planes,
}
@@ -81,11 +82,12 @@ pub(crate) fn hevc_config(w: u32, h: u32, rate: FrameRate, om: &OutputModule) ->
}
impl Hevc {
pub(crate) fn new(w: u32, h: u32, rate: FrameRate, om: &OutputModule) -> Result<Hevc> {
let cfg = hevc_config(w, h, rate, om);
pub(crate) fn new(w: u32, h: u32, rate: FrameRate, om: &OutputModule, codes: Codes) -> Result<Hevc> {
let mut cfg = hevc_config(w, h, rate, om);
(cfg.colour_primaries, cfg.transfer_characteristics, cfg.matrix_coeffs) = (codes.primaries, codes.transfer, codes.matrix);
let bits = cfg.profile.bit_depth();
let enc = effectcraft_hevcenc::Encoder::new(cfg).map_err(enc_err)?;
Ok(Hevc { enc, w, h, bits, planes: Planes::default() })
Ok(Hevc { enc, w, h, bits, codes, planes: Planes::default() })
}
}
@@ -93,7 +95,7 @@ impl VideoEncoder for Hevc {
fn sample_entry(&self) -> SampleEntry {
let cfg = HevcConfig::parse(&self.enc.hvcc()).unwrap_or_default();
let mut entry = SampleEntry::hevc(cfg, self.w as u16, self.h as u16);
crate::encode::tag_bt709_limited(&mut entry);
crate::encode::tag_nclx_limited(&mut entry, self.codes);
entry
}
fn encode(&mut self, rgba: &[u8], _index: u64) -> Result<Vec<Packet>> {
@@ -116,6 +118,7 @@ pub(crate) struct Av1 {
w: u32,
h: u32,
bits: u8,
codes: Codes,
planes: Planes,
}
@@ -135,11 +138,12 @@ pub(crate) fn av1_config(w: u32, h: u32, rate: FrameRate, om: &OutputModule) ->
}
impl Av1 {
pub(crate) fn new(w: u32, h: u32, rate: FrameRate, om: &OutputModule) -> Result<Av1> {
let cfg = av1_config(w, h, rate, om);
pub(crate) fn new(w: u32, h: u32, rate: FrameRate, om: &OutputModule, codes: Codes) -> Result<Av1> {
let mut cfg = av1_config(w, h, rate, om);
(cfg.color_primaries, cfg.transfer_characteristics, cfg.matrix_coefficients) = (codes.primaries, codes.transfer, codes.matrix);
let bits = cfg.bit_depth;
let enc = effectcraft_av1enc::Encoder::new(cfg).map_err(enc_err)?;
Ok(Av1 { enc, w, h, bits, planes: Planes::default() })
Ok(Av1 { enc, w, h, bits, codes, planes: Planes::default() })
}
/// The `av1C` record (also the WebM CodecPrivate).
pub(crate) fn av1c(&self) -> Vec<u8> {
@@ -151,7 +155,7 @@ impl VideoEncoder for Av1 {
fn sample_entry(&self) -> SampleEntry {
let cfg = Av1Config::parse(&self.enc.av1c()).unwrap_or_default();
let mut entry = SampleEntry::video(FourCc(*b"av01"), CodecConfig::Av1(cfg), self.w as u16, self.h as u16);
crate::encode::tag_bt709_limited(&mut entry);
crate::encode::tag_nclx_limited(&mut entry, self.codes);
entry
}
fn encode(&mut self, rgba: &[u8], _index: u64) -> Result<Vec<Packet>> {
+25 -3
View File
@@ -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);
+4 -1
View File
@@ -391,11 +391,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()));
}
+2 -1
View File
@@ -42,7 +42,8 @@ fn cluster(blocks: &mut [Block]) -> Vec<u8> {
pub(crate) async fn webm_av1(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut State<'_>) -> Result<Report> {
let rate = job.settings.rate(comp);
let mut venc = crate::hevc_av1::Av1::new(w, h, rate, job.output)?;
let codes = crate::encode::output_codes(&job.project.settings, job.output.channels);
let mut venc = crate::hevc_av1::Av1::new(w, h, rate, job.output, codes)?;
let with_audio = wants_audio(job);
let opus_channels = if job.output.audio_channels == 1 { 1usize } else { 2 };
let mut opus = with_audio.then(|| opus_encoder(job));
+17 -1
View File
@@ -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);
}
+2
View File
@@ -7,10 +7,12 @@ 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;
mod opus_roundtrip;
mod prores_color;
mod render_settings;
mod roundtrip;
mod targa_sequence;
+2 -1
View File
@@ -102,5 +102,6 @@ fn h264_movie_bytes_are_pinned() {
#[test]
fn prores_movie_bytes_are_pinned() {
let data = export_bytes(OutputFormat::ProRes, "/out/pinned.mov");
assert_eq!((data.len(), fnv1a(&data)), (270_094, 8_038_709_743_204_075_800), "ProRes MOV");
// The `nclc` colr atom (#2801) adds exactly 18 bytes; the frames don't move.
assert_eq!((data.len(), fnv1a(&data)), (270_112, 2_693_123_490_992_200_481), "ProRes MOV");
}
+95
View File
@@ -0,0 +1,95 @@
//! H.264, HEVC and AV1 MP4s describe the frames the render path made (#2879): the `colr` box, read
//! back with FilmCraft's demuxer, and the bitstream's own colour description (the SPS VUI, AV1's
//! `color_config`), which ffprobe reports for the first frame of the bare elementary stream when
//! ffmpeg can copy it out and decode it.
use std::path::Path;
use std::process::Command;
use effectcraft_color::Label;
use effectcraft_export::render_queue::{OutputFormat, OutputModule, RenderSettings, TimeSpan};
use effectcraft_export::{Job, export};
use effectcraft_project::{ColorSpace, Comp, ItemKind, LayerSource, Project, Solid, build};
use effectcraft_render::NoFootage;
use effectcraft_time::{FrameRate, Tick};
use filmcraft_isobmff::{ColorInfo, TrackKind};
/// (name, Output color space, H.273 primaries / transfer / matrix, the same as ffprobe names them)
const ROWS: &[(&str, Option<ColorSpace>, [u16; 3], [&str; 3])] = &[
("default", None, [1, 1, 1], ["bt709", "bt709", "bt709"]),
("rec2020", Some(ColorSpace::Rec2020), [9, 1, 1], ["bt2020", "bt709", "bt709"]),
("p3", Some(ColorSpace::DisplayP3), [12, 13, 1], ["smpte432", "iec61966-2-1", "bt709"]),
("pq", Some(ColorSpace::Rec2100Pq), [9, 16, 1], ["bt2020", "smpte2084", "bt709"]),
("hlg", Some(ColorSpace::Rec2100Hlg), [9, 18, 1], ["bt2020", "arib-std-b67", "bt709"]),
];
fn render(output: Option<ColorSpace>, format: OutputFormat, path: &Path) {
let mut p = Project::default();
p.settings.working_space = Some(ColorSpace::Srgb);
p.settings.output_space = output;
let mut comp = Comp::new(64, 48, FrameRate::new(10, 1), Tick::from_seconds_f64(0.2));
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: [0.75, 0.25, 0.5], width: 64, height: 48, pixel_aspect: 1.0 }));
comp.layers.push(build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (64, 48), None));
let comp = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into()));
let settings = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
let om = OutputModule::for_format(format);
let out = path.to_string_lossy().to_string();
let job = Job {
project: &p,
footage: &NoFootage,
expr: None,
accel: None,
comp,
settings: &settings,
output: &om,
path: &out,
sink: None,
options: Default::default(),
nested_switches: true,
};
export(&job, &mut |_| true).expect("export");
}
/// ffprobe's primaries, transfer and matrix of the first frame of the movie's video copied out as a
/// bare elementary stream (`h264`, `hevc` or `obu`), so no container metadata can stand in for the
/// bitstream's own; `None` without ffmpeg or when its build can't copy or decode the stream.
fn bitstream_tags(path: &Path, format: &str) -> Option<Vec<String>> {
let es = path.with_extension(format);
let copied =
Command::new("ffmpeg").args(["-v", "error", "-y", "-i"]).arg(path).args(["-map", "0:v:0", "-c", "copy", "-f", format]).arg(&es).status().ok()?;
if !copied.success() {
return None;
}
let out = Command::new("ffprobe")
.args(["-v", "error", "-read_intervals", "%+#1", "-show_entries", "frame=color_primaries,color_transfer,color_space", "-of", "default=nw=1"])
.arg(&es)
.output()
.ok()?;
let text = String::from_utf8_lossy(&out.stdout).to_string();
let tag = |key: &str| text.lines().find_map(|l| l.strip_prefix(key)?.strip_prefix('=')).map(str::to_string);
Some(vec![tag("color_primaries")?, tag("color_transfer")?, tag("color_space")?])
}
#[test]
fn mp4_tags_describe_the_frames() {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export/movie_color");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("mkdir");
for (format, codec, stream) in [(OutputFormat::H264, "h264", "h264"), (OutputFormat::Hevc, "hevc", "hevc"), (OutputFormat::Av1, "av1", "obu")] {
for &(name, output, [primaries, transfer, matrix], names) in ROWS {
let path = dir.join(format!("{codec}_{name}.mp4"));
render(output, format, &path);
let bytes = std::fs::read(&path).expect("read movie");
let mp4 = filmcraft_isobmff::open(bytes.as_slice()).expect("demux");
let track = mp4.tracks.iter().find(|t| t.kind == TrackKind::Video).expect("video track");
let colr = track.entries[0].video.as_ref().and_then(|v| v.color.clone());
assert_eq!(colr, Some(ColorInfo::Nclx { primaries, transfer, matrix, full_range: false }), "{codec} {name}: colr");
match bitstream_tags(&path, stream) {
Some(tags) => assert_eq!(tags, names, "{codec} {name}: the bitstream's colour description"),
None => eprintln!("{codec} {name}: ffprobe can't decode it here, bitstream not checked"),
}
}
}
}
+158
View File
@@ -0,0 +1,158 @@
//! 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. 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, sequence_path};
use effectcraft_export::{Job, export};
use effectcraft_media::MediaPool;
use effectcraft_project::{ColorSpace, Comp, ItemKind, LayerSource, Project, Solid, build};
use effectcraft_render::{FootageSource, NoFootage};
use effectcraft_time::{FrameRate, Tick};
use filmcraft_isobmff::{ColorInfo, TrackKind};
struct Row {
name: &'static str,
working: Option<ColorSpace>,
output: Option<ColorSpace>,
channels: Channels,
/// H.273 primaries, transfer and matrix.
codes: [u16; 3],
/// The same as ffprobe names them.
names: [&'static str; 3],
}
const SRGB: Option<ColorSpace> = Some(ColorSpace::Srgb);
const PQ: Option<ColorSpace> = Some(ColorSpace::Rec2100Pq);
const BT709: [&str; 3] = ["bt709", "bt709", "bt709"];
const ROWS: &[Row] = &[
Row { name: "unmanaged", working: None, output: None, channels: Channels::Rgb, codes: [1, 1, 1], names: BT709 },
Row { name: "unmanaged_ignores_output", working: None, output: PQ, channels: Channels::Rgb, codes: [1, 1, 1], names: BT709 },
Row { name: "srgb_output_stays_1_1_1", working: SRGB, output: None, channels: Channels::Rgb, codes: [1, 1, 1], names: BT709 },
Row { name: "rec709", working: SRGB, output: Some(ColorSpace::Rec709), channels: Channels::Rgb, codes: [1, 1, 1], names: BT709 },
Row { name: "rec2020", working: SRGB, output: Some(ColorSpace::Rec2020), channels: Channels::Rgb, codes: [9, 1, 1], names: ["bt2020", "bt709", "bt709"] },
Row {
name: "p3",
working: SRGB,
output: Some(ColorSpace::DisplayP3),
channels: Channels::Rgb,
codes: [12, 13, 1],
names: ["smpte432", "iec61966-2-1", "bt709"],
},
Row { name: "pq", working: SRGB, output: PQ, channels: Channels::Rgb, codes: [9, 16, 1], names: ["bt2020", "smpte2084", "bt709"] },
Row {
name: "hlg",
working: SRGB,
output: Some(ColorSpace::Rec2100Hlg),
channels: Channels::Rgb,
codes: [9, 18, 1],
names: ["bt2020", "arib-std-b67", "bt709"],
},
Row { name: "aces", working: SRGB, output: Some(ColorSpace::AcesCg), channels: Channels::Rgb, codes: [2, 8, 1], names: ["unknown", "linear", "bt709"] },
Row { name: "pq_with_alpha", working: SRGB, output: PQ, channels: Channels::Rgba, codes: [9, 16, 1], names: ["bt2020", "smpte2084", "bt709"] },
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: 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 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,
expr: None,
accel: None,
comp,
settings: &settings,
output,
path: &out,
sink: None,
options: Default::default(),
nested_switches: true,
};
export(&job, &mut |_| true).expect("export");
}
/// ffprobe's primaries, transfer and matrix for the stream, or `None` without ffprobe.
fn ffprobe_tags(path: &Path) -> Option<Vec<String>> {
let out = Command::new("ffprobe")
.args(["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=color_primaries,color_transfer,color_space", "-of", "default=nw=1"])
.arg(path)
.output()
.ok()?;
assert!(out.status.success(), "ffprobe rejected {}: {}", path.display(), String::from_utf8_lossy(&out.stderr));
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('=')).unwrap_or("").to_string();
Some(vec![tag("color_primaries"), tag("color_transfer"), tag("color_space")])
}
#[test]
fn prores_colr_describes_the_frames() {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export/prores_color");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("mkdir");
for row in ROWS {
let path = dir.join(format!("{}.mov", row.name));
render(row, &path);
let bytes = std::fs::read(&path).expect("read movie");
let mov = filmcraft_isobmff::open(bytes.as_slice()).expect("demux");
let track = mov.tracks.iter().find(|t| t.kind == TrackKind::Video).expect("video track");
let [primaries, transfer, matrix] = row.codes;
let colr = track.entries[0].video.as_ref().and_then(|v| v.color.clone());
assert_eq!(colr, Some(ColorInfo::Nclc { primaries, transfer, matrix }), "{}: colr", row.name);
if let Some(stream) = ffprobe_tags(&path) {
assert_eq!(stream, row.names, "{}: ffprobe stream tags", row.name);
}
}
}
/// #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");
}
}
+17
View File
@@ -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();
+1 -1
View File
@@ -1144,7 +1144,7 @@ function __global(name, get) {
__global('thisComp', function () { return __S.comp || (__S.comp = new Comp(__S.c)); });
__global('thisLayer', function () { return __S.layer || (__S.layer = __layer(__S.c, __S.l)); });
__global('thisProperty', function () { return __S.prop || (__S.prop = __mkProp(__S.c, __S.l, __S.path)); });
['name', 'index', 'inPoint', 'outPoint', 'startTime', 'width', 'height', 'hasParent', 'parent', 'hasVideo', 'enabled', 'active',
['name', 'index', 'inPoint', 'outPoint', 'startTime', 'width', 'height', 'hasParent', 'parent', 'hasVideo', 'enabled', 'active', 'source',
'transform', 'position', 'scale', 'rotation', 'opacity', 'anchorPoint', 'text', 'marker'].forEach(function (k) {
__global(k, function () { return thisLayer[k]; });
});
+83
View File
@@ -1402,6 +1402,89 @@ fn layer_source_is_the_layers_source_item() {
assert_close!(f.num("thisComp.layer('Pre').source.numLayers", 0.0), 1.0);
}
/// The fixture plus a 20×12 solid "Wide", a 48×36 still "Still" (item "still.png") and a precomp
/// layer "Pre" of the 64×32 "Other" comp.
fn implicit_source_fx() -> (Fx, LayerId, LayerId, LayerId) {
let mut f = fx();
let main = f.p.comp(f.cid).unwrap().clone();
let wide = solid(&mut f.p, &main, "Wide", [1.0, 1.0, 0.0], 20, 12);
let item = f.p.add_item(
"still.png",
Label::SeaFoam,
None,
ItemKind::Footage(effectcraft_project::Footage {
path: "still.png".into(),
kind: effectcraft_project::FootageKind::Still,
width: 48,
height: 36,
has_video: true,
..Default::default()
}),
);
let still = build::layer(&mut f.p, &main, "Still", LayerSource::Footage { item }, (48, 36), None);
let other = f.p.items.values().find(|i| i.name == "Other").unwrap().id;
let pre = build::layer(&mut f.p, &main, "Pre", LayerSource::Comp { item: other }, (64, 32), None);
let ids = (wide.id, still.id, pre.id);
let c = f.p.comp_mut(f.cid).unwrap();
c.layers.push(wide);
c.layers.push(still);
c.layers.push(pre);
(f, ids.0, ids.1, ids.2)
}
fn num_on(f: &Fx, layer: LayerId, text: &str) -> f64 {
match f.ev(layer, ROT, text, 0.0) {
Ok(v) => v.as_f64(),
Err(e) => panic!("{text}: {e}"),
}
}
#[test]
fn implicit_source_matches_this_layer_source() {
let (f, wide, still, pre) = implicit_source_fx();
let same = "['name', 'width', 'height', 'duration', 'frameDuration', 'pixelAspect']\
.every(function (k) { return source[k] === thisLayer.source[k]; }) ? 1 : 0";
for (layer, name, w, h) in [(wide, "Wide", 20.0, 12.0), (still, "still.png", 48.0, 36.0), (pre, "Other", 64.0, 32.0)] {
assert_close!(num_on(&f, layer, "source.width"), w);
assert_close!(num_on(&f, layer, "source.height"), h);
assert_close!(num_on(&f, layer, &format!("source.name == '{name}' ? 1 : 0")), 1.0);
assert_close!(num_on(&f, layer, same), 1.0);
}
assert_close!(num_on(&f, pre, "source.numLayers"), 1.0);
}
#[test]
fn implicit_source_is_null_without_a_source_item() {
let f = fx();
for layer in [f.title, f.shape] {
assert_close!(num_on(&f, layer, "source === null && thisLayer.source === null ? 1 : 0"), 1.0);
let bare = f.ev(layer, ROT, "source.width", 0.0).unwrap_err();
let qualified = f.ev(layer, ROT, "thisLayer.source.width", 0.0).unwrap_err();
assert!(qualified.contains("TypeError"), "{qualified}");
assert_eq!(bare, qualified);
}
}
#[test]
fn implicit_source_can_be_shadowed_locally() {
let (f, wide, ..) = implicit_source_fx();
assert_close!(num_on(&f, wide, "var source = 7;\nsource * 2"), 14.0);
assert_close!(num_on(&f, wide, "source.width"), 20.0);
assert_close!(num_on(&f, wide, "var source = thisComp.layer('B').source;\nsource.width"), 40.0);
assert_close!(num_on(&f, wide, "source.height"), 12.0);
}
#[test]
fn implicit_source_follows_the_current_layer() {
let (f, wide, still, pre) = implicit_source_fx();
let e = "source === null ? -1 : source.width * 1000 + source.height";
for _ in 0..2 {
for (layer, want) in [(wide, 20012.0), (still, 48036.0), (pre, 64032.0), (f.title, -1.0), (f.b, 40040.0)] {
assert_close!(num_on(&f, layer, e), want);
}
}
}
#[test]
fn layer_control_property_stands_for_its_layer() {
let mut f = fx();
+1
View File
@@ -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]
+6 -4
View File
@@ -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() {
+757 -124
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -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;
+21 -9
View File
@@ -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> {
+77 -3
View File
@@ -174,6 +174,64 @@ 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);
// `rem_euclid` can round a tiny negative phase up to `n`: wrap the index like the CPU
// lookup (`color3::arbitrary_map`) so both endpoints come from the start of the map.
let i = x.floor() as usize % n;
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 +366,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];
@@ -379,3 +437,19 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
}
run(e, &p, b, data)
}
#[cfg(test)]
mod map_tests {
use super::arbitrary_map_table;
/// A phase just below zero rounds `rem_euclid` up to `n` at input 0: the table must hold the
/// map's first entry and the slope towards its second, as the CPU lookup does.
#[test]
fn arbitrary_map_wraps_a_phase_rounded_to_the_end() {
let map = [0.25f32, 0.75, 1.0];
let table = arbitrary_map_table(&map, -1e-300).unwrap();
assert_eq!(table[0], 0.0);
assert_eq!(table[1], 0.25, "value at input 0 is map[0]");
assert_eq!(table[2], 0.5 * 2.0, "slope from map[0] to map[1], times n - 1");
}
}
+18 -19
View File
@@ -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> {
+764
View File
@@ -0,0 +1,764 @@
//! 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", &params([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",
&params([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",
&params([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", &params([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", &params([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", &params([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> {
// Texture and buffer limits are independent: a level that fits as a texture can still need a
// flags buffer over the device's storage binding limit, and that validation error would
// retire the context. Decline the GPU path instead (the fill runs on the CPU).
if !flags_fit(&e.g.device.limits(), w, h) {
return None;
}
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) })
}
/// Whether a level's `w × h` f32 flags buffer (at least the 16 bytes [`Enc::data`] allocates)
/// can be created and bound as one storage buffer on a device with `limits`.
fn flags_fit(limits: &wgpu::Limits, w: u32, h: u32) -> bool {
u64::from(w)
.checked_mul(u64::from(h))
.and_then(|n| n.checked_mul(4))
.map(|b| b.max(16))
.is_some_and(|b| b <= limits.max_buffer_size && b <= limits.max_storage_buffer_binding_size)
}
/// 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", &params([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
}
#[cfg(test)]
mod limit_tests {
use super::flags_fit;
/// A level whose flags buffer is over the storage binding limit is declined before it is
/// created (the default 128 MiB binding holds 33,554,432 flags: 8192 × 4096).
#[test]
fn flags_buffer_is_checked_against_the_device_limits() {
let l = wgpu::Limits::default();
assert!(flags_fit(&l, 1, 1));
assert!(flags_fit(&l, 8192, 4096));
assert!(!flags_fit(&l, 8192, 4097));
assert!(!flags_fit(&l, 16384, 16384));
let small = wgpu::Limits { max_buffer_size: 1024, ..wgpu::Limits::default() };
assert!(flags_fit(&small, 16, 16));
assert!(!flags_fit(&small, 16, 17));
}
}
+28 -1
View File
@@ -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() || !crate::inpaint::KERNELS.iter().all(|e| self.ctx.has_kernel(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();
+328
View File
@@ -0,0 +1,328 @@
//! 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,
//! and lower on a discrete card whose memory the OS reports without `unsafe` (Linux's amdgpu
//! driver, in sysfs: a 2–4 GB card ran out of memory and took the system down, #1487);
//! `EFFECTCRAFT_GPU_MEMCAP_MB` overrides it (0 = off). Backends without an allocator report
//! (Metal, WebGPU, GL) are never gated.
use std::path::Path;
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, vram(info))
}
fn from_env(var: Option<&str>, device_type: wgpu::DeviceType, vram: Option<u64>) -> MemCap {
let cap = cap_from_env(var, device_type, vram);
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, vram: Option<u64>) -> u64 {
let Some(v) = var else { return default_cap(device_type, vram) };
match v.trim().parse::<u64>().ok().and_then(|mb| mb.checked_mul(MIB)) {
Some(cap) => cap,
None => {
let cap = default_cap(device_type, vram);
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`), and at most three quarters of the card's
/// memory when it is known (`vram`), so a 2 GB card gets 1.5 GiB (#1487). Integrated and software
/// adapters share system memory: 2 GiB, whatever their carve-out.
fn default_cap(t: wgpu::DeviceType, vram: Option<u64>) -> u64 {
match t {
wgpu::DeviceType::DiscreteGpu => vram.map_or(3 * GIB, |v| (v / 4).saturating_mul(3).min(3 * GIB)),
_ => 2 * GIB,
}
}
/// The adapter's dedicated memory in bytes, where the OS reports it without `unsafe`.
fn vram(info: &wgpu::AdapterInfo) -> Option<u64> {
if !cfg!(target_os = "linux") || info.device_type != wgpu::DeviceType::DiscreteGpu {
return None;
}
let bytes = vram_in_sysfs(Path::new("/sys/class/drm"), info.vendor, info.device, &info.device_pci_bus_id);
if let Some(b) = bytes {
log::info!(target: "effectcraft_gpu", "gpu {}: {} MiB of video memory (sysfs)", info.name, b >> 20);
}
bytes
}
/// `mem_info_vram_total` (amdgpu) of the DRM card under `drm` whose PCI vendor and device ids are
/// `vendor` and `device`. Connector entries (`card1-DP-1`) are skipped. Variants of one model share
/// those ids (the RX 550 ships with 2 and 4 GB), so the card at PCI address `bus` (`uevent`'s
/// `PCI_SLOT_NAME`, wgpu's `device_pci_bus_id`) wins; without one, the smallest matching card.
fn vram_in_sysfs(drm: &Path, vendor: u32, device: u32, bus: &str) -> Option<u64> {
let id = |path: &Path| std::fs::read_to_string(path).ok().and_then(|s| u32::from_str_radix(s.trim().trim_start_matches("0x"), 16).ok());
let slot = |dev: &Path| {
std::fs::read_to_string(dev.join("uevent")).ok()?.lines().find_map(|l| l.strip_prefix("PCI_SLOT_NAME=").map(|s| s.trim().to_ascii_lowercase()))
};
let mut cards: Vec<_> = std::fs::read_dir(drm)
.ok()?
.flatten()
.map(|e| e.path())
.filter(|p| p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("card") && !n.contains('-')))
.collect();
cards.sort();
let found: Vec<(Option<String>, u64)> = cards
.iter()
.filter_map(|card| {
let dev = card.join("device");
if id(&dev.join("vendor")) != Some(vendor) || id(&dev.join("device")) != Some(device) {
return None;
}
let bytes = std::fs::read_to_string(dev.join("mem_info_vram_total")).ok()?.trim().parse::<u64>().ok().filter(|&b| b > 0)?;
Some((slot(&dev), bytes))
})
.collect();
let bus = bus.trim().to_ascii_lowercase();
found.iter().find(|(s, _)| !bus.is_empty() && s.as_deref() == Some(bus.as_str())).map(|&(_, b)| b).or_else(|| found.iter().map(|&(_, b)| b).min())
}
/// 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, None), 3 * GIB);
assert_eq!(default_cap(wgpu::DeviceType::IntegratedGpu, None), 2 * GIB);
let m = MemCap::from_env(None, wgpu::DeviceType::IntegratedGpu, None);
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, None);
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, None).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, None);
// 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, None);
// 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");
}
/// #1487: a 2–4 GB card gets three quarters of its memory instead of the 3 GiB default; a
/// larger card keeps the default, and an integrated GPU's carve-out doesn't lower its cap.
#[test]
fn known_video_memory_lowers_the_discrete_cap() {
let d = wgpu::DeviceType::DiscreteGpu;
assert_eq!(default_cap(d, Some(2 * GIB)), 3 * GIB / 2);
assert_eq!(default_cap(d, Some(4 * GIB)), 3 * GIB);
assert_eq!(default_cap(d, Some(16 * GIB)), 3 * GIB, "never above the default");
assert_eq!(default_cap(wgpu::DeviceType::IntegratedGpu, Some(GIB / 2)), 2 * GIB);
assert_eq!(cap_from_env(None, d, Some(2 * GIB)), 3 * GIB / 2);
assert_eq!(cap_from_env(Some("lots"), d, Some(2 * GIB)), 3 * GIB / 2, "a bad override falls back to it");
assert_eq!(cap_from_env(Some("4096"), d, Some(2 * GIB)), 4 * GIB, "the override still wins");
}
#[test]
fn video_memory_is_read_from_the_matching_drm_card() {
let dir = std::env::temp_dir().join(format!("effectcraft-memcap-{}", std::process::id()));
let card = |name: &str, vendor: &str, device: &str, vram: Option<&str>| {
let dev = dir.join(name).join("device");
std::fs::create_dir_all(&dev).unwrap();
std::fs::write(dev.join("vendor"), vendor).unwrap();
std::fs::write(dev.join("device"), device).unwrap();
if let Some(v) = vram {
std::fs::write(dev.join("mem_info_vram_total"), v).unwrap();
}
};
// An integrated Intel GPU (no amdgpu file), the RX 550 and a connector entry.
card("card0", "0x8086\n", "0x9bc5\n", None);
card("card1", "0x1002\n", "0x699f\n", Some("4294967296\n"));
std::fs::create_dir_all(dir.join("card1-DP-1")).unwrap();
assert_eq!(vram_in_sysfs(&dir, 0x1002, 0x699f, ""), Some(4 * GIB));
assert_eq!(vram_in_sysfs(&dir, 0x8086, 0x9bc5, ""), None, "no mem_info_vram_total");
assert_eq!(vram_in_sysfs(&dir, 0x1002, 0x73bf, ""), None, "no such card");
assert_eq!(vram_in_sysfs(&dir.join("missing"), 0x1002, 0x699f, ""), None);
// A 2 GB RX 550 next to the 4 GB one: same ids, told apart by PCI address.
card("card2", "0x1002\n", "0x699f\n", Some("2147483648\n"));
std::fs::write(dir.join("card1/device/uevent"), "DRIVER=amdgpu\nPCI_SLOT_NAME=0000:01:00.0\n").unwrap();
std::fs::write(dir.join("card2/device/uevent"), "DRIVER=amdgpu\nPCI_SLOT_NAME=0000:03:00.0\n").unwrap();
assert_eq!(vram_in_sysfs(&dir, 0x1002, 0x699f, "0000:01:00.0"), Some(4 * GIB));
assert_eq!(vram_in_sysfs(&dir, 0x1002, 0x699f, "0000:03:00.0"), Some(2 * GIB));
assert_eq!(vram_in_sysfs(&dir, 0x1002, 0x699f, ""), Some(2 * GIB), "unknown address: the smaller card");
assert_eq!(vram_in_sysfs(&dir, 0x1002, 0x699f, "0000:09:00.0"), Some(2 * GIB), "no such address: the smaller card");
let _ = std::fs::remove_dir_all(&dir);
}
}
+97 -5
View File
@@ -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,20 @@ 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);
// From 2^23 up every f32 is an integer, and `t + 0.5` would round to even (8388608 → 8388609).
if (abs(x) >= 8388608.0) {
return x;
}
return t;
let t = trunc(x);
if (x >= 0.0) {
return select(t, t + 1.0, x >= t + 0.5);
}
return select(t, t - 1.0, x <= t - 0.5);
}
// f32::rem_euclid(1.0).
+3 -46
View File
@@ -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)
+9 -9
View File
@@ -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;
+36 -4
View File
@@ -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,
+15 -10
View File
@@ -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);
+2 -22
View File
@@ -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);
}
+5 -5
View File
@@ -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);
}
+796
View File
@@ -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));
}
+4 -4
View File
@@ -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;
+629 -3
View File
@@ -52,10 +52,136 @@ 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;
}
if (P.u[0].x == 2u) {
textureStore(out, p, vec4<f32>(round_away(v), 0.0, 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", &params, &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:?}");
}
}
}
/// `round_away` is `f32::round`, also from 2^23 up where every value is already an integer
/// (integer IDs there must not merge: 8388608 and 8388609 both rounded to 8388609).
#[test]
fn round_away_matches_f32_round_for_large_values() {
let Some(g) = gpu() else { return };
let mut values = vec![];
for m in [1.0f32, -1.0] {
for v in [0.0f32, 0.5, 1.5, 2.5, 0.49999997, 4194303.5, 8388607.5, 8388608.0, 8388609.0, 8388610.0, 16777216.0, 3.0e9] {
values.push([m * v, 0.0, 0.0, 1.0]);
}
}
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] = [2, 0, 0, 0];
e.pixels("math_probe", &params, &input, None, &out, None);
let result = e.download(&out).unwrap();
for (v, got) in values.iter().zip(result.data) {
assert_eq!(v[0].round(), got[0], "round_away({})", v[0]);
}
}
/// 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.has_kernel(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 +910,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 +1046,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);
@@ -1009,7 +1135,7 @@ fn effects_whose_kernels_failed_render_on_the_cpu() {
ctx.drop_kernel("pointwise");
ctx.drop_kernel("box_h");
let names: Vec<&str> = ctx.unavailable_kernels().iter().map(|(e, _)| *e).collect();
assert_eq!(names, ["pointwise", "box_h"]);
assert_eq!(names, ["box_h", "pointwise"]);
let g = Gpu::from_context(ctx);
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
let mut s = Scene::new(depth);
@@ -1029,6 +1155,24 @@ fn effects_whose_kernels_failed_render_on_the_cpu() {
}
}
/// #1590: effect kernels are built on first use, not with the context (building all of them
/// up front kept Chrome's GPU process busy past its watchdog). Once asked for, every one of
/// them builds on this device: a kernel no test dispatches still can't break unnoticed.
#[test]
fn effect_kernels_build_on_first_use() {
if gpu().is_none() {
return;
}
let Ok(ctx) = pollster::block_on(crate::context::GpuContext::request()) else { return };
let built = |ctx: &crate::context::GpuContext| ctx.effect_pipelines_built();
assert_eq!(built(&ctx), 0, "no effect kernel is built with the context");
let started = std::time::Instant::now();
ctx.build_all_kernels();
eprintln!("{}: built {} effect kernels in {:.2?}", ctx.name, built(&ctx), started.elapsed());
assert_eq!(ctx.unavailable_kernels(), vec![], "every effect kernel builds on {}", ctx.name);
assert!(ctx.has_kernel("classic3d") && ctx.has_kernel("math_probe"), "the up-front kernels build on {}", ctx.name);
}
/// WGSL cannot read the CPU's constants, so Glow's bright pass repeats them as literals; this
/// ties them to `effects::GLOW_GAIN` / `GLOW_KNEE` (runs without a GPU).
#[test]
@@ -1315,3 +1459,485 @@ fn owned_devices_favour_memory_usage() {
assert_eq!(d.label, Some("effectcraft gpu"));
assert_eq!(d.required_limits.max_texture_dimension_2d, wgpu::Limits::default().max_texture_dimension_2d);
}
/// 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) = (&params, &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");
});
}
+90
View File
@@ -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: &params, 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: &params, 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: &params, 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).
+310
View File
@@ -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> = crate::inpaint::KERNELS.iter().copied().filter(|e| !g.ctx.has_kernel(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(&gt, &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(&gt, &hole);
let mem = membrane_fill(&src, &hole);
let pm = g.patch(&src, &hole, None, 7, 1);
let (pm_db, mem_db) = (psnr(&pm, &gt, Some(&hole)), psnr(&mem, &gt, 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(&gt, &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, &gt, 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(&gt, &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, &gt, Some(&hole)) > 25.0, "{:.1} dB", psnr(&gpu_pm, &gt, 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(&gt, &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, &gt, 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(&gt, &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], &gts[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(&gt, &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(&gt, &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, &gt, 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(&gt, &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");
}
}
}
+6
View File
@@ -213,6 +213,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(());
+1 -1
View File
@@ -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);
+10
View File
@@ -83,6 +83,10 @@ pub struct EncoderConfig {
pub keyint: u32,
/// BT.709 limited range is signalled in the VUI unless this is set.
pub full_range: bool,
/// The VUI colour description (ITU-T H.273 code points); BT.709 by default.
pub colour_primaries: u8,
pub transfer_characteristics: u8,
pub matrix_coeffs: u8,
}
impl EncoderConfig {
@@ -97,6 +101,9 @@ impl EncoderConfig {
rate: RateControl::ConstantQp(28),
keyint: 60,
full_range: false,
colour_primaries: 1,
transfer_characteristics: 1,
matrix_coeffs: 1,
}
}
}
@@ -234,6 +241,9 @@ impl Encoder {
profile_idc: cfg.profile.idc(),
level_idc,
full_range: cfg.full_range,
colour_primaries: cfg.colour_primaries,
transfer_characteristics: cfg.transfer_characteristics,
matrix_coeffs: cfg.matrix_coeffs,
fps_num: cfg.fps_num,
fps_den: cfg.fps_den,
};
+6 -3
View File
@@ -25,6 +25,9 @@ pub struct SeqParams {
pub profile_idc: u8,
pub level_idc: u8,
pub full_range: bool,
pub colour_primaries: u8,
pub transfer_characteristics: u8,
pub matrix_coeffs: u8,
pub fps_num: u32,
pub fps_den: u32,
}
@@ -166,9 +169,9 @@ fn vui(w: &mut BitWriter, p: &SeqParams) {
w.put(5, 3); // video_format: unspecified
w.flag(p.full_range);
w.put(1, 1); // colour_description_present_flag
w.put(1, 8); // colour_primaries BT.709
w.put(1, 8); // transfer_characteristics BT.709
w.put(1, 8); // matrix_coeffs BT.709
w.put(p.colour_primaries as u32, 8); // colour_primaries
w.put(p.transfer_characteristics as u32, 8); // transfer_characteristics
w.put(p.matrix_coeffs as u32, 8); // matrix_coeffs
w.put(0, 1); // chroma_loc_info_present_flag
w.put(0, 1); // neutral_chroma_indication_flag
w.put(0, 1); // field_seq_flag
+12 -8
View File
@@ -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;
+64 -3
View File
@@ -1,7 +1,7 @@
//! Timeline documents → compositions (File ▸ Import ▸ Adobe Premiere Pro Project / Timeline
//! Interchange…).
use std::collections::{BTreeMap, HashMap};
use std::collections::{BTreeMap, HashMap, HashSet};
use effectcraft_color::{BlendMode, Label};
use effectcraft_keyframe::{Keyframe, Value};
@@ -156,18 +156,79 @@ pub fn import(
}),
name: Some(opts.name.trim_end_matches(|c| c != '.').trim_end_matches('.').to_string()).filter(|s| !s.is_empty()),
};
let (imported, report) = filmcraft_interchange::import_with(bytes, format.fc(), &fc_opts)?;
let (mut imported, report) = filmcraft_interchange::import_with(bytes, format.fc(), &fc_opts)?;
// Each file is probed once, though an EDL's media are probed before the layers are made.
let mut probed: HashMap<String, Option<Footage>> = HashMap::new();
let mut probe_once = |path: &str| probed.entry(path.to_string()).or_insert_with(|| probe(path)).clone();
let left_on_timecode = if format == TimelineFormat::Edl { rebase_to_file_timecode(&mut imported.project, &mut probe_once) } else { Vec::new() };
let mut res = ImportResult { format: Some(format), ..Default::default() };
for e in &report.entries {
// The reader's "rebase after probing" note is done above: name only the media it wasn't.
if format == TimelineFormat::Edl && e.message.starts_with(EDL_MEDIA_TIME_NOTE) {
if !left_on_timecode.is_empty() {
res.warnings
.push(format!("EDL source timecodes stay media time for {}: the file is missing or has no start timecode", left_on_timecode.join(", ")));
}
continue;
}
if e.count > 1 { res.warnings.push(format!("{} (×{})", e.message, e.count)) } else { res.warnings.push(e.message.clone()) }
}
let folder_name = if opts.name.is_empty() { "Imported Timeline".to_string() } else { opts.name.clone() };
res.folder = project.add_item(&folder_name, Label::None, None, ItemKind::Folder);
let mut im = Importer { src: &imported.project, project, res, items: HashMap::new(), solids: HashMap::new(), solids_folder: None, bins: HashMap::new() };
im.run(&imported.sequences, probe);
im.run(&imported.sequences, &mut probe_once);
Ok(im.res)
}
/// The start of the note FilmCraft's EDL reader adds when source timecodes look like real
/// timecode (an hour or more) and it has kept them as media time.
const EDL_MEDIA_TIME_NOTE: &str = "EDL source timecodes were imported as media time";
/// An EDL's source times are its source timecodes, which FilmCraft's reader keeps as media time.
/// The largest start timecode, in frames, a file may have: a day at 1000 fps.
const MAX_FILE_TIMECODE: i64 = 24 * 3600 * 1000;
/// Put the clips of each file that has a start timecode on the file's own time, as FilmCraft does
/// when it links the media of an imported EDL. Returns the names of the media that stay on
/// timecode though their clips use an hour or more of it (the file is missing or has none).
fn rebase_to_file_timecode(project: &mut fp::Project, probe: &mut dyn FnMut(&str) -> Option<Footage>) -> Vec<String> {
let mut on_timecode: HashSet<fp::ItemId> = HashSet::new();
for it in project.items.values() {
if let fp::ItemKind::Sequence(seq) = &it.kind {
// 01:00:00:00 as the list counts it: 01:00:00;00 drop-frame at 29.97 fps is 3599.996 s
let (rate, df) = (seq.settings.frame_rate, seq.settings.drop_frame);
let hour = rate.tick_of(filmcraft_time::fields_to_frames(1, 0, 0, 0, rate, df));
on_timecode.extend(seq.all_tracks().flat_map(|t| &t.items).filter(|ti| ti.source_in >= hour).map(|ti| ti.item));
}
}
let files: Vec<(fp::ItemId, String, String, bool)> = project
.items
.values()
.filter_map(|it| match &it.kind {
fp::ItemKind::Media(m) => match &m.media {
fp::MediaRef::File { path } => Some((it.id, it.name.clone(), path.clone(), m.offline)),
_ => None,
},
_ => None,
})
.collect();
let mut left = Vec::new();
for (id, name, path, offline) in files {
match if offline { None } else { probe(&path) } {
// A corrupt timecode past a day of frames (at up to 1000 fps) would overflow the tick
// arithmetic; such a file stays on timecode.
Some(Footage { file_timecode: Some(tc), native_rate, frame_rate, .. }) if (0..=MAX_FILE_TIMECODE).contains(&tc) => {
filmcraft_interchange::rebase_source_timecode(project, id, tc, crate::rate_out(native_rate.unwrap_or(frame_rate)));
}
_ if on_timecode.contains(&id) => left.push(name),
_ => {}
}
}
left.sort();
left.dedup();
left
}
struct Importer<'a> {
src: &'a fp::Project,
project: &'a mut Project,
+55
View File
@@ -365,6 +365,61 @@ fn edl_imports_one_track() {
assert!(c.layers.len() >= 2);
}
/// An EDL's source timecodes are footage time once the file's own start timecode is known:
/// 01:00:01:00 in a file that starts at 01:00:00:00 is one second in, not 3601 s.
#[test]
fn edl_source_timecode_lands_on_the_files_own_time() {
let edl = "TITLE: CUT\nFCM: NON-DROP FRAME\n\n001 A001 V C 01:00:01:00 01:00:02:00 01:00:00:00 01:00:01:00\n* FROM CLIP NAME: a.mov\n* SOURCE FILE: /media/a.mov\n";
let mut probe_tc = |path: &str| probe(path).map(|f| Footage { file_timecode: Some(3600 * 25), ..f });
let mut p = Project::default();
let opts = ImportOptions { name: "cut.edl".into(), edl_frame_rate: Some(25.0), ..Default::default() };
let r = import(&mut p, edl.as_bytes(), None, &opts, &mut probe_tc).unwrap();
let c = p.comp(r.comps[0]).unwrap();
let l = layer(c, "a.mov");
assert!(near(l.layer_time(l.in_point), t(1.0)), "{}", l.layer_time(l.in_point).seconds());
assert!(near(l.start_time, t(-1.0)), "{}", l.start_time.seconds());
assert!(!r.warnings.iter().any(|w| w.contains("media time")), "the rebase is done: {:?}", r.warnings);
}
/// The reader's note to rebase after probing is replaced by the media that couldn't be rebased:
/// a file with no start timecode and a missing file. Media cut from 00:00:00:00 needs no rebase.
#[test]
fn edl_media_left_on_timecode_is_named() {
let event = |n: u32, reel: &str, ch: &str, src: (&str, &str), rec: (&str, &str), file: &str| {
format!("{n:03} {reel:<8} {ch:<5} C {} {} {} {}\n* FROM CLIP NAME: {file}\n* SOURCE FILE: /media/{file}\n\n", src.0, src.1, rec.0, rec.1)
};
let edl = [
"TITLE: CUT\nFCM: NON-DROP FRAME\n\n".to_string(),
event(1, "A", "V", ("01:00:01:00", "01:00:02:00"), ("01:00:00:00", "01:00:01:00"), "a.mov"),
event(2, "B", "V", ("01:00:01:00", "01:00:02:00"), ("01:00:01:00", "01:00:02:00"), "b.mov"),
event(3, "C", "V", ("01:00:01:00", "01:00:02:00"), ("01:00:02:00", "01:00:03:00"), "c.mov"),
event(4, "D", "A", ("00:00:00:00", "00:00:03:00"), ("01:00:00:00", "01:00:03:00"), "d.wav"),
]
.concat();
let mut probe_some = |path: &str| match path {
"/media/a.mov" => probe(path).map(|f| Footage { file_timecode: Some(3600 * 25), ..f }),
"/media/c.mov" => None,
_ => probe(path),
};
let mut p = Project::default();
let opts = ImportOptions { name: "cut.edl".into(), edl_frame_rate: Some(25.0), ..Default::default() };
let r = import(&mut p, edl.as_bytes(), None, &opts, &mut probe_some).unwrap();
let notes: Vec<&String> = r.warnings.iter().filter(|w| w.contains("media time")).collect();
assert_eq!(notes, ["EDL source timecodes stay media time for b.mov, c.mov: the file is missing or has no start timecode"], "{:?}", r.warnings);
}
/// A drop-frame list counts its hour in timecode: a clip cut at 01:00:00;00 (3599.996 s at 29.97
/// fps) from a missing file is still named.
#[test]
fn edl_drop_frame_media_left_on_timecode_is_named() {
let edl = "TITLE: CUT\nFCM: DROP FRAME\n\n001 C V C 01:00:00;00 01:00:01;00 01:00:00;00 01:00:01;00\n* FROM CLIP NAME: c.mov\n* SOURCE FILE: /media/c.mov\n";
let mut p = Project::default();
let opts = ImportOptions { name: "cut.edl".into(), edl_frame_rate: Some(29.97), ..Default::default() };
let r = import(&mut p, edl.as_bytes(), None, &opts, &mut |_: &str| None).unwrap();
let notes: Vec<&String> = r.warnings.iter().filter(|w| w.contains("media time")).collect();
assert_eq!(notes, ["EDL source timecodes stay media time for c.mov: the file is missing or has no start timecode"], "{:?}", r.warnings);
}
#[test]
fn rejects_unknown_documents() {
let mut p = Project::default();
+13 -1
View File
@@ -135,6 +135,17 @@ pub fn import(
cx.fonts.push((n, fam, st));
}
}
// Glyph outlines embedded for players without the fonts. Text is set in installed fonts.
if let Some(chars) = doc.get("chars").and_then(Json::as_array).filter(|c| !c.is_empty()) {
let mut fams: Vec<&str> = vec![];
for fam in chars.iter().filter_map(|c| s(c, "fFamily")).filter(|f| !f.trim().is_empty()) {
if !fams.contains(&fam) {
fams.push(fam);
}
}
let of = if fams.is_empty() { String::new() } else { format!(" of {}", fams.join(", ")) };
cx.im.warn(format!("embedded glyphs (`chars`){of} are not imported; text uses the installed fonts"));
}
let (w, h) = (w.round().max(1.0) as u32, h.round().max(1.0) as u32);
let duration = tb.tick(op);
// Pass 1: asset items (precomps empty for now, images stored).
@@ -305,7 +316,8 @@ fn masks(im: &Im, ids: &mut Ids, list: &[Json], g: &mut PropGroup) {
import_prop(im, j, p, conv);
}
};
set("pt", "path", &shapes::to_path);
let closed = m.get("cl").and_then(Json::as_bool).unwrap_or(false);
set("pt", "path", &|v| shapes::to_path(v, closed));
set("o", "opacity", &anim::to_scalar);
set("x", "expansion", &anim::to_scalar);
set("f", "feather", &anim::to_vec2);
+6 -3
View File
@@ -27,7 +27,9 @@ pub(crate) fn path_value(v: &Value) -> Json {
}
}
pub(crate) fn to_path(j: &Json) -> Option<Value> {
/// A Lottie path. `closed` is used when the path has no `c`: Bodymovin 3.x and early 4.x keep the
/// flag outside the path, as `cl` on a mask and `closed` on an `sh` item.
pub(crate) fn to_path(j: &Json, closed: bool) -> Option<Value> {
let o = match j {
Json::Array(a) => a.first()?,
o => o,
@@ -48,7 +50,7 @@ pub(crate) fn to_path(j: &Json) -> Option<Value> {
in_tangents: fit(pts("i")),
out_tangents: fit(pts("o")),
vertices,
closed: o.get("c").and_then(Json::as_bool).unwrap_or(false),
closed: o.get("c").and_then(Json::as_bool).unwrap_or(closed),
feather: Vec::new(),
}))
}
@@ -493,7 +495,8 @@ pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec<Pr
"sh" => {
let mut g = build::shape_path(ids, ShapePath::default());
dir(&mut g);
set(im, &mut g, "path", j.get("ks"), &to_path);
let closed = j.get("closed").and_then(Json::as_bool).unwrap_or(false);
set(im, &mut g, "path", j.get("ks"), &|v| to_path(v, closed));
g
}
"fl" => {
+38
View File
@@ -520,6 +520,44 @@ fn zero_time_stretch_is_not_imported() {
assert!(imp.warnings.iter().any(|w| w.contains("time stretch")), "{:?}", imp.warnings);
}
#[test]
fn embedded_glyphs_are_reported_when_dropped() {
let glyph = |ch: &str, fam: &str| json!({"ch": ch, "size": 100, "style": "Regular", "w": 60, "fFamily": fam, "data": {"shapes": []}});
let doc = json!({"w": 20, "h": 20, "fr": 30, "ip": 0, "op": 30, "assets": [], "layers": [],
"fonts": {"list": [{"fName": "Sans-Regular", "fFamily": "Sans", "fStyle": "Regular"}]},
"chars": [glyph("A", "Sans"), glyph("B", ""), glyph("C", "Serif")]});
let warnings = |doc: &Json| import(&mut Project::default(), doc.to_string().as_bytes(), "Imported", "", &mut |_| None).unwrap().warnings;
// A blank family name is left out, not listed as "of , …".
assert_eq!(warnings(&doc), ["embedded glyphs (`chars`) of Sans, Serif are not imported; text uses the installed fonts"]);
let blank = json!({"w": 20, "h": 20, "fr": 30, "layers": [], "chars": [glyph("A", " ")]});
assert_eq!(warnings(&blank), ["embedded glyphs (`chars`) are not imported; text uses the installed fonts"]);
}
#[test]
fn legacy_closed_flags_close_masks_and_paths() {
// Bodymovin 3.x and early 4.x put the closed flag on the mask (`cl`) and on the `sh` item
// (`closed`), with no `c` in the path. An open mask would leave the solid whole.
let square = json!({"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[5, 5], [15, 5], [15, 15], [5, 15]]});
let doc = json!({"w": 20, "h": 20, "fr": 30, "ip": 0, "op": 30, "assets": [], "layers": [
{"ty": 4, "ind": 1, "ip": 0, "op": 30, "st": 0, "ks": {},
"shapes": [{"ty": "sh", "closed": true, "ks": {"k": [{"t": 0, "s": [square], "e": [square]}, {"t": 10}]}}]},
{"ty": 1, "ind": 2, "sc": "#ff0000", "sw": 20, "sh": 20, "ip": 0, "op": 30, "st": 0, "ks": {},
"masksProperties": [{"mode": "a", "cl": true, "pt": {"k": square}}]}]});
let mut q = Project::default();
let nc = import(&mut q, doc.to_string().as_bytes(), "Imported", "", &mut |_| None).unwrap().comp;
let comp = q.comp(nc).unwrap();
let closed = |v: &Value| matches!(v, Value::Path(sp) if sp.closed);
let shape = comp.layers[0].props.sub("contents").unwrap().groups().next().unwrap();
let keys = &shape.get("path").unwrap().keys;
assert_eq!(keys.len(), 2);
assert!(keys.iter().all(|k| closed(&k.value)));
let mask = comp.layers[1].masks().unwrap().groups().next().unwrap();
assert!(closed(&mask.get("path").unwrap().value));
let img = render_frame(&q, nc, s(0.0), 1.0);
assert_eq!(img.data[0][3], 0.0, "the mask cuts the corner away");
assert!(img.data[10 * 20 + 10][3] > 0.99);
}
#[test]
fn precomp_with_time_remap() {
let (mut p, cid) = setup();
+25 -1
View File
@@ -198,16 +198,21 @@ pub(crate) fn footage_from_info(path: &str, info: &filmcraft_media::MediaInfo) -
missing: false,
sequence: Vec::new(),
color_profile: v.and_then(|v| profile_of(&v.color)),
// counted in video frames, so only with a video stream
file_timecode: v.and(info.start_timecode),
..Default::default()
}
}
/// The colour profile a video stream declares (H.273 primaries / transfer from the bitstream or
/// container). `None` (interpreted as sRGB) for Rec. 709 primaries with an sRGB curve.
/// container). `None` (interpreted as sRGB) for Rec. 709 primaries with an sRGB curve. BT.2020
/// with a PQ or HLG curve is Rec. 2100 (HDR10, HLG), not Rec. 2020's 2.4 gamma (#2849).
fn profile_of(c: &filmcraft_color::ColorInfo) -> Option<effectcraft_project::ColorSpace> {
use effectcraft_project::ColorSpace;
use filmcraft_color::{Primaries, Transfer};
match (c.primaries, c.transfer) {
(Primaries::Bt2020, Transfer::Pq) => Some(ColorSpace::Rec2100Pq),
(Primaries::Bt2020, Transfer::Hlg) => Some(ColorSpace::Rec2100Hlg),
(Primaries::Bt2020, _) => Some(ColorSpace::Rec2020),
(Primaries::P3D65, _) => Some(ColorSpace::DisplayP3),
(_, Transfer::Bt709) => Some(ColorSpace::Rec709),
@@ -383,6 +388,25 @@ mod tests {
(0..n).map(|k| (at(k + 1) - at(k)) as u32).collect()
}
/// #2849: a stream's primaries and curve pick its profile; BT.2020 with PQ or HLG is Rec. 2100.
#[test]
fn stream_colour_picks_the_profile() {
use effectcraft_project::ColorSpace;
use filmcraft_color::{Primaries, Transfer};
let rows = [
(Primaries::Bt2020, Transfer::Pq, Some(ColorSpace::Rec2100Pq)),
(Primaries::Bt2020, Transfer::Hlg, Some(ColorSpace::Rec2100Hlg)),
(Primaries::Bt2020, Transfer::Bt709, Some(ColorSpace::Rec2020)),
(Primaries::P3D65, Transfer::Srgb, Some(ColorSpace::DisplayP3)),
(Primaries::Bt709, Transfer::Bt709, Some(ColorSpace::Rec709)),
(Primaries::Bt709, Transfer::Srgb, None),
];
for (primaries, transfer, want) in rows {
let c = filmcraft_color::ColorInfo { primaries, transfer, ..filmcraft_color::ColorInfo::REC709 };
assert_eq!(profile_of(&c), want, "{primaries:?} + {transfer:?}");
}
}
/// A model's side files are read from beside it; a URI to another computer is not followed.
#[test]
fn model_side_files_are_not_read_from_other_computers() {
+38
View File
@@ -0,0 +1,38 @@
//! The start timecode a movie records is read into its footage (`Footage::file_timecode`).
use std::io::Cursor;
use effectcraft_project::FootageKind;
use filmcraft_isobmff::{Brand, Mp4Writer, SampleEntry, TimecodeConfig, TrackConfig, WriteSample, WriterOptions};
/// A two-frame 24 fps Motion JPEG MOV, with a timecode track from `start_frame` when given.
fn mov(start_frame: Option<u32>) -> Vec<u8> {
let mut jpeg = Vec::new();
image::RgbImage::from_pixel(16, 16, image::Rgb([200, 40, 90])).write_to(&mut Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).expect("jpeg");
let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mov)).expect("writer");
let v = w.add_track(TrackConfig::new(SampleEntry::jpeg(16, 16), 24)).expect("video track");
if let Some(start) = start_frame {
let tc = TimecodeConfig { timescale: 24, frame_duration: 1, frames_per_second: 24, ..Default::default() };
w.add_timecode_track(v, tc, start).expect("timecode track");
}
for _ in 0..2 {
w.write_sample(v, WriteSample { data: &jpeg, duration: 1, composition_offset: 0, is_sync: true }).expect("frame");
}
w.finish().expect("finish").into_inner()
}
#[test]
fn a_movies_start_timecode_is_read() {
let dir = std::env::temp_dir().join(format!("effectcraft-file-timecode-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("mkdir");
// 10:00:00:00 at 24 fps, and a movie with no timecode
for (name, start) in [("tc.mov", Some(10 * 3600 * 24)), ("none.mov", None)] {
let p = dir.join(name);
std::fs::write(&p, mov(start)).expect("write");
let f = effectcraft_media::probe(&p).expect("probe");
assert_eq!(f.kind, FootageKind::Video, "{name}");
assert_eq!(f.file_timecode, start.map(i64::from), "{name}");
assert_eq!(f.start_timecode, None, "{name}: Interpret Footage still uses the source file timecode");
}
let _ = std::fs::remove_dir_all(&dir);
}
+1
View File
@@ -3,6 +3,7 @@
mod aac_layout;
mod downmix;
mod exif_orientation;
mod file_timecode;
mod footage;
mod open_in_place;
mod tga;
Loaded 100 of 190 files, more files were not shown because too many files have changed in this diff. Show more