mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-29 12:37:08 -06:00
Merge main into #2550; keep both sides of contributors/people.toml
This commit is contained in:
Generated
+48
-36
@@ -1086,6 +1086,17 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-quit"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"dispatch2",
|
||||
"egui",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
@@ -1329,7 +1340,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"craft-update",
|
||||
@@ -1355,7 +1366,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-automation"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-host",
|
||||
@@ -1367,14 +1378,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-av1enc"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"filmcraft-av1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-cli"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"cpu-time",
|
||||
"effectcraft-automation",
|
||||
@@ -1390,7 +1401,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-color"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1398,7 +1409,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-effects"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"effectcraft-color",
|
||||
@@ -1416,7 +1427,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-engine"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-docpath",
|
||||
@@ -1451,7 +1462,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-export"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-av1enc",
|
||||
"effectcraft-color",
|
||||
@@ -1481,7 +1492,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-expr"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-color",
|
||||
@@ -1498,7 +1509,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-geom"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1506,7 +1517,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-gpu"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"effectcraft-color",
|
||||
@@ -1528,14 +1539,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-hevcenc"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"filmcraft-hevc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-host"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
@@ -1560,7 +1571,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-interchange"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-keyframe",
|
||||
@@ -1579,7 +1590,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-keyframe"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
@@ -1591,7 +1602,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-lottie"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"effectcraft-color",
|
||||
@@ -1609,7 +1620,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-media"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"effectcraft-model",
|
||||
@@ -1638,7 +1649,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-model"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"image",
|
||||
@@ -1650,14 +1661,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-opusenc"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"filmcraft-opus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-path"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"effectcraft-keyframe",
|
||||
@@ -1669,7 +1680,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-pdf"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"effectcraft-raster",
|
||||
@@ -1685,7 +1696,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-plugin"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-effects",
|
||||
"effectcraft-keyframe",
|
||||
@@ -1697,7 +1708,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-project"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
@@ -1710,14 +1721,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-psd"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.9.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-raster"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
@@ -1726,7 +1737,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-render"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
@@ -1748,7 +1759,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-script"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-engine",
|
||||
@@ -1766,7 +1777,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-segment"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.9.1",
|
||||
"ndarray",
|
||||
@@ -1775,7 +1786,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-svg"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"effectcraft-path",
|
||||
@@ -1787,7 +1798,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-text"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-keyframe",
|
||||
"harfrust",
|
||||
@@ -1799,7 +1810,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-time"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1807,7 +1818,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-track"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-raster",
|
||||
"effectcraft-segment",
|
||||
@@ -1819,9 +1830,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-ui-egui"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-quit",
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
"effectcraft-color",
|
||||
@@ -1853,11 +1865,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-vp9enc"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-web"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
@@ -7183,7 +7195,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde_json",
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -13,6 +13,7 @@ repository = "https://github.com/storytold/effectcraft"
|
||||
[workspace.dependencies]
|
||||
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-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
|
||||
# internal
|
||||
effectcraft-time = { path = "crates/time" }
|
||||
|
||||
@@ -279,6 +279,9 @@ fn utf8_args(raw: impl IntoIterator<Item = std::ffi::OsString>) -> Result<Vec<St
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Warnings and errors to standard error (`RUST_LOG` overrides), so a GPU that declines
|
||||
// frames or runs out of memory says so instead of silently rendering on the CPU.
|
||||
effectcraft_engine::logging::install_with("warn");
|
||||
let raw = match utf8_args(std::env::args_os().skip(1)) {
|
||||
Ok(raw) => raw,
|
||||
Err(e) => fail_usage(&e),
|
||||
|
||||
@@ -7,6 +7,23 @@
|
||||
//
|
||||
// Plain functions only: the Rust side owns the logic.
|
||||
|
||||
// ------------------------------------------------------------------ shortcuts
|
||||
|
||||
/// Keep Duplicate on the app canvas instead of also opening the browser's bookmarks.
|
||||
/// The physical D key counts on layouts where it types no ASCII letter (e.g. Cyrillic), because
|
||||
/// eframe falls back to the key code there; it does not on Dvorak, where it types E.
|
||||
/// eframe still receives the key; other chords and HTML inputs keep their browser behavior.
|
||||
export function installCanvasShortcuts(canvas) {
|
||||
canvas.addEventListener("keydown", (event) => {
|
||||
if (event.target === canvas && (event.ctrlKey || event.metaKey)
|
||||
&& !event.shiftKey && !event.altKey && !event.isComposing
|
||||
&& (event.key.toLowerCase() === "d"
|
||||
|| (event.code === "KeyD" && !/^[a-z]$/i.test(event.key)))) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}, { capture: true });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ storage
|
||||
|
||||
const DIR = "effectcraft";
|
||||
|
||||
@@ -15,6 +15,8 @@ use wasm_bindgen::prelude::*;
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = showDeviceLost)]
|
||||
fn show_device_lost(message: &str, kept: bool);
|
||||
#[wasm_bindgen(js_name = installCanvasShortcuts)]
|
||||
fn install_canvas_shortcuts(canvas: &web_sys::HtmlCanvasElement);
|
||||
}
|
||||
|
||||
/// Work for the UI thread with access to the app (posted by async tasks: picked files…).
|
||||
@@ -204,6 +206,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
effectcraft_ui_egui::i18n::set_system_locales(query_value("lang"), browser.collect());
|
||||
let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
|
||||
let canvas: web_sys::HtmlCanvasElement = doc.get_element_by_id(&canvas_id).ok_or("no canvas")?.dyn_into()?;
|
||||
install_canvas_shortcuts(&canvas);
|
||||
let empty = query_flag("empty");
|
||||
let home = query_flag("home");
|
||||
// Browser storage: settings, recent projects, imported media, saved projects, auto-saves and
|
||||
|
||||
@@ -151,6 +151,10 @@ impl Services for WebServices {
|
||||
fn is_dir(&self, _path: &str) -> bool {
|
||||
false
|
||||
}
|
||||
/// The table's keys are the paths themselves.
|
||||
fn full_path(&self, path: &str) -> Option<String> {
|
||||
Some(path.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Media import from the table (the bytes are also handed to the media pool, which decodes them).
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
// - A lost WebGPU device: the page says so over the canvas and offers Save Project and Reload;
|
||||
// the reload writes pending changes to browser storage first, and a failed write is reported
|
||||
// instead of reloading (#225).
|
||||
// - Duplicate cancels the browser's bookmark action on the app canvas, while still delivering
|
||||
// the key to the app; other chords and HTML inputs keep their browser behavior (#2495).
|
||||
// Exits non-zero on failure.
|
||||
import assert from "node:assert/strict";
|
||||
import { dirname, join } from "node:path";
|
||||
@@ -58,6 +60,30 @@ globalThis.location = { reload: () => reloads++ };
|
||||
|
||||
const host = await import(pathToFileURL(join(here, "../js/host.js")));
|
||||
|
||||
// #2495: prevent the browser default without swallowing the app's Duplicate key.
|
||||
const canvas = new EventTarget();
|
||||
const input = new EventTarget();
|
||||
host.installCanvasShortcuts(canvas);
|
||||
let delivered = 0;
|
||||
canvas.addEventListener("keydown", () => delivered++);
|
||||
const key = (target, properties) => {
|
||||
const event = new Event("keydown", { cancelable: true });
|
||||
Object.assign(event, { key: "d", ...properties });
|
||||
target.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
};
|
||||
assert.equal(key(canvas, { ctrlKey: true }), true);
|
||||
assert.equal(key(canvas, { metaKey: true, key: "D" }), true);
|
||||
for (const properties of [{}, { ctrlKey: true, shiftKey: true }, { ctrlKey: true, altKey: true }, { ctrlKey: true, isComposing: true }, { ctrlKey: true, key: "r" }]) {
|
||||
assert.equal(key(canvas, properties), false);
|
||||
}
|
||||
// Non-Latin layouts: the physical D key types "в", and eframe still reads it as D.
|
||||
assert.equal(key(canvas, { ctrlKey: true, key: "в", code: "KeyD" }), true);
|
||||
// Dvorak: the physical D key types "e", which is not Duplicate.
|
||||
assert.equal(key(canvas, { ctrlKey: true, key: "e", code: "KeyD" }), false);
|
||||
assert.equal(delivered, 9, "all keys still reach the app");
|
||||
assert.equal(key(input, { ctrlKey: true }), false, "an external HTML input keeps its shortcut");
|
||||
|
||||
// #226: one readable and one unreadable file.
|
||||
const file = (name, read) => ({ name, webkitRelativePath: "", arrayBuffer: read });
|
||||
const picked = host.browsePickFiles(false);
|
||||
|
||||
@@ -365,14 +365,11 @@ fn main() -> eframe::Result {
|
||||
}))
|
||||
}),
|
||||
);
|
||||
match &result {
|
||||
Ok(()) => log::info!("the window closed"),
|
||||
Err(e) => {
|
||||
if on_gl {
|
||||
launch_guard::opengl_failed(effectcraft_host::config_dir().as_deref());
|
||||
}
|
||||
startup_failed(e, !on_gl && cfg!(not(target_os = "macos")));
|
||||
if let Err(e) = &result {
|
||||
if on_gl {
|
||||
launch_guard::opengl_failed(effectcraft_host::config_dir().as_deref());
|
||||
}
|
||||
startup_failed(e, !on_gl && cfg!(not(target_os = "macos")));
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -381,9 +378,15 @@ fn main() -> eframe::Result {
|
||||
/// instead of eframe's fixed set: an older GPU that can't meet those (Intel HD Graphics 5500,
|
||||
/// #198) still opens the app, and the compositor then checks what it needs and falls back to
|
||||
/// the CPU when the device can't run it. `backends`: Window Graphics' choice, when it makes one.
|
||||
///
|
||||
/// The surface is eframe's own default, [`eframe::SurfaceConfig::LOW_LATENCY`]: vsync with one
|
||||
/// frame in flight, so a drag or a scrub shows one frame sooner. The bare
|
||||
/// `WgpuConfiguration::default()` is `HIGH_THROUGHPUT` instead (two frames in flight, three
|
||||
/// drawables on Metal): one more frame of pointer-to-screen lag on every interaction, about 8 ms
|
||||
/// at 120 Hz and 17 ms at 60 Hz, and a third 15 MB drawable held for the window.
|
||||
fn wgpu_options(backends: Option<eframe::wgpu::Backends>) -> eframe::WgpuConfiguration {
|
||||
use eframe::egui_wgpu::WgpuSetup;
|
||||
let mut config = eframe::WgpuConfiguration::default();
|
||||
let mut config = eframe::WgpuConfiguration::default().with_surface_config(eframe::SurfaceConfig::LOW_LATENCY);
|
||||
if let WgpuSetup::CreateNew(setup) = &mut config.wgpu_setup {
|
||||
if let Some(b) = backends {
|
||||
setup.instance_descriptor.backends = b;
|
||||
@@ -455,7 +458,8 @@ struct Desktop {
|
||||
}
|
||||
|
||||
impl eframe::App for Desktop {
|
||||
/// The desktop host's logic tick: update the macOS menu bar, run the shared app tick and close the IME pass.
|
||||
/// The desktop host's logic tick: update the macOS menu bar, run the shared app tick, close the IME pass, show the
|
||||
/// file dialogs asked for and, last, hand a close to the platform quit.
|
||||
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
|
||||
#[cfg(target_os = "macos")]
|
||||
self.menu.update(&mut self.app, ctx);
|
||||
@@ -464,6 +468,7 @@ impl eframe::App for Desktop {
|
||||
if let Some(asked) = &self.file_dialogs {
|
||||
sheet_dialogs::show_asked(asked, frame, ctx);
|
||||
}
|
||||
effectcraft_ui_egui::panels::unsaved::quit_on_close(ctx);
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
@@ -484,6 +489,9 @@ impl eframe::App for Desktop {
|
||||
}
|
||||
|
||||
fn on_exit(&mut self) {
|
||||
// On macOS the app quits through `-[NSApplication terminate:]`, and the process ends
|
||||
// before `eframe::run_native` returns (#341).
|
||||
log::info!("the window is closing");
|
||||
self.launch.exited();
|
||||
self.window.save(self.app.session.config.as_deref());
|
||||
self.app.on_exit();
|
||||
@@ -565,6 +573,19 @@ mod tests {
|
||||
assert!(!setup.instance_descriptor.flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));
|
||||
}
|
||||
|
||||
/// The window's surface keeps one frame in flight, like eframe's own default, not the two
|
||||
/// of the bare `WgpuConfiguration::default()` (one extra frame of lag on every drag).
|
||||
#[test]
|
||||
fn the_window_surface_is_low_latency() {
|
||||
for backends in [None, Some(eframe::wgpu::Backends::METAL), Some(eframe::wgpu::Backends::GL)] {
|
||||
let surface = super::wgpu_options(backends).surface;
|
||||
assert_eq!(surface, eframe::SurfaceConfig::LOW_LATENCY, "{backends:?}");
|
||||
assert_eq!(surface.desired_maximum_frame_latency, Some(1));
|
||||
assert_eq!(surface.present_mode, eframe::wgpu::PresentMode::AutoVsync);
|
||||
}
|
||||
assert_eq!(super::wgpu_options(None).surface, eframe::NativeOptions::default().wgpu_options.surface, "eframe's default surface");
|
||||
}
|
||||
|
||||
/// The window's device never asks for more than the adapter offers (#198).
|
||||
#[test]
|
||||
fn device_limits_fit_the_adapter() {
|
||||
|
||||
@@ -99,5 +99,10 @@ sync_github_name = false
|
||||
[people.ki2pixel]
|
||||
display_name = "ki2pixel"
|
||||
|
||||
[people.becauseno7]
|
||||
real_name = "Shuvra Basak"
|
||||
display_name = "Shuvra Basak"
|
||||
sync_github_name = false
|
||||
|
||||
[people.ashokram-si]
|
||||
real_name = "Ashok Ram"
|
||||
|
||||
@@ -491,6 +491,8 @@ fn bridge_forwards_to_control_channel() {
|
||||
|
||||
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "B", "width": 64, "height": 64}}));
|
||||
assert_eq!(call_json(&mut s, "get_comp", json!({}))["name"], "B");
|
||||
let cmds = call_json(&mut s, "list_commands", json!({"filter": "layer.newSolid", "compact": true}));
|
||||
assert!(cmds.as_array().unwrap().contains(&json!({"id": "layer.newSolid", "label": "Solid..."})), "{cmds}");
|
||||
|
||||
let (c, err) = call(&mut s, "render_frame", json!({}));
|
||||
assert!(!err, "{c:?}");
|
||||
@@ -738,6 +740,40 @@ fn every_engine_command_is_reachable_over_mcp() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `compact: true` shrinks the listings for MCP clients' tool-result limits (#2020): commands with
|
||||
/// only `id` and `label`, effects with only `id`, `name` and `category`.
|
||||
#[test]
|
||||
fn compact_listings_keep_only_ids_and_names() {
|
||||
let mut s = server();
|
||||
let cmds = call_json(&mut s, "list_commands", json!({"filter": "layer.new", "compact": true}));
|
||||
assert!(cmds.as_array().unwrap().contains(&json!({"id": "layer.newSolid", "label": "Solid..."})), "{cmds}");
|
||||
let fx = call_json(&mut s, "list_effects", json!({"filter": "gaussian", "compact": true}));
|
||||
assert!(fx.as_array().unwrap().contains(&json!({"id": "ec.blur.gaussian", "name": "Gaussian Blur", "category": "Blur & Sharpen"})), "{fx}");
|
||||
// Each compact listing is the full one with the same arguments, entry for entry; `compact`
|
||||
// wins over `schemas`.
|
||||
let check = |s: &mut McpServer, tool: &str, args: Value, keys: &[&str]| {
|
||||
let full = call_json(s, tool, args.clone());
|
||||
let want: Value = full.as_array().unwrap().iter().map(|e| Value::Object(keys.iter().map(|&k| (k.to_string(), e[k].clone())).collect())).collect();
|
||||
let mut compact = args.clone();
|
||||
compact["compact"] = json!(true);
|
||||
assert_eq!(call_json(s, tool, compact), want, "{tool} {args}");
|
||||
};
|
||||
for args in [json!({}), json!({"filter": "layer.new"}), json!({"enabled_only": true}), json!({"schemas": true})] {
|
||||
check(&mut s, "list_commands", args.clone(), &["id", "label"]);
|
||||
check(&mut s, "command_list", args, &["id", "label"]);
|
||||
}
|
||||
check(&mut s, "list_effects", json!({}), &["id", "name", "category"]);
|
||||
check(&mut s, "list_effects", json!({"filter": "gaussian"}), &["id", "name", "category"]);
|
||||
// `compact: false` is the default listing, byte for byte; `compact: true` is a fraction of it.
|
||||
let text = |s: &mut McpServer, tool: &str, args: Value| call(s, tool, args).0[0]["text"].as_str().unwrap().to_string();
|
||||
for (tool, ratio) in [("list_commands", 2), ("list_effects", 10)] {
|
||||
let full = text(&mut s, tool, json!({}));
|
||||
assert_eq!(text(&mut s, tool, json!({"compact": false})), full, "{tool}");
|
||||
let compact = text(&mut s, tool, json!({"compact": true})).len();
|
||||
assert!(compact * ratio < full.len(), "{tool}: {compact} of {} bytes", full.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_level_tools_add_effect_and_report_state() {
|
||||
let mut s = server();
|
||||
|
||||
@@ -175,16 +175,21 @@ fn params_of(v: Option<&Value>) -> Result<Value> {
|
||||
|
||||
// ------------------------------------------------------------------ engine tools
|
||||
|
||||
/// `list_commands` tool: the engine command list, minus empty fields; with `compact`, only `id` and `label`.
|
||||
fn list_commands(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
let schemas = get(a, "schemas").and_then(Value::as_bool).unwrap_or(false).then_some(json!(true));
|
||||
let compact = get(a, "compact").and_then(Value::as_bool).unwrap_or(false);
|
||||
let schemas = (!compact && get(a, "schemas").and_then(Value::as_bool).unwrap_or(false)).then_some(json!(true));
|
||||
let r = b.exec("command.list", obj(&[("filter", get(a, "filter")), ("enabledOnly", get(a, "enabled_only")), ("schemas", schemas.as_ref())]))?;
|
||||
// Drop empty fields to keep the listing compact.
|
||||
let list: Vec<Value> = r
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|mut c| {
|
||||
if compact {
|
||||
return json!({"id": c["id"], "label": c["label"]});
|
||||
}
|
||||
// Drop empty fields.
|
||||
if let Some(o) = c.as_object_mut() {
|
||||
o.retain(|_, v| !(v.is_null() || v.as_array().is_some_and(Vec::is_empty) || v.as_str() == Some("{}")));
|
||||
}
|
||||
@@ -205,8 +210,13 @@ fn describe_command(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
json_reply(b.exec("command.describe", json!({"command": id}))?)
|
||||
}
|
||||
|
||||
/// `list_effects` tool: the effect registry; with `compact`, only `id`, `name` and `category`.
|
||||
fn list_effects(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
json_reply(b.exec("effect.list", obj(&[("filter", get(a, "filter"))]))?)
|
||||
let mut r = b.exec("effect.list", obj(&[("filter", get(a, "filter"))]))?;
|
||||
if get(a, "compact").and_then(Value::as_bool).unwrap_or(false) {
|
||||
r = r.as_array().into_iter().flatten().map(|e| json!({"id": e["id"], "name": e["name"], "category": e["category"]})).collect();
|
||||
}
|
||||
json_reply(r)
|
||||
}
|
||||
|
||||
fn list_fonts(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
@@ -542,14 +552,15 @@ fn control(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
static TOOLS: &[ToolDef] = &[
|
||||
ToolDef {
|
||||
name: "list_commands",
|
||||
description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`).",
|
||||
description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`); `compact: true` lists only ids and labels.",
|
||||
bridge_only: false,
|
||||
schema: || {
|
||||
schema(
|
||||
json!({
|
||||
"filter": {"type": "string", "description": "Only commands whose id or label contains this text (case-insensitive)."},
|
||||
"enabled_only": {"type": "boolean", "description": "Only commands that can run right now."},
|
||||
"schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."}
|
||||
"schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."},
|
||||
"compact": {"type": "boolean", "description": "Return only each command's `id` and `label` (overrides `schemas`)."}
|
||||
}),
|
||||
&[],
|
||||
)
|
||||
@@ -696,9 +707,17 @@ static TOOLS: &[ToolDef] = &[
|
||||
},
|
||||
ToolDef {
|
||||
name: "list_effects",
|
||||
description: "The effect registry: id, After Effects name, category, GPU and 32-bpc support. Narrow with `filter` (id, name or category text). Apply with add_effect.",
|
||||
description: "The effect registry: id, After Effects name, category, GPU and 32-bpc support. Narrow with `filter` (id, name or category text); `compact: true` lists only ids, names and categories. Apply with add_effect.",
|
||||
bridge_only: false,
|
||||
schema: || schema(json!({"filter": {"type": "string", "description": "Only effects whose id, name or category contains this text."}}), &[]),
|
||||
schema: || {
|
||||
schema(
|
||||
json!({
|
||||
"filter": {"type": "string", "description": "Only effects whose id, name or category contains this text."},
|
||||
"compact": {"type": "boolean", "description": "Return only each effect's `id`, `name` and `category`."}
|
||||
}),
|
||||
&[],
|
||||
)
|
||||
},
|
||||
run: list_effects,
|
||||
},
|
||||
ToolDef {
|
||||
@@ -911,14 +930,15 @@ static TOOLS: &[ToolDef] = &[
|
||||
},
|
||||
ToolDef {
|
||||
name: "command_list",
|
||||
description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`).",
|
||||
description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`); `compact: true` lists only ids and labels.",
|
||||
bridge_only: false,
|
||||
schema: || {
|
||||
schema(
|
||||
json!({
|
||||
"filter": {"type": "string", "description": "Only commands whose id or label contains this text (case-insensitive)."},
|
||||
"enabled_only": {"type": "boolean", "description": "Only commands that can run right now."},
|
||||
"schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."}
|
||||
"schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."},
|
||||
"compact": {"type": "boolean", "description": "Return only each command's `id` and `label` (overrides `schemas`)."}
|
||||
}),
|
||||
&[],
|
||||
)
|
||||
|
||||
+94
-43
@@ -3,7 +3,7 @@
|
||||
//! processes, so the reconstruction built here is exactly what a decoder produces; the syntax
|
||||
//! is written afterwards by `writer.rs`.
|
||||
|
||||
use crate::pred::{INTRA_MODES_USED, InterBlock, IntraEdge, Plane, edges, predict_inter, predict_intra, subpel_plane};
|
||||
use crate::pred::{INTRA_MODES_USED, InterBlock, IntraEdge, Plane, edges, half_planes, predict_inter, predict_intra};
|
||||
use crate::tables::{
|
||||
AC_QLOOKUP, BLOCK_8X8, BLOCK_64X64, DC_PRED, DC_QLOOKUP, DCT_DCT, DEFAULT_SCAN_4X4, DEFAULT_SCAN_8X8, DEFAULT_SCAN_16X16, DEFAULT_SCAN_32X32, MODE_TO_TXFM,
|
||||
NEWMV, NUM_4X4_BLOCKS_WIDE, PARTITION_SPLIT, PARTITION_SUBSIZE, TX_4X4, TX_8X8, TX_16X16, TX_32X32, TX_64X64, TX_SET_DCTONLY, TX_SET_INTER_3,
|
||||
@@ -122,6 +122,38 @@ struct TxResult {
|
||||
bits: f64,
|
||||
}
|
||||
|
||||
/// The output and scratch buffers of one reconstruction, each `n * n` entries long.
|
||||
struct ReconBuffers<'a> {
|
||||
deq: &'a mut [i32],
|
||||
resid: &'a mut [i32],
|
||||
recon: &'a mut [u16],
|
||||
}
|
||||
|
||||
/// Outcome of trying one transform block; the levels and the reconstruction stay in `TxScratch`.
|
||||
#[derive(Clone, Copy)]
|
||||
struct TxEval {
|
||||
nonzero: bool,
|
||||
dist: f64,
|
||||
bits: f64,
|
||||
}
|
||||
|
||||
/// Buffers of the transform path, sized for the largest coded transform block (32x32). Only the
|
||||
/// first `n * n` entries are used; they are reused across candidates instead of being allocated.
|
||||
struct TxScratch {
|
||||
res: [i32; 1024],
|
||||
coef: [f32; 1024],
|
||||
levels: [i32; 1024],
|
||||
deq: [i32; 1024],
|
||||
resid: [i32; 1024],
|
||||
recon: [u16; 1024],
|
||||
}
|
||||
|
||||
impl Default for TxScratch {
|
||||
fn default() -> Self {
|
||||
TxScratch { res: [0; 1024], coef: [0.0; 1024], levels: [0; 1024], deq: [0; 1024], resid: [0; 1024], recon: [0; 1024] }
|
||||
}
|
||||
}
|
||||
|
||||
/// Saved encoder state of a block area (reconstruction, BlockDecoded flags).
|
||||
struct Snapshot {
|
||||
planes: [Vec<u16>; 3],
|
||||
@@ -146,6 +178,8 @@ pub(crate) struct Decider<'a> {
|
||||
lambda_sad: f64,
|
||||
/// Half-sample interpolations of the reference luma (visible area): (8, 0), (0, 8), (8, 8).
|
||||
half: [Vec<u16>; 3],
|
||||
/// Transform buffers, taken out while a block is being tried (see `code_tx`).
|
||||
scratch: Option<Box<TxScratch>>,
|
||||
}
|
||||
|
||||
const MODE_BITS_INTRA: f64 = 3.0;
|
||||
@@ -178,9 +212,10 @@ impl<'a> Decider<'a> {
|
||||
lambda,
|
||||
lambda_sad: lambda.sqrt(),
|
||||
half: match refr {
|
||||
Some(rf) => [(8, 0), (0, 8), (8, 8)].map(|(fx, fy)| subpel_plane(&rf.planes[0], g.width, g.height, fx, fy, bd)),
|
||||
Some(rf) => half_planes(&rf.planes[0], g.width, g.height, bd),
|
||||
None => Default::default(),
|
||||
},
|
||||
scratch: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,8 +374,10 @@ impl<'a> Decider<'a> {
|
||||
let log2 = (n4 * 4).trailing_zeros();
|
||||
let (sbr, sbc) = ((r & 15) as isize, (c & 15) as isize);
|
||||
let bd = self.bd;
|
||||
let mut pred = vec![0u16; 32 * 32];
|
||||
let mut best_pred = vec![0u16; 32 * 32];
|
||||
let mut pred = [0u16; 32 * 32];
|
||||
let mut best_pred = [0u16; 32 * 32];
|
||||
let mut best_levels = [0i32; 32 * 32];
|
||||
let mut best_recon = [0u16; 32 * 32];
|
||||
let n = 1usize << log2;
|
||||
// luma
|
||||
let e = IntraEdge {
|
||||
@@ -358,26 +395,26 @@ impl<'a> Decider<'a> {
|
||||
let ed = edges(&self.rec[0], &e);
|
||||
// modes ranked by SATD, then a rate-distortion choice of mode and transform type among
|
||||
// the best three
|
||||
let mut ranked: Vec<(f64, usize)> = INTRA_MODES_USED
|
||||
.iter()
|
||||
.map(|&mode| {
|
||||
predict_intra(&ed, &e, mode, &mut pred);
|
||||
let sad = self.satd(0, c * 4, r * 4, n, &pred[..n * n]) as f64;
|
||||
(sad + self.lambda_sad * if mode == DC_PRED { 1.0 } else { MODE_BITS_INTRA }, mode)
|
||||
})
|
||||
.collect();
|
||||
let mut ranked: [(f64, usize); INTRA_MODES_USED.len()] = INTRA_MODES_USED.map(|mode| {
|
||||
predict_intra(&ed, &e, mode, &mut pred);
|
||||
let sad = self.satd(0, c * 4, r * 4, n, &pred[..n * n]) as f64;
|
||||
(sad + self.lambda_sad * if mode == DC_PRED { 1.0 } else { MODE_BITS_INTRA }, mode)
|
||||
});
|
||||
ranked.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let tx_y = tx_size_for(log2);
|
||||
let mut best: Option<(f64, usize, usize, TxResult, Option<Vec<u16>>)> = None;
|
||||
let mut best: Option<(f64, usize, usize, TxEval, bool)> = None;
|
||||
let mut sc = self.scratch.take().unwrap_or_default();
|
||||
for &(_, mode) in ranked.iter().take(3) {
|
||||
predict_intra(&ed, &e, mode, &mut pred);
|
||||
let mut types = vec![DCT_DCT];
|
||||
let mut types = [DCT_DCT; 2];
|
||||
let mut n_types = 1;
|
||||
let alt = MODE_TO_TXFM[mode] as usize;
|
||||
if alt != DCT_DCT && tx_in_set(tx_set(tx_y, false), false, alt) {
|
||||
types.push(alt);
|
||||
types[1] = alt;
|
||||
n_types = 2;
|
||||
}
|
||||
for &t in &types {
|
||||
let (res, recon) = self.try_tx(TransformBlock { plane: 0, x: c * 4, y: r * 4, log2, tx_type: t, intra: true }, &pred[..n * n]);
|
||||
for &t in &types[..n_types] {
|
||||
let (res, has_recon) = self.try_tx(&mut sc, TransformBlock { plane: 0, x: c * 4, y: r * 4, log2, tx_type: t, intra: true }, &pred[..n * n]);
|
||||
let type_bits = if !res.nonzero || tx_set(tx_y, false) == TX_SET_DCTONLY {
|
||||
0.0
|
||||
} else if t == DCT_DCT {
|
||||
@@ -389,18 +426,24 @@ impl<'a> Decider<'a> {
|
||||
let cost = res.dist + self.lambda * (res.bits + type_bits + mode_bits);
|
||||
if best.as_ref().is_none_or(|b| cost < b.0) {
|
||||
best_pred[..n * n].copy_from_slice(&pred[..n * n]);
|
||||
best = Some((cost, mode, t, res, recon));
|
||||
best_levels[..n * n].copy_from_slice(&sc.levels[..n * n]);
|
||||
if has_recon {
|
||||
best_recon[..n * n].copy_from_slice(&sc.recon[..n * n]);
|
||||
}
|
||||
best = Some((cost, mode, t, res, has_recon));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.scratch = Some(sc);
|
||||
// Every ranked mode tries DCT_DCT, so `best` is always set; without a candidate the block
|
||||
// can't be coded intra (an infinite cost keeps it from being chosen).
|
||||
let Some((_, y_mode, tx_type_y, ty, recon)) = best else {
|
||||
let Some((_, y_mode, tx_type_y, ty, has_recon)) = best else {
|
||||
let leaf =
|
||||
Leaf { bsize, is_inter: false, y_mode: DC_PRED, uv_mode: DC_PRED, tx_type_y: DCT_DCT, mv: [0, 0], skip: true, coefs: Default::default() };
|
||||
return (leaf, f64::INFINITY);
|
||||
};
|
||||
self.put(0, c * 4, r * 4, n, recon.as_deref().unwrap_or(&best_pred[..n * n]));
|
||||
self.put(0, c * 4, r * 4, n, if has_recon { &best_recon[..n * n] } else { &best_pred[..n * n] });
|
||||
let ty = TxResult { levels: best_levels[..n * n].to_vec(), nonzero: ty.nonzero, dist: ty.dist, bits: ty.bits };
|
||||
let tx_type_y = if ty.nonzero { tx_type_y } else { DCT_DCT };
|
||||
self.mark_decoded(0, r, c, n4);
|
||||
// chroma (one mode for both planes)
|
||||
@@ -792,32 +835,36 @@ impl<'a> Decider<'a> {
|
||||
/// over the prediction `pred`; the reconstruction is written to `rec`.
|
||||
fn code_tx(&mut self, block: TransformBlock, pred: &[u16]) -> TxResult {
|
||||
let TransformBlock { plane: p, x, y, log2, .. } = block;
|
||||
let (res, recon) = self.try_tx(block, pred);
|
||||
let mut sc = self.scratch.take().unwrap_or_default();
|
||||
let (res, has_recon) = self.try_tx(&mut sc, block, pred);
|
||||
let n = 1usize << log2;
|
||||
self.put(p, x, y, n, recon.as_deref().unwrap_or(pred));
|
||||
res
|
||||
self.put(p, x, y, n, if has_recon { &sc.recon[..n * n] } else { pred });
|
||||
let levels = sc.levels[..n * n].to_vec();
|
||||
self.scratch = Some(sc);
|
||||
TxResult { levels, nonzero: res.nonzero, dist: res.dist, bits: res.bits }
|
||||
}
|
||||
|
||||
/// Transform and quantise one block; returns the result and its reconstruction (None: the
|
||||
/// prediction) without writing it.
|
||||
fn try_tx(&self, block: TransformBlock, pred: &[u16]) -> (TxResult, Option<Vec<u16>>) {
|
||||
/// Transform and quantise one block without writing it. The levels are left in `sc.levels`;
|
||||
/// the flag tells whether `sc.recon` holds the reconstruction (false: it is the prediction).
|
||||
fn try_tx(&self, sc: &mut TxScratch, block: TransformBlock, pred: &[u16]) -> (TxEval, bool) {
|
||||
let TransformBlock { plane: p, x, y, log2, tx_type, intra } = block;
|
||||
let n = 1usize << log2;
|
||||
let nn = n * n;
|
||||
let tx_sz = tx_size_for(log2);
|
||||
let bd = self.bd;
|
||||
let src = &self.src[p];
|
||||
let mut res = vec![0i32; n * n];
|
||||
let TxScratch { res, coef, levels, deq, resid, recon } = sc;
|
||||
let (res, coef, levels) = (&mut res[..nn], &mut coef[..nn], &mut levels[..nn]);
|
||||
for i in 0..n {
|
||||
let row = &src.row(y + i)[x..x + n];
|
||||
for j in 0..n {
|
||||
res[i * n + j] = row[j] as i32 - pred[i * n + j] as i32;
|
||||
}
|
||||
}
|
||||
let mut coef = vec![0f32; n * n];
|
||||
forward_2d(&res, &mut coef, n, tx_type);
|
||||
forward_2d(res, coef, n, tx_type);
|
||||
// dead-zone quantiser: rounding offset below one half
|
||||
let rnd = if intra { 0.38f32 } else { 0.30f32 };
|
||||
let mut levels = vec![0i32; n * n];
|
||||
levels.fill(0);
|
||||
let mut nonzero = false;
|
||||
for (k, (&cf, l)) in coef.iter().zip(levels.iter_mut()).enumerate() {
|
||||
let q = if k == 0 { self.dc_q } else { self.ac_q };
|
||||
@@ -829,23 +876,26 @@ impl<'a> Decider<'a> {
|
||||
}
|
||||
let zero_dist = self.sse(p, x, y, n, pred);
|
||||
if !nonzero {
|
||||
return (TxResult { levels, nonzero, dist: zero_dist, bits: 1.0 }, None);
|
||||
return (TxEval { nonzero, dist: zero_dist, bits: 1.0 }, false);
|
||||
}
|
||||
let recon = self.reconstruct(&levels, tx_sz, tx_type, pred, n, bd);
|
||||
let dist = self.sse(p, x, y, n, &recon);
|
||||
let bits = estimate_bits(&levels, tx_sz);
|
||||
let recon = &mut recon[..nn];
|
||||
self.reconstruct(levels, ReconBuffers { deq: &mut deq[..nn], resid: &mut resid[..nn], recon }, tx_sz, tx_type, pred, bd);
|
||||
let dist = self.sse(p, x, y, n, recon);
|
||||
let bits = estimate_bits(levels, tx_sz);
|
||||
if dist + self.lambda * bits >= zero_dist + self.lambda {
|
||||
levels.iter_mut().for_each(|l| *l = 0);
|
||||
return (TxResult { levels, nonzero: false, dist: zero_dist, bits: 1.0 }, None);
|
||||
levels.fill(0);
|
||||
return (TxEval { nonzero: false, dist: zero_dist, bits: 1.0 }, false);
|
||||
}
|
||||
(TxResult { levels, nonzero: true, dist, bits }, Some(recon))
|
||||
(TxEval { nonzero: true, dist, bits }, true)
|
||||
}
|
||||
|
||||
/// Dequantisation (7.12.3) and the inverse transform, added to the prediction.
|
||||
fn reconstruct(&self, levels: &[i32], tx_sz: usize, tx_type: usize, pred: &[u16], n: usize, bd: u32) -> Vec<u16> {
|
||||
/// Dequantisation (7.12.3) and the inverse transform, added to the prediction; the result is
|
||||
/// written to `recon`. `deq` and `resid` are scratch buffers of `n * n` entries.
|
||||
fn reconstruct(&self, levels: &[i32], bufs: ReconBuffers, tx_sz: usize, tx_type: usize, pred: &[u16], bd: u32) {
|
||||
let ReconBuffers { deq, resid, recon } = bufs;
|
||||
let denom: i64 = if tx_sz == TX_32X32 { 2 } else { 1 };
|
||||
let lim = 1i64 << (7 + bd);
|
||||
let mut deq = vec![0i32; n * n];
|
||||
deq.fill(0);
|
||||
for (k, (&l, d)) in levels.iter().zip(deq.iter_mut()).enumerate() {
|
||||
if l == 0 {
|
||||
continue;
|
||||
@@ -855,10 +905,11 @@ impl<'a> Decider<'a> {
|
||||
let mag = (dq.abs() & 0xFF_FFFF) / denom;
|
||||
*d = (if dq < 0 { -mag } else { mag }).clamp(-lim, lim - 1) as i32;
|
||||
}
|
||||
let mut resid = vec![0i32; n * n];
|
||||
inverse_2d(&deq, &mut resid, tx_sz, tx_type, bd);
|
||||
inverse_2d(deq, resid, tx_sz, tx_type, bd);
|
||||
let max = (1i32 << bd) - 1;
|
||||
pred.iter().zip(&resid).map(|(&p, &r)| (p as i32 + r).clamp(0, max) as u16).collect()
|
||||
for ((o, &p), &r) in recon.iter_mut().zip(pred).zip(resid.iter()) {
|
||||
*o = (p as i32 + r).clamp(0, max) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -409,11 +409,13 @@ impl Encoder {
|
||||
};
|
||||
let bd = self.cfg.bit_depth as u32;
|
||||
let mut cache: Vec<(u32, u64)> = vec![(0, sse(&rec[0]))];
|
||||
// one working copy of the luma plane for all candidate levels
|
||||
let mut planes = [rec[0].clone(), Plane::new(0, 0), Plane::new(0, 0)];
|
||||
let mut eval = |lvl: u32| -> u64 {
|
||||
if let Some(&(_, e)) = cache.iter().find(|(l, _)| *l == lvl) {
|
||||
return e;
|
||||
}
|
||||
let mut planes = [rec[0].clone(), Plane::new(0, 0), Plane::new(0, 0)];
|
||||
planes[0].data.copy_from_slice(&rec[0].data);
|
||||
loop_filter(&mut planes, info, [lvl; 4], bd, true, sb_row_step);
|
||||
let e = sse(&planes[0]);
|
||||
cache.push((lvl, e));
|
||||
|
||||
@@ -297,7 +297,8 @@ pub(crate) fn predict_inter(refp: &Plane, block: &InterBlock, pred: &mut [u16])
|
||||
|
||||
/// The visible `w x h` area of a reference plane interpolated at the sub-sample phase
|
||||
/// (`fx`, `fy`) (1/16 units) with the EIGHTTAP filter, as `predict_inter` computes it for
|
||||
/// blocks wider and taller than 4. Used for motion search.
|
||||
/// blocks wider and taller than 4. The reference for `half_planes`, which the encoder uses.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn subpel_plane(refp: &Plane, w: usize, h: usize, fx: usize, fy: usize, bit_depth: u32) -> Vec<u16> {
|
||||
let kh = &SUBPEL_FILTERS[EIGHTTAP][fx];
|
||||
let kv = &SUBPEL_FILTERS[EIGHTTAP][fy];
|
||||
@@ -327,6 +328,66 @@ pub(crate) fn subpel_plane(refp: &Plane, w: usize, h: usize, fx: usize, fy: usiz
|
||||
out
|
||||
}
|
||||
|
||||
/// The three half-sample planes (8, 0), (0, 8), (8, 8) of `subpel_plane` in one pass: the
|
||||
/// horizontal half-sample pass is shared by (8, 0) and (8, 8), and the phase-0 kernels reduce to
|
||||
/// exact shifts (Round2( 128 * v, 11 ) = Round2( v, 4 ), Round2( 16 * s, 11 ) = Round2( s, 7 )),
|
||||
/// so the result equals three `subpel_plane` calls bit for bit.
|
||||
pub(crate) fn half_planes(refp: &Plane, w: usize, h: usize, bit_depth: u32) -> [Vec<u16>; 3] {
|
||||
let k = &SUBPEL_FILTERS[EIGHTTAP][8];
|
||||
let max = (1i32 << bit_depth) - 1;
|
||||
let (lx, ly) = (w as i32 - 1, h as i32 - 1);
|
||||
// horizontal pass at phase 8: Round2( sum, 3 )
|
||||
let mut hp = vec![0i32; w * h];
|
||||
for y in 0..h {
|
||||
let row = &refp.row(y)[..w];
|
||||
let out = &mut hp[y * w..y * w + w];
|
||||
let tap = |x: i32| -> i32 {
|
||||
let mut s = 0i32;
|
||||
for t in 0..8 {
|
||||
s += k[t] as i32 * row[(x + t as i32 - 3).clamp(0, lx) as usize] as i32;
|
||||
}
|
||||
round2(s, 3)
|
||||
};
|
||||
if w >= 8 {
|
||||
for x in 0..3 {
|
||||
out[x] = tap(x as i32);
|
||||
}
|
||||
for (x, o) in out.iter_mut().enumerate().take(w - 4).skip(3) {
|
||||
let seg = &row[x - 3..x + 5];
|
||||
let mut s = 0i32;
|
||||
for t in 0..8 {
|
||||
s += k[t] as i32 * seg[t] as i32;
|
||||
}
|
||||
*o = round2(s, 3);
|
||||
}
|
||||
for x in w - 4..w {
|
||||
out[x] = tap(x as i32);
|
||||
}
|
||||
} else {
|
||||
for (x, o) in out.iter_mut().enumerate() {
|
||||
*o = tap(x as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
let p80: Vec<u16> = hp.iter().map(|&v| round2(v, 4).clamp(0, max) as u16).collect();
|
||||
let mut p08 = vec![0u16; w * h];
|
||||
let mut p88 = vec![0u16; w * h];
|
||||
for y in 0..h {
|
||||
let rows: [usize; 8] = std::array::from_fn(|t| (y as i32 + t as i32 - 3).clamp(0, ly) as usize);
|
||||
let (o08, o88) = (&mut p08[y * w..y * w + w], &mut p88[y * w..y * w + w]);
|
||||
for x in 0..w {
|
||||
let (mut a, mut b) = (0i32, 0i32);
|
||||
for t in 0..8 {
|
||||
a += k[t] as i32 * refp.data[rows[t] * refp.stride + x] as i32;
|
||||
b += k[t] as i32 * hp[rows[t] * w + x];
|
||||
}
|
||||
o08[x] = round2(a, 7).clamp(0, max) as u16;
|
||||
o88[x] = round2(b, 11).clamp(0, max) as u16;
|
||||
}
|
||||
}
|
||||
[p80, p08, p88]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -361,6 +422,20 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_planes_match_subpel_plane() {
|
||||
for (w, h) in [(37usize, 29usize), (5, 3), (1, 1), (64, 64)] {
|
||||
let mut p = Plane::new(64, 64);
|
||||
for (i, v) in p.data.iter_mut().enumerate() {
|
||||
*v = ((i * 7919) % 1024) as u16;
|
||||
}
|
||||
let hp = half_planes(&p, w, h, 10);
|
||||
for (i, (fx, fy)) in [(8, 0), (0, 8), (8, 8)].into_iter().enumerate() {
|
||||
assert_eq!(hp[i], subpel_plane(&p, w, h, fx, fy, 10), "{w}x{h} phase ({fx}, {fy})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_zero_filters_are_impulses() {
|
||||
for f in SUBPEL_FILTERS.iter() {
|
||||
|
||||
@@ -70,6 +70,8 @@ pub(crate) struct TileWriter<'a> {
|
||||
left_dc: [Vec<u8>; 3],
|
||||
/// LoopfilterTxSizes per plane (row stride mi_cols + 32).
|
||||
lf_tx: [Vec<u8>; 3],
|
||||
/// Scratch for the levels coded so far in `coeffs`.
|
||||
quant: Vec<i32>,
|
||||
// current block
|
||||
r: usize,
|
||||
c: usize,
|
||||
@@ -122,6 +124,7 @@ impl<'a> TileWriter<'a> {
|
||||
left_level: std::array::from_fn(|_| vec![0; g.mi_rows + 64]),
|
||||
left_dc: std::array::from_fn(|_| vec![0; g.mi_rows + 64]),
|
||||
lf_tx: std::array::from_fn(|_| vec![0; (g.mi_cols + 32) * (g.mi_rows + 32)]),
|
||||
quant: Vec::new(),
|
||||
r: 0,
|
||||
c: 0,
|
||||
bsize: 0,
|
||||
@@ -189,10 +192,10 @@ impl<'a> TileWriter<'a> {
|
||||
self.sw.symbol(partition, cdf);
|
||||
} else if has_cols || has_rows {
|
||||
assert!(split, "blocks crossing the frame edge are split");
|
||||
let pcdf: Vec<u16> = match bsl {
|
||||
2 => self.cdf.partition_w16[ctx].to_vec(),
|
||||
3 => self.cdf.partition_w32[ctx].to_vec(),
|
||||
_ => self.cdf.partition_w64[ctx].to_vec(),
|
||||
let pcdf: &[u16] = match bsl {
|
||||
2 => &self.cdf.partition_w16[ctx],
|
||||
3 => &self.cdf.partition_w32[ctx],
|
||||
_ => &self.cdf.partition_w64[ctx],
|
||||
};
|
||||
let pr = |k: usize| pcdf[k] as i32 - if k == 0 { 0 } else { pcdf[k - 1] as i32 };
|
||||
let psum = if has_cols {
|
||||
@@ -493,7 +496,9 @@ impl<'a> TileWriter<'a> {
|
||||
let height = TX_HEIGHT[tx_sz] as usize;
|
||||
let width = 1usize << bwl;
|
||||
// Quant[] as the decoder sees it while parsing: the levels coded so far (capped)
|
||||
let mut quant = vec![0i32; width * height];
|
||||
let mut quant = std::mem::take(&mut self.quant);
|
||||
quant.clear();
|
||||
quant.resize(width * height, 0);
|
||||
for c in (0..eob).rev() {
|
||||
let pos = scan[c] as usize;
|
||||
let a = levels[pos].unsigned_abs().min(15) as i32;
|
||||
@@ -518,6 +523,7 @@ impl<'a> TileWriter<'a> {
|
||||
}
|
||||
quant[pos] = a;
|
||||
}
|
||||
self.quant = quant;
|
||||
for c in 0..eob {
|
||||
let pos = scan[c] as usize;
|
||||
let v = levels[pos];
|
||||
|
||||
+17
-1
@@ -97,7 +97,9 @@ pub fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
|
||||
return (0.0, 0.0, l);
|
||||
}
|
||||
let d = max - min;
|
||||
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
|
||||
let denom = if l > 0.5 { 2.0 - max - min } else { max + min };
|
||||
// Over-range (HDR) or negative inputs can make the denominator zero or negative.
|
||||
let s = if denom > 0.0 { d / denom } else { 1.0 };
|
||||
let h = if max == r {
|
||||
(((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0).rem_euclid(1.0)
|
||||
} else if max == g {
|
||||
@@ -262,6 +264,20 @@ impl Label {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hsl_round_trip_is_finite_for_out_of_range_colours() {
|
||||
for (r, g, b) in [(1.5, 0.5, 0.5), (2.0, 0.1, 0.3), (-0.5, 0.2, 0.9)] {
|
||||
let (h, s, l) = rgb_to_hsl(r, g, b);
|
||||
assert!(h.is_finite() && s.is_finite() && l.is_finite(), "{h} {s} {l}");
|
||||
let (r2, g2, b2) = hsl_to_rgb(h, s, l);
|
||||
assert!(r2.is_finite() && g2.is_finite() && b2.is_finite(), "{r2} {g2} {b2}");
|
||||
}
|
||||
// A 32 bpc colour whose denominator stays positive still round-trips exactly.
|
||||
let (h, s, l) = rgb_to_hsl(1.2, 0.5, 0.5);
|
||||
let (r, g, b) = hsl_to_rgb(h, s, l);
|
||||
assert!((r - 1.2).abs() < 1e-5 && (g - 0.5).abs() < 1e-5 && (b - 0.5).abs() < 1e-5, "{r} {g} {b}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hsv_to_rgb_tiny_negative_hue_is_red() {
|
||||
let (r, g, b) = hsv_to_rgb(-1e-9, 1.0, 1.0);
|
||||
|
||||
+217
-34
@@ -275,27 +275,70 @@ pub fn transform_shutter(ctx: &EffectCtx) -> Option<(f64, f64, u32)> {
|
||||
(angle > 0.0).then_some((angle, phase, n.clamp(2, 64)))
|
||||
}
|
||||
|
||||
fn transform_matrix(pr: &crate::Params, b: &Buf) -> Mat3 {
|
||||
let anchor = b.to_px(pr.v2("anchor"));
|
||||
let pos = b.to_px(pr.v2("position"));
|
||||
/// The Transform effect's matrix in buffer pixels (`to_px` maps layer pixels into the buffer),
|
||||
/// as After Effects 2026 draws it (cases 723, 819, 820): `position + K · R · S · (p − anchor)`
|
||||
/// with the skew `K = rot(−axis) · shearY(tan skew) · rot(axis)` applied after the rotation.
|
||||
pub fn transform_matrix_px(pr: &crate::Params, to_px: impl Fn([f64; 2]) -> (f64, f64)) -> Mat3 {
|
||||
let anchor = to_px(pr.v2("anchor"));
|
||||
let pos = to_px(pr.v2("position"));
|
||||
let sh = pr.f("scaleHeight");
|
||||
let sw = if pr.b("uniform") { sh } else { pr.f("scaleWidth") };
|
||||
let axis = pr.f("skewAxis");
|
||||
let k = pr.f("skew").clamp(-85.0, 85.0).to_radians().tan();
|
||||
let k = if k.is_finite() { k } else { 0.0 };
|
||||
let skew = Mat3::rotate_deg(-axis) * Mat3([[1.0, 0.0, 0.0], [k, 1.0, 0.0], [0.0, 0.0, 1.0]]) * Mat3::rotate_deg(axis);
|
||||
Mat3::translate(vec2(pos.0, pos.1))
|
||||
* skew
|
||||
* Mat3::rotate_deg(pr.f("rotation"))
|
||||
* Mat3::skew_deg(pr.f("skew"), pr.f("skewAxis"))
|
||||
* Mat3::scale(vec2(sw / 100.0, sh / 100.0))
|
||||
* Mat3::translate(vec2(-anchor.0, -anchor.1))
|
||||
}
|
||||
|
||||
fn transform_matrix(pr: &crate::Params, b: &Buf) -> Mat3 {
|
||||
transform_matrix_px(pr, |p| b.to_px(p))
|
||||
}
|
||||
|
||||
/// The transparent margins (left, top, right, bottom) a `w` x `h` buffer needs so its warp by
|
||||
/// any of `ms` stays on it: After Effects draws Transform's result outside the layer's bounds
|
||||
/// (case 723). Each side grows by its own overshoot; the grown buffer is capped at 16 384 px a
|
||||
/// side and 16 Mpx in all (the margins shrink in proportion), so a layer moved far away costs
|
||||
/// a bounded allocation.
|
||||
pub fn transform_grow(ms: &[Mat3], w: u32, h: u32) -> [u32; 4] {
|
||||
const SIDE: f64 = 16_384.0;
|
||||
const AREA: f64 = 16_777_216.0;
|
||||
let (wf, hf) = (w as f64, h as f64);
|
||||
let mut m = [0.0f64; 4];
|
||||
for mat in ms {
|
||||
for (x, y) in [(0.0, 0.0), (wf, 0.0), (0.0, hf), (wf, hf)] {
|
||||
let q = mat.apply(vec2(x, y));
|
||||
if q.x.is_finite() && q.y.is_finite() {
|
||||
m = [m[0].max(-q.x), m[1].max(-q.y), m[2].max(q.x - wf), m[3].max(q.y - hf)];
|
||||
}
|
||||
}
|
||||
}
|
||||
let m = m.map(|v| v.ceil().max(0.0));
|
||||
let fit = |len: f64, a: f64, b: f64| -> f64 {
|
||||
let room = (SIDE - len).max(0.0);
|
||||
if a + b > room && a + b > 0.0 { room / (a + b) } else { 1.0 }
|
||||
};
|
||||
let kx = fit(wf, m[0], m[2]);
|
||||
let ky = fit(hf, m[1], m[3]);
|
||||
let area = |k: f64| (wf + (m[0] + m[2]) * kx * k) * (hf + (m[1] + m[3]) * ky * k);
|
||||
let k = if area(1.0) > AREA && area(1.0) > wf * hf {
|
||||
// Shrink both margins by the same factor until the area fits.
|
||||
let (mut lo, mut hi) = (0.0f64, 1.0f64);
|
||||
for _ in 0..40 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
if area(mid) > AREA { hi = mid } else { lo = mid }
|
||||
}
|
||||
lo
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
[m[0] * kx * k, m[1] * ky * k, m[2] * kx * k, m[3] * ky * k].map(|v| v.floor().clamp(0.0, SIDE) as u32)
|
||||
}
|
||||
|
||||
fn transform(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let anchor = b.to_px(ctx.params.v2("anchor"));
|
||||
let pos = b.to_px(ctx.params.v2("position"));
|
||||
let uniform = ctx.params.b("uniform");
|
||||
let sh = ctx.params.f("scaleHeight");
|
||||
let sw = if uniform { sh } else { ctx.params.f("scaleWidth") };
|
||||
let skew = ctx.params.f("skew");
|
||||
let skew_axis = ctx.params.f("skewAxis");
|
||||
let rot = ctx.params.f("rotation");
|
||||
let opacity = ctx.params.f("opacity") as f32 / 100.0;
|
||||
let sampling = if ctx.params.e("sampling") == 1 { effectcraft_raster::Sampling::Bicubic } else { effectcraft_raster::Sampling::Bilinear };
|
||||
// Motion blur: the transform at Samples instants across the shutter, averaged (the
|
||||
@@ -304,33 +347,35 @@ fn transform(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
&& let Some(host) = ctx.env.host
|
||||
{
|
||||
let fd = 1.0 / ctx.fps();
|
||||
let mut acc = Image::new(b.img.width, b.img.height);
|
||||
let mut count = 0.0f32;
|
||||
for k in 0..n {
|
||||
let t = ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd;
|
||||
let Some(pr) = host.params_at(t) else { continue };
|
||||
let mut one = Image::new(b.img.width, b.img.height);
|
||||
let op = pr.f("opacity") as f32 / 100.0;
|
||||
effectcraft_raster::composite_warp(
|
||||
&mut one,
|
||||
&b.img,
|
||||
&transform_matrix(&pr, &b),
|
||||
&effectcraft_raster::WarpOpts { opacity: op, sampling, ..Default::default() },
|
||||
);
|
||||
acc.data.iter_mut().zip(&one.data).for_each(|(a, o)| (0..4).for_each(|c| a[c] += o[c]));
|
||||
count += 1.0;
|
||||
}
|
||||
if count > 0.0 {
|
||||
let samples: Vec<crate::Params> = (0..n).filter_map(|k| host.params_at(ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd)).collect();
|
||||
if !samples.is_empty() {
|
||||
if !ctx.adjustment {
|
||||
let ms: Vec<Mat3> = samples.iter().map(|pr| transform_matrix(pr, &b)).collect();
|
||||
b.pad_sides(transform_grow(&ms, b.img.width, b.img.height));
|
||||
}
|
||||
let mut acc = Image::new(b.img.width, b.img.height);
|
||||
for pr in &samples {
|
||||
let mut one = Image::new(b.img.width, b.img.height);
|
||||
let op = pr.f("opacity") as f32 / 100.0;
|
||||
effectcraft_raster::composite_warp(
|
||||
&mut one,
|
||||
&b.img,
|
||||
&transform_matrix(pr, &b),
|
||||
&effectcraft_raster::WarpOpts { opacity: op, sampling, ..Default::default() },
|
||||
);
|
||||
acc.data.iter_mut().zip(&one.data).for_each(|(a, o)| (0..4).for_each(|c| a[c] += o[c]));
|
||||
}
|
||||
let count = samples.len() as f32;
|
||||
acc.data.iter_mut().for_each(|p| p.iter_mut().for_each(|v| *v /= count));
|
||||
b.img = acc;
|
||||
return b;
|
||||
}
|
||||
}
|
||||
let m = Mat3::translate(vec2(pos.0, pos.1))
|
||||
* Mat3::rotate_deg(rot)
|
||||
* Mat3::skew_deg(skew, skew_axis)
|
||||
* Mat3::scale(vec2(sw / 100.0, sh / 100.0))
|
||||
* Mat3::translate(vec2(-anchor.0, -anchor.1));
|
||||
if !ctx.adjustment {
|
||||
let m = transform_matrix(ctx.params, &b);
|
||||
b.pad_sides(transform_grow(&[m], b.img.width, b.img.height));
|
||||
}
|
||||
let m = transform_matrix(ctx.params, &b);
|
||||
let mut out = Image::new(b.img.width, b.img.height);
|
||||
effectcraft_raster::composite_warp(&mut out, &b.img, &m, &effectcraft_raster::WarpOpts { opacity, sampling, ..Default::default() });
|
||||
b.img = out;
|
||||
@@ -576,6 +621,144 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// After Effects 2026 (cases 723, 819, 820): a grey 960 × 540 solid centred in a 1920 × 1080
|
||||
/// comp with Transform. The skew applies after the rotation, and the result is drawn outside
|
||||
/// the layer's bounds. Points are comp pixels sampled from AE's renders (at least 3 px from
|
||||
/// an edge; true = the solid covers it), half where the old order and clipping differed.
|
||||
#[test]
|
||||
fn transform_matches_after_effects() {
|
||||
let solid = Image::filled(960, 540, [0.5, 0.5, 0.5, 1.0]);
|
||||
let p2 = |x: f64, y: f64| Value::Vec2([x, y]);
|
||||
let cases: [(&str, Vec<(&str, Value)>, &[(u32, u32, bool)]); 3] = [
|
||||
(
|
||||
"723",
|
||||
vec![
|
||||
("anchor", p2(400.0, 200.0)),
|
||||
("position", p2(500.0, 300.0)),
|
||||
("uniform", Value::Bool(false)),
|
||||
("scaleHeight", num(80.0)),
|
||||
("scaleWidth", num(120.0)),
|
||||
("skew", num(10.0)),
|
||||
("skewAxis", num(20.0)),
|
||||
("rotation", num(30.0)),
|
||||
],
|
||||
&[
|
||||
(1465, 954, true),
|
||||
(1127, 821, true),
|
||||
(1378, 846, true),
|
||||
(1416, 878, true),
|
||||
(1486, 850, true),
|
||||
(492, 467, true),
|
||||
(706, 239, true),
|
||||
(1402, 1021, true),
|
||||
(518, 440, true),
|
||||
(1218, 882, true),
|
||||
(454, 448, true),
|
||||
(1473, 920, true),
|
||||
(1048, 654, true),
|
||||
(1050, 486, true),
|
||||
(1060, 736, true),
|
||||
(932, 804, true),
|
||||
(1074, 562, true),
|
||||
(653, 591, true),
|
||||
(1353, 466, false),
|
||||
(88, 1021, false),
|
||||
(1855, 886, false),
|
||||
(1796, 401, false),
|
||||
(497, 913, false),
|
||||
(1445, 515, false),
|
||||
],
|
||||
),
|
||||
(
|
||||
"819",
|
||||
vec![("skew", num(15.0)), ("skewAxis", num(60.0))],
|
||||
&[
|
||||
(465, 367, true),
|
||||
(554, 793, false),
|
||||
(481, 529, true),
|
||||
(1451, 599, true),
|
||||
(428, 575, true),
|
||||
(432, 600, true),
|
||||
(463, 444, true),
|
||||
(759, 803, false),
|
||||
(445, 684, true),
|
||||
(1411, 757, true),
|
||||
(915, 784, false),
|
||||
(480, 737, true),
|
||||
(758, 620, true),
|
||||
(975, 705, true),
|
||||
(1289, 376, true),
|
||||
(575, 420, true),
|
||||
(1015, 507, true),
|
||||
(1377, 448, true),
|
||||
(1679, 171, false),
|
||||
(194, 435, false),
|
||||
(413, 159, false),
|
||||
(327, 741, false),
|
||||
(1824, 138, false),
|
||||
(1093, 29, false),
|
||||
],
|
||||
),
|
||||
(
|
||||
"820",
|
||||
vec![
|
||||
("skew", num(15.0)),
|
||||
("skewAxis", num(60.0)),
|
||||
("rotation", num(-25.0)),
|
||||
("uniform", Value::Bool(false)),
|
||||
("scaleWidth", num(130.0)),
|
||||
("scaleHeight", num(70.0)),
|
||||
("anchor", p2(100.0, 50.0)),
|
||||
],
|
||||
&[
|
||||
(1674, 566, true),
|
||||
(1425, 317, false),
|
||||
(1788, 278, true),
|
||||
(1530, 390, true),
|
||||
(1531, 652, true),
|
||||
(1589, 545, true),
|
||||
(1890, 441, true),
|
||||
(1724, 400, true),
|
||||
(1657, 511, true),
|
||||
(1895, 261, true),
|
||||
(1697, 344, true),
|
||||
(933, 840, true),
|
||||
(1413, 467, true),
|
||||
(1295, 691, true),
|
||||
(1353, 548, true),
|
||||
(1293, 548, true),
|
||||
(1103, 551, true),
|
||||
(973, 758, true),
|
||||
(446, 484, false),
|
||||
(1009, 420, false),
|
||||
(144, 761, false),
|
||||
(1812, 111, false),
|
||||
(1241, 10, false),
|
||||
(900, 854, false),
|
||||
],
|
||||
),
|
||||
];
|
||||
for (case, set, pts) in cases {
|
||||
let o = run("ec.distort.transform", &set, solid.clone());
|
||||
for &(x, y, inside) in pts {
|
||||
// Comp pixel → layer pixel (the solid's top-left sits at 480, 270) → buffer.
|
||||
let (bx, by) = o.to_px([x as f64 + 0.5 - 480.0, y as f64 + 0.5 - 270.0]);
|
||||
let a = if bx >= 0.0 && by >= 0.0 && bx < o.img.width as f64 && by < o.img.height as f64 { o.img.get(bx as i64, by as i64)[3] } else { 0.0 };
|
||||
assert_eq!(a > 0.5, inside, "case {case}: comp pixel ({x}, {y}) alpha {a}");
|
||||
}
|
||||
}
|
||||
// A huge scale stays within the buffer cap.
|
||||
let o = run("ec.distort.transform", &[("scaleHeight", num(100_000.0))], Image::filled(8, 8, [1.0; 4]));
|
||||
assert!(o.img.width <= 16_384 && o.img.height <= 16_384);
|
||||
// A layer moved far right grows the buffer on that side only, within 16 Mpx.
|
||||
let o = run("ec.distort.transform", &[("position", p2(960.0 + 2000.0, 540.0))], Image::filled(1920, 1080, [1.0; 4]));
|
||||
assert_eq!((o.offset[0], o.img.height), (0.0, 1080), "only the right side grows");
|
||||
assert!(o.img.width >= 1920 + 2000 && o.img.width as u64 * o.img.height as u64 <= 16_777_216, "{} x {}", o.img.width, o.img.height);
|
||||
assert!(o.img.get(2000 + 960, 540)[3] > 0.99 && o.img.get(10, 540)[3] < 0.01);
|
||||
let o = run("ec.distort.transform", &[("position", p2(40_000.0, 30_000.0))], Image::filled(1920, 1080, [1.0; 4]));
|
||||
assert!(o.img.width as u64 * o.img.height as u64 <= 16_777_216, "{} x {}", o.img.width, o.img.height);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_sampling_bicubic_differs_from_bilinear() {
|
||||
let im = img(32, 32);
|
||||
|
||||
@@ -78,7 +78,7 @@ pub use color_fx::{
|
||||
pub use color_fx::{COLORAMA_PRESETS, ColoramaPalette};
|
||||
pub use color2::{Curve, auto_correct_settings, color_link_mode, color_link_sample, equalize_tables, luma_clip_points, shadow_highlight_amounts};
|
||||
pub use color3::OffsetCurve;
|
||||
pub use distort::transform_shutter;
|
||||
pub use distort::{transform_grow, transform_matrix_px, transform_shutter};
|
||||
pub use distort2::{MAGNIFY_MODES, bezier_grid, parse_mesh};
|
||||
pub use distort3::{RESHAPE_POINTS, ReshapeSetup, SmearSetup, cc_smear_pad, reshape_setup, smear_setup, warp_inverse_map};
|
||||
pub use distort4::liquify_mesh;
|
||||
@@ -86,7 +86,10 @@ use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::build::Ids;
|
||||
use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property};
|
||||
pub use effectcraft_raster::{AuxChannels, Image};
|
||||
pub use misc::{INVERT_ALPHA, INVERT_CHANNELS, glow_ab_t, glow_operation};
|
||||
pub use misc::{
|
||||
GLOW_GAIN, GLOW_KNEE, INVERT_ALPHA, INVERT_CHANNELS, glow_ab_t, glow_operation, glow_sigma, radial_wipe_sigma, radial_wipe_wedge, venetian_cover,
|
||||
venetian_origin,
|
||||
};
|
||||
pub use sim::particle_state;
|
||||
pub use sim3::{PgBlit, PgPlan, playground_plan, playground_state};
|
||||
pub use stylize2::strobe_on;
|
||||
@@ -104,7 +107,7 @@ pub use transition::place_layer;
|
||||
// effectcraft-gpu fx_noise.
|
||||
pub use keying2::{InnerOuterPlan, inner_outer_plan};
|
||||
pub use noise::remove_grain_level;
|
||||
pub use noise3::{FractalGpu, fractal_gpu};
|
||||
pub use noise3::{FractalGpu, NOISE_CELL, fractal_gpu};
|
||||
pub use stylize3::{BumpLight, 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};
|
||||
@@ -287,6 +290,27 @@ impl Buf {
|
||||
self.offset[0] += origin[0] * self.scale;
|
||||
self.offset[1] += origin[1] * self.scale;
|
||||
}
|
||||
/// Grow by transparent pixels: `sides` = left, top, right, bottom. Content keeps its place.
|
||||
pub fn pad_sides(&mut self, sides: [u32; 4]) {
|
||||
let [l, t, r, b] = sides;
|
||||
if l == 0 && t == 0 && r == 0 && b == 0 {
|
||||
return;
|
||||
}
|
||||
let (Some(w), Some(h)) = (self.img.width.checked_add(l).and_then(|v| v.checked_add(r)), self.img.height.checked_add(t).and_then(|v| v.checked_add(b)))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let mut out = Image::new(w, h);
|
||||
let (sw, ow) = (self.img.width as usize, w as usize);
|
||||
for y in 0..self.img.height as usize {
|
||||
let at = (y + t as usize) * ow + l as usize;
|
||||
let (Some(src), Some(dst)) = (self.img.data.get(y * sw..(y + 1) * sw), out.data.get_mut(at..at + sw)) else { continue };
|
||||
dst.copy_from_slice(src);
|
||||
}
|
||||
self.img = out;
|
||||
self.offset[0] += l as f64;
|
||||
self.offset[1] += t as f64;
|
||||
}
|
||||
/// Grow by `pad` transparent pixels on every side.
|
||||
pub fn pad(&mut self, pad: u32) {
|
||||
if pad == 0 {
|
||||
|
||||
@@ -344,6 +344,23 @@ pub fn upgrade_instance(spec: &EffectSpec, g: &mut PropGroup, ids: &mut Ids, lay
|
||||
*g != before
|
||||
}
|
||||
|
||||
/// The spec id of the parameter that an instance of `spec` saved at `id` (`a/b/param`) is now:
|
||||
/// `id` itself when the spec still has it, the new id of a renamed parameter
|
||||
/// ([`PARAM_ID_ALIASES`]), or the twirl-down path of a top-level parameter that moved into a
|
||||
/// group, as [`upgrade_instance`] finds them. Returns `None` when no parameter matches.
|
||||
pub fn current_param_id(spec: &EffectSpec, id: &str) -> Option<&'static str> {
|
||||
if let Some(ps) = spec.params.iter().find(|ps| ps.id == id) {
|
||||
return Some(ps.id);
|
||||
}
|
||||
if let Some((_, _, new)) = PARAM_ID_ALIASES.iter().find(|(e, old, _)| *e == spec.id && *old == id) {
|
||||
return Some(new);
|
||||
}
|
||||
if id.contains('/') {
|
||||
return None;
|
||||
}
|
||||
spec.params.iter().find(|ps| ps.id.rsplit('/').next() == Some(id)).map(|ps| ps.id)
|
||||
}
|
||||
|
||||
/// Bring an effect instance saved before project schema 2 into effect space (#227).
|
||||
///
|
||||
/// Layers without a source rectangle (shape, text) have comp-sized effect bounds centred on
|
||||
@@ -559,6 +576,21 @@ mod tests {
|
||||
assert_eq!(prop_at(&mut g, "stabilization/smoothness").unwrap().value, Value::Scalar(12.0));
|
||||
}
|
||||
|
||||
/// A saved parameter id maps to the parameter `upgrade_instance` moves it to.
|
||||
#[test]
|
||||
fn current_param_id_follows_renames_and_twirl_down_moves() {
|
||||
let stab = find(crate::warp_stab::ID).unwrap();
|
||||
assert_eq!(current_param_id(stab, "stabilization/smoothness"), Some("stabilization/smoothness"));
|
||||
assert_eq!(current_param_id(stab, "smoothness"), Some("stabilization/smoothness"));
|
||||
let ctc = find("ec.color.changetocolor").unwrap();
|
||||
assert_eq!(current_param_id(ctc, "tolerance"), Some("toleranceGroup/hue"));
|
||||
// Radio Waves' old Velocity is the growth speed, not the new Velocity.
|
||||
let waves = find("ec.generate.radiowaves").unwrap();
|
||||
assert_eq!(current_param_id(waves, "velocity"), Some("waveMotion/expansion"));
|
||||
assert_eq!(current_param_id(stab, "noSuchParam"), None);
|
||||
assert_eq!(current_param_id(stab, "other/smoothness"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_wipe_back_layer_popup_becomes_a_layer_parameter() {
|
||||
let spec = find("ec.transition.cardwipe").unwrap();
|
||||
|
||||
+311
-20
@@ -142,6 +142,28 @@ pub fn glow_ab_t(l: f32, looping: u32, loops: f32, phase: f32, mid: f32) -> f32
|
||||
t.max(0.0).powf(0.5f32.ln() / m.ln())
|
||||
}
|
||||
|
||||
/// Glow's blur: a Gaussian of σ = 0.384 × Glow Radius. Fitted to After Effects 2026 (cases
|
||||
/// 807 and 808, Glow Radius 10 and 30 on a grey solid): the edge profile of the glow's
|
||||
/// coverage is an erfc step of that σ within 1.7/255 (it was σ = radius / 2).
|
||||
pub fn glow_sigma(radius: f64) -> f64 {
|
||||
(radius * 0.384).max(0.5)
|
||||
}
|
||||
|
||||
/// Glow's bright pass for the original colours, per channel of the straight colour `v`:
|
||||
/// `8 × (v − threshold + 0.0611)`, nothing below. Fitted to After Effects 2026 renders of grey
|
||||
/// 0.65 / 0.8 / 0.9 / 1, orange and light-blue solids at Threshold 40 / 60 / 80 % and Radius 10
|
||||
/// / 30 (36 channel values, exact to the 8-bit capture): the glow is linear in each channel,
|
||||
/// its gain is the same at every threshold and radius, and channels below the threshold add
|
||||
/// nothing (an orange solid glows red only).
|
||||
pub fn glow_bright_channel(v: f32, thr: f32) -> f32 {
|
||||
(GLOW_GAIN * (v - thr + GLOW_KNEE)).max(0.0)
|
||||
}
|
||||
|
||||
/// [`glow_bright_channel`]'s gain and threshold offset. `kernels.wgsl` repeats both as literals;
|
||||
/// the GPU test `glow_constants_match_the_cpu` checks that they are these.
|
||||
pub const GLOW_GAIN: f32 = 8.0;
|
||||
pub const GLOW_KNEE: f32 = 0.0611;
|
||||
|
||||
/// Glow Operation options (blend modes the glow is applied with).
|
||||
pub const GLOW_OPERATIONS: [&str; 17] = [
|
||||
"Normal",
|
||||
@@ -205,23 +227,31 @@ fn glow(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
// Glow Based On: brightness from the colours, or from the alpha channel.
|
||||
let l = if alpha_based { a } else { luminance(px[0] / a, px[1] / a, px[2] / a) };
|
||||
let k = ((l - thr) / (1.0 - thr).max(1e-3)).clamp(0.0, 1.0);
|
||||
// Based on the colour channels, the glow's coverage is the layer's whole alpha, below
|
||||
// the threshold too: After Effects darkens what lies under a dim layer's edge (glow_alpha).
|
||||
let ga = if alpha_based { k * a } else { a };
|
||||
if let Some(map) = &arbitrary {
|
||||
let t = glow_ab_t(l, looping, loops, phase, mid);
|
||||
let c = map.iter().map(|cv| cv.as_ref().map_or(t, |cv| cv.eval(t))).collect::<Vec<f32>>();
|
||||
*px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, k * a];
|
||||
*px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, ga];
|
||||
} else if use_colors {
|
||||
let t = glow_ab_t(l, looping, loops, phase, mid);
|
||||
let c = [ca[0] + (cb[0] - ca[0]) * t, ca[1] + (cb[1] - ca[1]) * t, ca[2] + (cb[2] - ca[2]) * t];
|
||||
*px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, k * a];
|
||||
*px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, ga];
|
||||
} else if alpha_based {
|
||||
*px = [px[0] * k, px[1] * k, px[2] * k, ga];
|
||||
} else {
|
||||
*px = px.map(|v| v * k);
|
||||
// Original colours: each channel of the straight colour against the threshold.
|
||||
let c = [px[0] / a, px[1] / a, px[2] / a].map(|v| glow_bright_channel(v, thr) * a);
|
||||
*px = [c[0], c[1], c[2], ga];
|
||||
}
|
||||
});
|
||||
let s = (radius / 2.0).max(0.5);
|
||||
let s = glow_sigma(radius);
|
||||
let blurred = gaussian_blur(&bright, s * kx, s * ky, false);
|
||||
let mode = ctx.params.e("operation");
|
||||
b.img.data.par_iter_mut().zip(blurred.data.par_iter()).for_each(|(o, g)| {
|
||||
let g = g.map(|v| v * intensity);
|
||||
// Intensity scales the glow's colour; with the colour channels its coverage stays.
|
||||
let g = [g[0] * intensity, g[1] * intensity, g[2] * intensity, if alpha_based { g[3] * intensity } else { g[3] }];
|
||||
match mode {
|
||||
// Behind: the glow shows only where the original is transparent.
|
||||
1 => {
|
||||
@@ -525,22 +555,67 @@ fn linear_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
/// Standard normal CDF Φ (Abramowitz & Stegun 7.1.26 erf, |error| < 1.5e-7).
|
||||
fn norm_cdf(z: f64) -> f64 {
|
||||
let x = z.abs() / std::f64::consts::SQRT_2;
|
||||
let t = 1.0 / (1.0 + 0.327_591_1 * x);
|
||||
let poly = t * (0.254_829_592 + t * (-0.284_496_736 + t * (1.421_413_741 + t * (-1.453_152_027 + t * 1.061_405_429))));
|
||||
let erf = 1.0 - poly * (-x * x).exp();
|
||||
if z >= 0.0 { 0.5 * (1.0 + erf) } else { 0.5 * (1.0 - erf) }
|
||||
}
|
||||
|
||||
/// Radial Wipe's wiped wedge relative to the Start Angle: (first ray, span), degrees clockwise
|
||||
/// from up. Clockwise (0) wipes from the start angle on, Counterclockwise (1) back from it,
|
||||
/// Both (2) half each way.
|
||||
pub fn radial_wipe_wedge(wipe: u32, done: f64) -> (f64, f64) {
|
||||
let span = done.clamp(0.0, 1.0) * 360.0;
|
||||
match wipe {
|
||||
1 => (-span, span),
|
||||
2 => (-span / 2.0, span),
|
||||
_ => (0.0, span),
|
||||
}
|
||||
}
|
||||
|
||||
/// Radial Wipe's feather: a Gaussian of σ = 0.4 × Feather pixels across both edges of the wedge
|
||||
/// (After Effects 2026, Feather 20: the edge profile is the same width in pixels at every
|
||||
/// radius, σ = 8.0 px, mean error 0.0002 over the frame).
|
||||
pub fn radial_wipe_sigma(feather: f64) -> f64 {
|
||||
feather.max(0.0) * 0.4
|
||||
}
|
||||
|
||||
/// Visible fraction of a pixel at `(ux, uy)` from the centre (y down) for a wiped wedge from
|
||||
/// `ray0` (absolute degrees clockwise from up) over `span` degrees: the signed distance to the
|
||||
/// wedge's nearer edge, through the feather's Gaussian.
|
||||
pub fn radial_wipe_alpha(ux: f64, uy: f64, ray0: f64, span: f64, sigma: f64) -> f64 {
|
||||
let a = (ux.atan2(-uy).to_degrees() - ray0).rem_euclid(360.0);
|
||||
let wiped = a < span;
|
||||
let ray_dist = |deg: f64| {
|
||||
let (s, c) = deg.to_radians().sin_cos();
|
||||
// Ray direction (sin, −cos) in y-down pixels.
|
||||
let along = ux * s - uy * c;
|
||||
if along > 0.0 { (ux * c + uy * s).abs() } else { ux.hypot(uy) }
|
||||
};
|
||||
let d = ray_dist(ray0).min(ray_dist(ray0 + span));
|
||||
let sd = if wiped { -d } else { d };
|
||||
if sigma < 1e-3 {
|
||||
return if wiped { 0.0 } else { 1.0 };
|
||||
}
|
||||
norm_cdf(sd / sigma)
|
||||
}
|
||||
|
||||
fn radial_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let done = ctx.params.f("completion") / 100.0;
|
||||
let done = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0);
|
||||
if done <= 0.0 {
|
||||
return b;
|
||||
}
|
||||
let start = ctx.params.f("startAngle");
|
||||
let c = b.to_px(ctx.params.v2("center"));
|
||||
let dir = ctx.params.e("wipe");
|
||||
let feather = (ctx.params.f("feather")).max(0.01);
|
||||
let (w0, span) = radial_wipe_wedge(ctx.params.e("wipe"), done);
|
||||
let sigma = radial_wipe_sigma(ctx.params.f("feather")) * b.scale;
|
||||
let full = done >= 1.0;
|
||||
b.img.rows_mut().for_each(|(y, row)| {
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let a = ((x as f64 + 0.5 - c.0).atan2(-(y as f64 + 0.5 - c.1)).to_degrees() - start).rem_euclid(360.0);
|
||||
// Both: the wipe opens in both directions from the start angle at once.
|
||||
let (a, edge) = match dir {
|
||||
1 => (360.0 - a, done * 360.0),
|
||||
2 => (a.min(360.0 - a), done * 180.0),
|
||||
_ => (a, done * 360.0),
|
||||
};
|
||||
let k = ((a - edge) / feather + 0.5).clamp(0.0, 1.0) as f32;
|
||||
let k = if full { 0.0 } else { radial_wipe_alpha(x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1, start + w0, span, sigma) as f32 };
|
||||
for ch in px.iter_mut() {
|
||||
*ch *= k;
|
||||
}
|
||||
@@ -549,16 +624,49 @@ fn radial_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
/// Venetian Blinds: how much of a pixel at phase `d` (0..`width`) a band of `done × width`
|
||||
/// covers, its two edges softened by a Gaussian of σ = Feather / 2 (σ in the units of `width`).
|
||||
/// Fitted to After Effects 2026 (Completion 40, Direction 30, Width 50, Feather 5 on 960 × 540
|
||||
/// and 480 × 270 layers): band from phase 0 to done × width, both edges Gaussian, within 6/255.
|
||||
pub fn venetian_cover(d: f64, width: f64, done: f64, sigma: f64) -> f64 {
|
||||
let band = done * width;
|
||||
if sigma < 1e-3 {
|
||||
return if d < band { 1.0 } else { 0.0 };
|
||||
}
|
||||
// Wider than a few blinds the stripes blur into an even veil.
|
||||
if sigma > 8.0 * width {
|
||||
return done.clamp(0.0, 1.0);
|
||||
}
|
||||
let n = ((4.0 * sigma / width).ceil() as i64 + 1).clamp(1, 40);
|
||||
let mut t = 0.0;
|
||||
for k in -n..=n {
|
||||
let u = d + k as f64 * width;
|
||||
t += norm_cdf(u / sigma) - norm_cdf((u - band) / sigma);
|
||||
}
|
||||
t.clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Venetian Blinds' phase origin in layer pixels: the layer centre plus 0.6 px (fitted to the
|
||||
/// AE renders above; the stripes are measured from the layer's centre, not its corner).
|
||||
pub fn venetian_origin(layer_size: [f64; 2]) -> [f64; 2] {
|
||||
[layer_size[0] / 2.0 + 0.6, layer_size[1] / 2.0 + 0.6]
|
||||
}
|
||||
|
||||
fn venetian(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let done = ctx.params.f("completion") / 100.0;
|
||||
let done = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0);
|
||||
if done <= 0.0 {
|
||||
return b;
|
||||
}
|
||||
let ang = ctx.params.f("direction").to_radians();
|
||||
let width = (ctx.params.f("width") * b.scale).max(1.0);
|
||||
let feather = (ctx.params.f("feather") * b.scale).max(0.001);
|
||||
let sigma = ctx.params.f("feather").max(0.0) * b.scale / 2.0;
|
||||
let (dx, dy) = (ang.cos(), ang.sin());
|
||||
let (ox, oy) = b.to_px(venetian_origin(ctx.layer_size));
|
||||
b.img.rows_mut().for_each(|(y, row)| {
|
||||
let ry = (y as f64 + 0.5 - oy) * dy;
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let d = ((x as f64 + 0.5) * dx + (y as f64 + 0.5) * dy).rem_euclid(width);
|
||||
let k = ((d - done * width) / feather + 0.5).clamp(0.0, 1.0) as f32;
|
||||
let d = ((x as f64 + 0.5 - ox) * dx + ry).rem_euclid(width);
|
||||
let k = (1.0 - venetian_cover(d, width, done, sigma)) as f32;
|
||||
for c in px.iter_mut() {
|
||||
*c *= k;
|
||||
}
|
||||
@@ -807,6 +915,189 @@ mod tests {
|
||||
img
|
||||
}
|
||||
|
||||
/// After Effects 2026 (cases 524, 807, 808, 809): Glow on a 50 % grey solid, which is below
|
||||
/// the 60 % threshold, still covers a blurred copy of the layer's alpha with black, so the
|
||||
/// red layer underneath darkens outside the edge. The halo is the same at Intensity 1 and 3
|
||||
/// and with Composite Original On Top or Behind; its width follows Glow Radius.
|
||||
#[test]
|
||||
fn glow_halo_matches_after_effects() {
|
||||
let grey = effectcraft_raster::Image::filled(200, 120, [0.5, 0.5, 0.5, 1.0]);
|
||||
// AE's red under the halo at 0.5, 1.5, 3.5, 5.5 and 7.5 px outside the left edge
|
||||
// (radius 10) and at 0.5, 5.5, 10.5, 20.5 px (radius 30); halo alpha = 1 − red.
|
||||
let r10 = [(0.5, 140.0), (1.5, 165.0), (3.5, 208.0), (5.5, 234.0), (7.5, 248.0)];
|
||||
let r30 = [(0.5, 132.0), (5.5, 173.0), (10.5, 208.0), (20.5, 245.0)];
|
||||
for (radius, intensity, operation, profile) in [(10.0, 1.0, 1, &r10[..]), (10.0, 3.0, 1, &r10[..]), (10.0, 1.0, 0, &r10[..]), (30.0, 1.0, 1, &r30[..])]
|
||||
{
|
||||
let set = [
|
||||
("based", Value::Enum(1)),
|
||||
("threshold", num(60.0)),
|
||||
("radius", num(radius)),
|
||||
("intensity", num(intensity)),
|
||||
("operation", Value::Enum(operation)),
|
||||
];
|
||||
let o = run_fx("ec.stylize.glow", &set, grey.clone(), 0.0, EffectEnv::default());
|
||||
for &(d, red) in profile {
|
||||
// Layer x = −d (pixel centre), mid-height; over AE's red: R = 1 − α, colour 0.
|
||||
let (bx, by) = o.to_px([-d, 60.0]);
|
||||
let p = o.img.get(bx.floor() as i64, by.floor() as i64);
|
||||
let ae = 1.0 - red / 255.0;
|
||||
assert!((p[3] - ae).abs() < 3.0 / 255.0, "radius {radius} intensity {intensity} op {operation} at {d} px: alpha {} vs AE {ae}", p[3]);
|
||||
assert!(p[0].abs() < 1e-6, "the halo is black: {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// After Effects 2026, Glow at Threshold 60 %, Radius 10, Intensity 0.25 on 200 × 200
|
||||
/// solids over red: the glow 1.5 px outside the left edge, premultiplied (the green / blue
|
||||
/// channels of the render, where the red background adds nothing), and its alpha 0.353.
|
||||
#[test]
|
||||
fn glow_brightness_matches_after_effects() {
|
||||
// (solid colour, channel, AE glow value 0..255)
|
||||
let cases = [
|
||||
([0.65, 0.65, 0.65], 1, 20.0),
|
||||
([0.8, 0.8, 0.8], 1, 47.0),
|
||||
([0.9, 0.9, 0.9], 1, 65.0),
|
||||
([1.0, 1.0, 1.0], 1, 83.0),
|
||||
([1.0, 0.5, 0.0], 1, 0.0),
|
||||
([0.2, 0.6, 1.0], 1, 11.0),
|
||||
([0.2, 0.6, 1.0], 2, 83.0),
|
||||
];
|
||||
for (c, ch, ae) in cases {
|
||||
let img = effectcraft_raster::Image::filled(200, 200, [c[0], c[1], c[2], 1.0]);
|
||||
let set = [("threshold", num(60.0)), ("radius", num(10.0)), ("intensity", num(0.25))];
|
||||
let o = run_fx("ec.stylize.glow", &set, img, 0.0, EffectEnv::default());
|
||||
let (bx, by) = o.to_px([-1.5, 100.0]);
|
||||
let p = o.img.get(bx.floor() as i64, by.floor() as i64);
|
||||
assert!((p[ch] * 255.0 - ae).abs() <= 2.5, "{c:?} channel {ch}: {} vs AE {ae}", p[ch] * 255.0);
|
||||
assert!((p[3] - 0.353).abs() < 3.0 / 255.0, "{c:?}: alpha {}", p[3]);
|
||||
}
|
||||
// Intensity scales the colour linearly: white at Intensity 1 is 3.7 × its alpha.
|
||||
let white = effectcraft_raster::Image::filled(200, 200, [1.0, 1.0, 1.0, 1.0]);
|
||||
let o = run_fx("ec.stylize.glow", &[("threshold", num(60.0)), ("radius", num(10.0))], white, 0.0, EffectEnv::default());
|
||||
let (bx, by) = o.to_px([-1.5, 100.0]);
|
||||
let p = o.img.get(bx.floor() as i64, by.floor() as i64);
|
||||
assert!((p[1] / p[3] - 3.69).abs() < 0.05, "{p:?}");
|
||||
}
|
||||
|
||||
/// After Effects 2026: Radial Blur keeps the layer's edges. Zoom 20 on a flat grey 960 × 540
|
||||
/// solid (case 713) leaves it flat and opaque to the edge (EffectCraft faded the edges toward
|
||||
/// transparency, letting the red layer below show through); on a full-frame horizontal ramp
|
||||
/// (case 829, centre [960, 540]) row 540 reads 2/255 at x = 2 and 253/255 at x = 1917.
|
||||
#[test]
|
||||
fn radial_blur_keeps_the_layer_edges_as_after_effects() {
|
||||
let zoom = |c: [f64; 2]| [("amount", num(20.0)), ("type", Value::Enum(1)), ("center", Value::Vec2(c))];
|
||||
let grey = effectcraft_raster::Image::filled(960, 540, [0.5, 0.5, 0.5, 1.0]);
|
||||
let o = run_fx("ec.blur.radial", &zoom([600.0, 300.0]), grey, 0.0, EffectEnv::default());
|
||||
for (x, y) in [(0, 270), (1, 270), (959, 270), (480, 0), (480, 539), (0, 0), (959, 539)] {
|
||||
let p = o.img.get(x, y);
|
||||
assert!((p[3] - 1.0).abs() < 1e-4 && (p[0] - 0.5).abs() < 1e-4, "({x}, {y}): {p:?}");
|
||||
}
|
||||
let mut ramp = effectcraft_raster::Image::new(1920, 1080);
|
||||
ramp.rows_mut().for_each(|(_, row)| {
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let v = (x as f32 + 0.5) / 1920.0;
|
||||
*px = [v, v, v, 1.0];
|
||||
}
|
||||
});
|
||||
let o = run_fx("ec.blur.radial", &zoom([960.0, 540.0]), ramp, 0.0, EffectEnv::default());
|
||||
for (x, ae) in [(2, 2.0), (1917, 253.0)] {
|
||||
let p = o.img.get(x, 540);
|
||||
assert!((p[0] * 255.0 - ae).abs() <= 2.0 && (p[3] - 1.0).abs() < 1e-4, "x {x}: {p:?} vs AE {ae}");
|
||||
}
|
||||
}
|
||||
|
||||
/// After Effects 2026, Venetian Blinds Completion 40, Direction 30, Width 50, Feather 5 on
|
||||
/// an opaque 480 × 270 layer: alpha (0..255) along row y = 135. The transparent band starts
|
||||
/// at the layer centre's phase, both edges are Gaussian (σ = Feather / 2).
|
||||
#[test]
|
||||
fn venetian_blinds_match_after_effects() {
|
||||
let img = effectcraft_raster::Image::filled(480, 270, [0.5, 0.5, 0.5, 1.0]);
|
||||
let vals = [("completion", Value::Scalar(40.0)), ("direction", Value::Scalar(30.0)), ("width", Value::Scalar(50.0)), ("feather", Value::Scalar(5.0))];
|
||||
let o = run_fx("ec.transition.venetian", &vals, img, 0.0, EffectEnv::default());
|
||||
let ae = [
|
||||
(200, 5.0),
|
||||
(203, 47.0),
|
||||
(206, 145.0),
|
||||
(209, 228.0),
|
||||
(212, 253.0),
|
||||
(224, 255.0),
|
||||
(236, 238.0),
|
||||
(239, 168.0),
|
||||
(242, 66.0),
|
||||
(245, 10.0),
|
||||
(251, 0.0),
|
||||
(257, 2.0),
|
||||
(260, 31.0),
|
||||
];
|
||||
for (x, a) in ae {
|
||||
let got = o.img.get(x, 135)[3] * 255.0;
|
||||
assert!((got - a).abs() <= 6.0, "x {x}: alpha {got} vs AE {a}");
|
||||
}
|
||||
// No completion leaves the layer; full completion clears it; no feather is a hard edge.
|
||||
let full = [("completion", Value::Scalar(100.0)), ("feather", Value::Scalar(5.0))];
|
||||
let img = effectcraft_raster::Image::filled(40, 20, [0.5, 0.5, 0.5, 1.0]);
|
||||
assert!(run_fx("ec.transition.venetian", &full, img.clone(), 0.0, EffectEnv::default()).img.data.iter().all(|p| p[3] < 1e-4));
|
||||
assert_eq!(run_fx("ec.transition.venetian", &[("completion", Value::Scalar(0.0))], img.clone(), 0.0, EffectEnv::default()).img, img);
|
||||
let hard = run_fx(
|
||||
"ec.transition.venetian",
|
||||
&[("completion", Value::Scalar(50.0)), ("width", Value::Scalar(10.0)), ("feather", Value::Scalar(0.0))],
|
||||
img,
|
||||
0.0,
|
||||
EffectEnv::default(),
|
||||
);
|
||||
assert!(hard.img.data.iter().all(|p| p[3] == 0.0 || p[3] == 1.0));
|
||||
// A feather far wider than the blinds is an even veil.
|
||||
assert_eq!(venetian_cover(3.0, 10.0, 0.5, 1e9), 0.5);
|
||||
}
|
||||
|
||||
/// After Effects 2026, Radial Wipe Completion 40, Start Angle 30, Wipe Counterclockwise,
|
||||
/// Feather 20, centre (800, 500): visible fraction at pixel offsets from the centre on both
|
||||
/// edges (8-bit captures of dark steps, so ±0.05).
|
||||
#[test]
|
||||
fn radial_wipe_feather_matches_after_effects() {
|
||||
let img = effectcraft_raster::Image::filled(400, 300, [1.0, 1.0, 1.0, 1.0]);
|
||||
let vals = [
|
||||
("completion", Value::Scalar(40.0)),
|
||||
("startAngle", Value::Scalar(30.0)),
|
||||
("center", Value::Vec2([200.0, 150.0])),
|
||||
("wipe", Value::Enum(1)),
|
||||
("feather", Value::Scalar(20.0)),
|
||||
];
|
||||
let o = run_fx("ec.transition.radialwipe", &vals, img.clone(), 0.0, EffectEnv::default());
|
||||
let ae = [
|
||||
((-50, 34), 0.922),
|
||||
((-52, 30), 0.797),
|
||||
((-54, 26), 0.609),
|
||||
((-55, 24), 0.5),
|
||||
((-56, 22), 0.406),
|
||||
((-57, 19), 0.266),
|
||||
((-58, 15), 0.125),
|
||||
((-104, 60), 0.951),
|
||||
((-108, 53), 0.732),
|
||||
((-111, 45), 0.341),
|
||||
((-114, 37), 0.062),
|
||||
((22, -56), 0.156),
|
||||
((26, -54), 0.312),
|
||||
((30, -52), 0.531),
|
||||
((34, -50), 0.729),
|
||||
((37, -47), 0.875),
|
||||
((53, -108), 0.177),
|
||||
((67, -99), 0.875),
|
||||
];
|
||||
for ((dx, dy), a) in ae {
|
||||
let got = o.img.get(200 + dx, 150 + dy)[3];
|
||||
assert!((got - a).abs() < 0.05, "offset ({dx}, {dy}): {got} vs AE {a}");
|
||||
}
|
||||
// 0 % leaves the layer, 100 % clears it, Feather 0 is a hard edge.
|
||||
let at = |c: f64, f: f64| {
|
||||
let v = [("completion", Value::Scalar(c)), ("feather", Value::Scalar(f))];
|
||||
run_fx("ec.transition.radialwipe", &v, img.clone(), 0.0, EffectEnv::default()).img
|
||||
};
|
||||
assert_eq!(at(0.0, 20.0), img);
|
||||
assert!(at(100.0, 20.0).data.iter().all(|p| p[3] == 0.0));
|
||||
assert!(at(50.0, 0.0).data.iter().all(|p| p[3] == 0.0 || p[3] == 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glow_operation_and_arbitrary_map() {
|
||||
// Pixels addressed in layer space (Glow pads its buffer).
|
||||
|
||||
@@ -60,6 +60,11 @@ pub const BLEND_NORMAL: u32 = 1;
|
||||
/// Index of "Soft Linear" in [`NOISE_TYPES`] (the default, and the GPU path's interpolation).
|
||||
pub const NOISE_SOFT_LINEAR: u32 = 2;
|
||||
|
||||
/// Lattice cell of Fractal / Turbulent Noise in pixels per percent of Scale: After Effects 2026
|
||||
/// draws Block noise at Scale 100 in 66.67 px cells (and Soft Linear noise has the matching
|
||||
/// correlation length), not 100 px.
|
||||
pub const NOISE_CELL: f64 = 2.0 / 3.0;
|
||||
|
||||
fn spec(id: &'static str, name: &'static str, params: Vec<ParamSpec>, render: crate::RenderFn) -> EffectSpec {
|
||||
EffectSpec { id, name, category: "Noise & Grain", params, render, gpu: false, float: true }
|
||||
}
|
||||
@@ -236,7 +241,7 @@ impl Fractal {
|
||||
overflow: pr.e("overflow"),
|
||||
invert: pr.b("invert"),
|
||||
rot: rot.sin_cos(),
|
||||
size: [(sw * b.scale).max(1.0) as f32, (sh * b.scale).max(1.0) as f32],
|
||||
size: [(sw * NOISE_CELL * b.scale).max(1.0) as f32, (sh * NOISE_CELL * b.scale).max(1.0) as f32],
|
||||
origin: [ox as f32, oy as f32],
|
||||
center: [cx as f32, cy as f32],
|
||||
perspective: pr.b("transform/perspectiveOffset"),
|
||||
@@ -535,14 +540,34 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn noise_types_differ_and_block_is_piecewise_constant() {
|
||||
let a = run("ec.noise.fractal", &[("noiseType", Value::Enum(0)), ("complexity", num(1.0)), ("transform/scale", num(10.0))]);
|
||||
// Block noise: neighbours inside one 10 px cell share a value.
|
||||
let a = run("ec.noise.fractal", &[("noiseType", Value::Enum(0)), ("complexity", num(1.0)), ("transform/scale", num(15.0))]);
|
||||
// Block noise: neighbours inside one 10 px cell (Scale 15) share a value.
|
||||
assert_eq!(a.get(4, 4), a.get(5, 5));
|
||||
let others: Vec<Image> = (1..4).map(|t| run("ec.noise.fractal", &[("noiseType", Value::Enum(t)), ("transform/scale", num(10.0))])).collect();
|
||||
assert_ne!(others[0], others[1]);
|
||||
assert_ne!(others[1], others[2]);
|
||||
}
|
||||
|
||||
/// After Effects 2026 (R9): Block noise at Scale 100, Complexity 1 changes value every
|
||||
/// 66.67 px along a row.
|
||||
#[test]
|
||||
fn block_noise_cells_are_two_thirds_of_scale_as_in_after_effects() {
|
||||
let img = run_fx(
|
||||
"ec.noise.fractal",
|
||||
&[("noiseType", Value::Enum(0)), ("complexity", num(1.0)), ("blendingMode", Value::Enum(BLEND_NONE))],
|
||||
Image::filled(400, 8, [0.0, 0.0, 0.0, 1.0]),
|
||||
0.0,
|
||||
EffectEnv::default(),
|
||||
)
|
||||
.img;
|
||||
let changes: Vec<i64> = (1..400).filter(|&x| img.get(x, 4) != img.get(x - 1, 4)).collect();
|
||||
assert!(changes.len() >= 4, "{changes:?}");
|
||||
for w in changes.windows(2) {
|
||||
let d = (w[1] - w[0]) as f64;
|
||||
assert!((d - 200.0 / 3.0).abs() <= 1.0, "cell width {d}: {changes:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_width_height_apply_without_uniform_scaling() {
|
||||
let base = &[("complexity", num(1.0)), ("transform/scale", num(8.0))];
|
||||
|
||||
@@ -191,6 +191,11 @@ pub fn roto_data(params: &Params, layer_size: [f64; 2], f: i64, with_model: bool
|
||||
/// Seed of the chain keys: what the frames are (the Input Key), the layer size, the frame rate,
|
||||
/// the mode and the model. The prompt is in each frame's key.
|
||||
pub fn seed(params: &Params, layer_size: [f64; 2], fps: f64, model: Option<&dyn MaskModel>) -> u64 {
|
||||
seed_of(params, layer_size, fps, model.map_or(effectcraft_segment::CLASSICAL, |m| m.info().id))
|
||||
}
|
||||
|
||||
/// [`seed`] for the model with id `model` ([`effectcraft_segment::CLASSICAL`]: no model).
|
||||
fn seed_of(params: &Params, layer_size: [f64; 2], fps: f64, model: &str) -> u64 {
|
||||
let mut h = 0xcbf2_9ce4_8422_2325;
|
||||
fnv(&mut h, ID.as_bytes());
|
||||
fnv(&mut h, s(params, INPUT_KEY).as_bytes());
|
||||
@@ -198,7 +203,7 @@ pub fn seed(params: &Params, layer_size: [f64; 2], fps: f64, model: Option<&dyn
|
||||
fnv(&mut h, &v.to_bits().to_le_bytes());
|
||||
}
|
||||
fnv(&mut h, &[tracks(params) as u8]);
|
||||
fnv(&mut h, model.map_or(effectcraft_segment::CLASSICAL, |m| m.info().id).as_bytes());
|
||||
fnv(&mut h, model.as_bytes());
|
||||
h
|
||||
}
|
||||
|
||||
@@ -255,9 +260,14 @@ pub fn frame_keys(params: &Params, layer_size: [f64; 2], fps: f64, frames: &[i64
|
||||
/// What frozen mattes depend on: what the frames are (the seed: Input Key, size, rate, mode,
|
||||
/// model), the prompt, the tracked span and the matte settings.
|
||||
pub fn frozen_key(params: &Params, layer_size: [f64; 2], fps: f64, model: Option<&dyn MaskModel>) -> String {
|
||||
let mut h = seed(params, layer_size, fps, model);
|
||||
frozen_key_of(params, layer_size, fps, model.map_or(effectcraft_segment::CLASSICAL, |m| m.info().id))
|
||||
}
|
||||
|
||||
/// [`frozen_key`] for the model with id `model` ([`effectcraft_segment::CLASSICAL`]: no model).
|
||||
fn frozen_key_of(params: &Params, layer_size: [f64; 2], fps: f64, model: &str) -> String {
|
||||
let mut h = seed_of(params, layer_size, fps, model);
|
||||
let base = reference_frame(params);
|
||||
if let Some(st) = prompt_strokes(params, layer_size, base, model.is_some()) {
|
||||
if let Some(st) = prompt_strokes(params, layer_size, base, model != effectcraft_segment::CLASSICAL) {
|
||||
fnv(&mut h, serde_json::to_string(&st).unwrap_or_default().as_bytes());
|
||||
}
|
||||
for v in [base, reach(params)[0], reach(params)[1]] {
|
||||
@@ -273,7 +283,9 @@ pub fn is_frozen(params: &Params) -> bool {
|
||||
}
|
||||
|
||||
/// Frame `f`'s frozen matte on a `w`×`h` grid, when the instance is frozen, the mattes still
|
||||
/// match ([`frozen_key`]) and `f` is one of them.
|
||||
/// match ([`frozen_key`]) and `f` is one of them. The mattes match under the session's `model`
|
||||
/// or under the model that froze them (which this session may not have loaded, e.g. a fresh
|
||||
/// headless render): frozen mattes need no segmentation.
|
||||
pub fn frozen_alpha(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, w: usize, h: usize, model: Option<&dyn MaskModel>) -> Option<Vec<f32>> {
|
||||
let j = s(params, FROZEN);
|
||||
if j.is_empty() {
|
||||
@@ -281,7 +293,10 @@ pub fn frozen_alpha(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, w:
|
||||
}
|
||||
let fc = frozen_cache(j)?;
|
||||
if fc.key != frozen_key(params, layer_size, fps, model) {
|
||||
return None;
|
||||
let ids = std::iter::once(effectcraft_segment::CLASSICAL).chain(effectcraft_segment::MODELS.iter().map(|m| m.id));
|
||||
if !ids.into_iter().any(|id| fc.key == frozen_key_of(params, layer_size, fps, id)) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let fr = fc.frames.get(&f)?;
|
||||
let [fw, fh] = fc.size;
|
||||
@@ -705,4 +720,28 @@ mod tests {
|
||||
p.values.insert(FROZEN.into(), Value::Str(fc2.to_json()));
|
||||
assert_eq!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 100, 80, None), None);
|
||||
}
|
||||
|
||||
/// Mattes frozen with a model stay usable in a session where that model is not loaded (a
|
||||
/// fresh headless render): they are used as stored, not re-segmented with another model.
|
||||
#[test]
|
||||
fn frozen_mattes_survive_a_session_without_their_model() {
|
||||
let mut p = params(&[
|
||||
("objectPoint", Value::Vec2([40.0, 40.0])),
|
||||
("useBox", Value::Bool(true)),
|
||||
("boxTopLeft", Value::Vec2([10.0, 10.0])),
|
||||
("boxBottomRight", Value::Vec2([90.0, 70.0])),
|
||||
("objectTracking/framesAfter", Value::Scalar(3.0)),
|
||||
]);
|
||||
p.values.insert(INPUT_KEY.into(), Value::Str("frozen-model-test".into()));
|
||||
let stub = Stub::default();
|
||||
let alpha: Vec<f32> = (0..100 * 80).map(|i| ((i % 100) < 50) as u8 as f32).collect();
|
||||
let mut fc = FrozenCache { key: frozen_key(&p, [100.0, 80.0], 25.0, Some(&stub)), size: [100, 80], ..Default::default() };
|
||||
fc.frames.insert(2, roto::freeze_frame(&alpha, &[]));
|
||||
p.values.insert(FROZEN.into(), Value::Str(fc.to_json()));
|
||||
assert!(is_frozen(&p));
|
||||
assert!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 100, 80, Some(&stub)).is_some());
|
||||
assert_eq!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 50, 40, None).map(|a| (a[20 * 50 + 5], a[20 * 50 + 45])), Some((1.0, 0.0)));
|
||||
let out = run(&p, 2);
|
||||
assert!(out.data[40 * 100 + 10][3] > 0.99 && out.data[40 * 100 + 90][3] < 0.01, "the frozen matte is used");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,7 +351,7 @@ fn hex_cell(x: f64, y: f64, r: f64) -> ((f64, f64), (i64, i64)) {
|
||||
let rr = (2.0 / 3.0 * y) / r;
|
||||
// Cube rounding.
|
||||
let (cx, cz) = (q, rr);
|
||||
let cy = -cx - cz;
|
||||
let cy = (-3f64.sqrt() / 3.0 * x - y / 3.0) / r;
|
||||
let (mut rx, ry, mut rz) = (cx.round(), cy.round(), cz.round());
|
||||
let (dx, dy, dz) = ((rx - cx).abs(), (ry - cy).abs(), (rz - cz).abs());
|
||||
if dx > dy && dx > dz {
|
||||
|
||||
@@ -223,6 +223,19 @@ fn plan_cached(akey: u64, a: &WarpAnalysis, s: &StabSettings) -> Arc<Plan> {
|
||||
p
|
||||
}
|
||||
|
||||
/// Validate and cache the default stabilization plan after analysis. Use the serialized
|
||||
/// analysis so the cached plan has exactly the same inputs as the first render, including
|
||||
/// any floating-point round trip through JSON. Other settings still get their own plan.
|
||||
pub fn prepare_default_plan(json: &str) -> bool {
|
||||
let Some((key, a)) = analysis_cached(json) else { return false };
|
||||
if a.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let fps = if a.frame_duration > 0.0 { 1.0 / a.frame_duration } else { 30.0 };
|
||||
let p = plan_cached(key, &a, &StabSettings { fps, ..Default::default() });
|
||||
p.warps.len() == a.frames.len() && p.warps.iter().all(|h| h.0.iter().flatten().all(|v| v.is_finite()))
|
||||
}
|
||||
|
||||
/// The stored analysis of an instance (`None` when not analysed).
|
||||
pub fn analysis(params: &Params) -> Option<Arc<WarpAnalysis>> {
|
||||
let j = s(params, ANALYSIS);
|
||||
@@ -601,6 +614,42 @@ mod tests {
|
||||
assert_eq!(render(&ctx, Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 }).img.data, img.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepared_default_plan_preserves_first_render_and_is_reused() {
|
||||
let json = analysis_json(25, 64.0, 48.0, |k| [((k * 7) % 5) as f64 - 2.0, k as f64 * 0.1]);
|
||||
let mut a = WarpAnalysis::from_json(&json).unwrap();
|
||||
a.start = 0.012345678901234567;
|
||||
a.tracks = (0..24)
|
||||
.map(|id| effectcraft_track::camtrack::Track2D {
|
||||
id,
|
||||
start: 0,
|
||||
pts: (0..25).map(|k| [8.0 + (id % 6) as f32 * 8.0 + ((k * 7) % 5) as f32, 8.0 + (id / 6) as f32 * 8.0 + k as f32 * 0.1]).collect(),
|
||||
})
|
||||
.collect();
|
||||
let json = a.to_json();
|
||||
let ps = params(&json, 2, 0);
|
||||
let ctx = EffectCtx { params: &ps, time: a.start, layer_size: a.size, seed: 1, adjustment: false, env: EffectEnv::default() };
|
||||
let input = Buf { img: frame([30.0, 22.0]), offset: [0.0; 2], scale: 1.0 };
|
||||
// Original flow: validate, discard the plan, then render from the stored analysis.
|
||||
let _discarded = plan(&a, &StabSettings { fps: 25.0, ..Default::default() });
|
||||
let before = render(&ctx, input.clone());
|
||||
let key = (fnv(json.as_bytes()), settings_key(&settings(&ps, 25.0)));
|
||||
plans().lock().unwrap().remove(&key);
|
||||
assert!(prepare_default_plan(&json));
|
||||
let prepared = plans().lock().unwrap().get(&key).unwrap().clone();
|
||||
assert!(!prepared.meshes.is_empty(), "exercise the Subspace Warp plan");
|
||||
let after = render(&ctx, input);
|
||||
let (_, _, rendered) = plan_at(&ps, a.start).unwrap();
|
||||
assert!(Arc::ptr_eq(&prepared, &rendered));
|
||||
assert_eq!(before.offset, after.offset);
|
||||
assert_eq!(before.scale.to_bits(), after.scale.to_bits());
|
||||
for (a, b) in before.img.data.iter().flatten().zip(after.img.data.iter().flatten()) {
|
||||
assert_eq!(a.to_bits(), b.to_bits());
|
||||
}
|
||||
assert!(!prepare_default_plan("invalid"));
|
||||
assert!(!prepare_default_plan(&WarpAnalysis::default().to_json()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_param_lookup() {
|
||||
let mut p = Params::default();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! the Warp Stabilizer, `Renderer::layer_input`) for every frame between the layer's In and Out
|
||||
//! points on a background thread, and feeds the frames to `effectcraft_track::stabilize`'s
|
||||
//! analyzer (step 1 of 2, "Analyzing in background"); step 2 ("Stabilizing") derives the
|
||||
//! stabilization plan once to validate the result. When the job finishes, the analysis is
|
||||
//! stabilization plan once to validate and cache the result. When the job finishes, the analysis is
|
||||
//! written into the effect's hidden Analysis parameter as one undo step, together with a key of
|
||||
//! what it was made from (the layer's source, In/Out, start, stretch and Time Remap). Edits that
|
||||
//! change those clear the analysis (`invalidate`, run after every edit) and queue the effect
|
||||
@@ -21,7 +21,7 @@ use effectcraft_project::{GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSour
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_render::{EvalCtx, ExprHost, FootageSource, LayerCache, Renderer};
|
||||
use effectcraft_time::Tick;
|
||||
use effectcraft_track::stabilize::{AnalyzeOpts, Analyzer, StabSettings, WarpAnalysis};
|
||||
use effectcraft_track::stabilize::{AnalyzeOpts, Analyzer, WarpAnalysis};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::tracking::lock;
|
||||
@@ -245,9 +245,8 @@ async fn run_work(w: WarpWork, shared: &WarpShared) {
|
||||
let lt0 = layer.layer_time(w.times[0]).seconds();
|
||||
let fd = if w.times.len() > 1 { layer.layer_time(w.times[1]).seconds() - lt0 } else { comp.frame_duration().seconds() };
|
||||
let a = an.finish(lt0, fd);
|
||||
// Step 2: derive a plan once (validates the motion and warms nothing else up).
|
||||
let plan = effectcraft_track::stabilize::plan(&a, &StabSettings { fps: if fd > 0.0 { 1.0 / fd } else { 30.0 }, ..Default::default() });
|
||||
if plan.warps.len() != a.frames.len() || plan.warps.iter().any(|h| h.0.iter().flatten().any(|v| !v.is_finite())) {
|
||||
// Plan from the stored JSON, exactly as rendering does, and retain it for the first render.
|
||||
if !warp_stab::prepare_default_plan(&a.to_json()) {
|
||||
return fail("the stabilization could not be computed");
|
||||
}
|
||||
*lock(&shared.result) = Some(a);
|
||||
|
||||
@@ -17,6 +17,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 mod essential;
|
||||
pub mod expr_tools;
|
||||
mod file;
|
||||
|
||||
@@ -72,9 +72,9 @@ pub fn parse_stamp(v: &Value, fps: f64) -> Option<Stamp> {
|
||||
}
|
||||
// ISO 8601: date [T| ] time [zone].
|
||||
if s.len() >= 10 && s.as_bytes()[4] == b'-' && s.as_bytes()[7] == b'-' {
|
||||
let (y, m, d) = (s[0..4].parse::<i64>().ok()?, s[5..7].parse::<i64>().ok()?, s[8..10].parse::<i64>().ok()?);
|
||||
let (y, m, d) = (s.get(0..4)?.parse::<i64>().ok()?, s.get(5..7)?.parse::<i64>().ok()?, s.get(8..10)?.parse::<i64>().ok()?);
|
||||
let mut secs = days_from_civil(y, m, d)? as f64 * 86_400.0;
|
||||
let rest = s[10..].trim_start_matches(['T', 't', ' ']);
|
||||
let rest = s.get(10..)?.trim_start_matches(['T', 't', ' ']);
|
||||
if !rest.is_empty() {
|
||||
// Zone: Z or ±hh:mm / ±hhmm.
|
||||
let (time, zone) = match rest.find(['Z', 'z', '+']).or_else(|| rest.rfind('-')) {
|
||||
@@ -229,6 +229,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn multibyte_date_is_rejected() {
|
||||
assert_eq!(parse_stamp(&json!("2026-05-0é"), 25.0), None);
|
||||
assert_eq!(parse_stamp(&json!("2026-05-01é"), 25.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_timestamps() {
|
||||
let p = |v: Value| parse_stamp(&v, 25.0).unwrap();
|
||||
|
||||
@@ -88,6 +88,21 @@ fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"path": path, "properties": props.len(), "effects": effects.len()}))
|
||||
}
|
||||
|
||||
/// Rewrite an effect property path in a preset (`effects/<effect id>[#n]/<param>`) to the current
|
||||
/// parameter id when it names a parameter that the effect has since renamed or moved into a
|
||||
/// twirl-down group. Any other path comes back unchanged.
|
||||
fn upgrade_effect_path(path: &str) -> String {
|
||||
let Some((seg, tail)) = path.strip_prefix("effects/").and_then(|r| r.split_once('/')) else { return path.to_string() };
|
||||
let effect = match seg.rsplit_once('#') {
|
||||
Some((m, n)) if !m.is_empty() && n.parse::<usize>().is_ok() => m,
|
||||
_ => seg,
|
||||
};
|
||||
match effectcraft_effects::find(effect).and_then(|spec| effectcraft_effects::migrate::current_param_id(spec, tail)) {
|
||||
Some(id) if id != tail => format!("effects/{seg}/{id}"),
|
||||
_ => path.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc: Value = match (p.get("preset"), str_p(p, "path")) {
|
||||
(Some(v), _) => v.clone(),
|
||||
@@ -117,7 +132,7 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|v| {
|
||||
let path = v["path"].as_str()?.to_string();
|
||||
let path = upgrade_effect_path(v["path"].as_str()?);
|
||||
let value: KV = serde_json::from_value(v["value"].clone()).ok()?;
|
||||
let keys: Vec<Keyframe> = serde_json::from_value(v["keys"].clone()).unwrap_or_default();
|
||||
let expr = serde_json::from_value(v["expression"].clone()).ok().flatten();
|
||||
@@ -130,16 +145,29 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let t = s.time();
|
||||
// Effect bounds per layer size the point defaults of parameters added since the preset was saved.
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let sizes: BTreeMap<LayerId, [f64; 2]> =
|
||||
ids.iter().filter_map(|lid| comp.layer(*lid).map(|l| (*lid, effectcraft_render::effect_bounds(&s.project, comp, l).0))).collect();
|
||||
let mut applied = 0;
|
||||
let mut skipped = vec![];
|
||||
s.edit("Apply Animation Preset", None, |proj, _| {
|
||||
let mut next = proj.next_id;
|
||||
for lid in &ids {
|
||||
let size = sizes.get(lid).copied().unwrap_or([100.0, 100.0]);
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
if let Some(fx) = l.props.sub_mut("effects") {
|
||||
for g in &effects {
|
||||
let mut g = g.clone();
|
||||
// Effects saved by an earlier version get the upgrade a project gets on open.
|
||||
let spec = match &g.kind {
|
||||
GroupKind::Effect { effect } => effectcraft_effects::find(effect),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(spec) = spec {
|
||||
effectcraft_effects::migrate::upgrade_instance(spec, &mut g, &mut Ids(&mut next), size);
|
||||
}
|
||||
g.reassign_uids(&mut next);
|
||||
next += 1;
|
||||
fx.children.push(Node::Group(g));
|
||||
|
||||
@@ -847,10 +847,12 @@ pub(crate) fn disk_stats(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
"layers": dc.keys(effectcraft_render::disk_cache::Kind::Layer).len(),
|
||||
"bytes": st.bytes,
|
||||
"maxBytes": st.max_bytes,
|
||||
"limitBytes": st.limit_bytes,
|
||||
"hits": st.hits,
|
||||
"misses": st.misses,
|
||||
"writes": st.writes,
|
||||
"evictions": st.evictions,
|
||||
"failed": st.failed,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
+310
-25
@@ -190,6 +190,7 @@ fn pick_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Cryptomatte layer, with the aux channels (for the manifest) and the effect's Selection.
|
||||
struct CryptoHit {
|
||||
at: (effectcraft_project::ItemId, LayerId, Tick),
|
||||
probe: std::sync::Arc<CryptoProbe>,
|
||||
aux: std::sync::Arc<effectcraft_raster::AuxChannels>,
|
||||
layer: String,
|
||||
hash: u32,
|
||||
@@ -210,13 +211,135 @@ impl CryptoHit {
|
||||
}
|
||||
}
|
||||
|
||||
/// The ID under `{x, y}` (effect space, which is layer space on footage and precomp layers) for
|
||||
/// the Cryptomatte effect `{layer?, effect?}`, from the aux channels its render reads. The point
|
||||
/// snaps to the centre of the pixel the effect's input buffer samples in a render at `scale`
|
||||
/// (the viewer's resolution).
|
||||
fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result<Option<CryptoHit>> {
|
||||
/// What a pick at one frame needs besides the point: the aux channels the Cryptomatte effect
|
||||
/// reads, its layer, and the effect's input buffer grid (scale, offset). Building it can render
|
||||
/// (a precomp's ID pass, the effects before the Cryptomatte effect); a point lookup in it is a
|
||||
/// few array reads. The session keeps the last one (see [`Session::crypto_probe_job`]).
|
||||
pub struct CryptoProbe {
|
||||
key: ProbeKey,
|
||||
grid: std::result::Result<ProbeGrid, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ProbeGrid {
|
||||
aux: std::sync::Arc<effectcraft_raster::AuxChannels>,
|
||||
layer: String,
|
||||
scale: f64,
|
||||
offset: [f64; 2],
|
||||
}
|
||||
|
||||
/// What a probe depends on: the project revision, the effect (and its index), the frame, the
|
||||
/// scale and the render prefs ([`Session::render_opts`]: nested switches, draft shadows).
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
struct ProbeKey {
|
||||
revision: u64,
|
||||
at: (effectcraft_project::ItemId, LayerId, Uid, usize),
|
||||
frame: ProbeFrame,
|
||||
scale: f64,
|
||||
prefs: (bool, bool),
|
||||
}
|
||||
|
||||
/// The frame a probe reads. On footage whose masks and effects up to the Cryptomatte effect
|
||||
/// don't change over time, its source frame: a still has one, held or remapped frames share
|
||||
/// theirs. Otherwise the comp time.
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
enum ProbeFrame {
|
||||
Source(i64),
|
||||
Comp(Tick),
|
||||
}
|
||||
|
||||
/// The session's kept probe, and the generation of the latest request: a build stores its
|
||||
/// result only while no newer request or edit came since.
|
||||
#[derive(Default)]
|
||||
pub struct CryptoProbeSlot {
|
||||
probe: Option<std::sync::Arc<CryptoProbe>>,
|
||||
want: Option<ProbeKey>,
|
||||
generation: u64,
|
||||
/// Jobs run (each on its thread), and those that built (not cancelled before they began).
|
||||
jobs: u64,
|
||||
builds: u64,
|
||||
}
|
||||
|
||||
impl CryptoProbeSlot {
|
||||
/// Drop the kept probe and cancel builds in flight.
|
||||
pub(crate) fn clear(&mut self) {
|
||||
(self.probe, self.want) = (None, None);
|
||||
self.generation += 1;
|
||||
}
|
||||
pub(crate) fn kept(&self) -> bool {
|
||||
self.probe.is_some()
|
||||
}
|
||||
pub(crate) fn builds(&self) -> (u64, u64) {
|
||||
(self.jobs, self.builds)
|
||||
}
|
||||
}
|
||||
|
||||
/// A probe built off the UI thread into the session's slot.
|
||||
pub type CryptoProbeJob = Box<dyn FnOnce() + Send>;
|
||||
|
||||
const NO_CRYPTO: &str = "this layer has no Cryptomatte channels (a multi-layer OpenEXR with Cryptomatte, or a 3D precomp)";
|
||||
|
||||
impl CryptoProbe {
|
||||
/// `None` when `live` turns false between its stages (a newer request or an edit since):
|
||||
/// a cancelled build stops early and lets go of what it read.
|
||||
fn build(r: &effectcraft_render::Renderer, key: ProbeKey, time: Tick, live: &dyn Fn() -> bool) -> Option<CryptoProbe> {
|
||||
let (cid, lid, _, pos) = key.at;
|
||||
let build = || -> std::result::Result<Option<ProbeGrid>, String> {
|
||||
let comp = r.project.comp(cid).ok_or("no composition")?;
|
||||
let layer = comp.layer(lid).ok_or("no such layer")?;
|
||||
let g = layer.effects().and_then(|fx| fx.groups().nth(pos)).ok_or("no such effect")?;
|
||||
let ctx = effectcraft_render::EvalCtx { project: r.project, comp_id: cid, comp, time, expr: r.expr, footage: None };
|
||||
let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr));
|
||||
let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset");
|
||||
let aux = r.layer_aux(&ctx, layer).ok_or(NO_CRYPTO)?;
|
||||
if !live() {
|
||||
return Ok(None);
|
||||
}
|
||||
let layer_name = aux.resolve_crypto_layer(want).ok_or(NO_CRYPTO)?;
|
||||
// The grid of the effect's input buffer (footage keeps native pixels from 3/4 scale
|
||||
// up, proxies have their own size, earlier effects can pad it), rendered and cached.
|
||||
if !live() {
|
||||
return Ok(None);
|
||||
}
|
||||
let b = r.layer_input(&ctx, layer, pos).ok_or("this layer has no pixels")?;
|
||||
Ok(Some(ProbeGrid { aux, layer: layer_name, scale: b.scale, offset: b.offset }))
|
||||
};
|
||||
// On another thread no command guard runs: a panic becomes the probe's error.
|
||||
let grid = crate::guard::guarded("Reading the Cryptomatte IDs", || Ok(build())).unwrap_or_else(|e| Err(e.to_string()));
|
||||
Some(CryptoProbe { key, grid: grid.transpose()? })
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a property in `g` other than `skip` changes over time (keyframes or an expression).
|
||||
fn moves(g: &PropGroup, skip: Option<Uid>) -> bool {
|
||||
g.children.iter().any(|c| match c {
|
||||
effectcraft_project::Node::Prop(p) => Some(p.uid) != skip && (p.is_animated() || p.has_expression()),
|
||||
effectcraft_project::Node::Group(g) => moves(g, skip),
|
||||
})
|
||||
}
|
||||
|
||||
/// [`ProbeFrame`] of the effect at index `pos` of `layer`.
|
||||
fn probe_frame(ctx: &effectcraft_render::EvalCtx, layer: &Layer, pos: usize) -> ProbeFrame {
|
||||
let comp = ProbeFrame::Comp(ctx.time);
|
||||
let effectcraft_project::LayerSource::Footage { item } = layer.source else { return comp };
|
||||
let Some(effectcraft_project::ItemKind::Footage(f)) = ctx.project.item(item).map(|i| &i.kind) else { return comp };
|
||||
let fx = layer.effects().into_iter().flat_map(|fx| fx.groups().take(pos + 1));
|
||||
let time_fx = |g: &PropGroup| matches!(&g.kind, GroupKind::Effect { effect } if effect.starts_with("ec.time."));
|
||||
let still = f.kind == effectcraft_project::FootageKind::Still;
|
||||
let blend = !still && ctx.comp.enable_frame_blending && layer.switches.frame_blend != effectcraft_project::FrameBlend::Off;
|
||||
// The Cryptomatte effect's own Selection isn't read by the probe: animated, it keeps it.
|
||||
let own = |i: usize, g: &PropGroup| if i == pos { g.prop("selection").map(|p| p.uid) } else { None };
|
||||
if blend || layer.masks().is_some_and(|m| moves(m, None)) || fx.enumerate().any(|(i, g)| moves(g, own(i, g)) || time_fx(g)) {
|
||||
return comp;
|
||||
}
|
||||
ProbeFrame::Source(if still { 0 } else { effectcraft_render::frame_position(ctx.source_time(layer), f.frame_rate).0 })
|
||||
}
|
||||
|
||||
/// The Cryptomatte effect `{layer?, effect?, scale?, time?}` names: its probe key, the comp
|
||||
/// time, its layer, group and Selection property.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn probe_key<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<(ProbeKey, Tick, &'a Layer, &'a PropGroup, &'a effectcraft_project::Property)> {
|
||||
let (cid, lid, fx_uid) = find_fx(s, p, cmd)?;
|
||||
let (Some(x), Some(y)) = (f_p(p, "x"), f_p(p, "y")) else { return Err(bad(cmd, "missing `x` / `y` (layer pixels)")) };
|
||||
let scale = f_p(p, "scale").unwrap_or(1.0);
|
||||
if !(scale.is_finite() && scale > 0.0 && scale <= 1.0) {
|
||||
return Err(bad(cmd, "`scale` is the preview's resolution factor, above 0 and at most 1"));
|
||||
@@ -227,30 +350,175 @@ fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result<Option<CryptoHit>> {
|
||||
let pos = fx.groups().position(|g| g.uid == fx_uid).ok_or_else(|| bad(cmd, "no such effect"))?;
|
||||
let g = fx.groups().nth(pos).ok_or_else(|| bad(cmd, "no such effect"))?;
|
||||
let prop = g.prop("selection").filter(|_| g.match_id == "ec.3d.cryptomatte").ok_or_else(|| bad(cmd, "not a Cryptomatte effect"))?;
|
||||
let t = super::time_p(s, p, Some(comp));
|
||||
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
|
||||
let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr));
|
||||
let r = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() });
|
||||
let aux = r.layer_aux(&ctx, layer);
|
||||
let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset");
|
||||
let Some((aux, layer_name)) = aux.and_then(|a| a.resolve_crypto_layer(want).map(|l| (a, l))) else {
|
||||
return Err(bad(cmd, "this layer has no Cryptomatte channels (a multi-layer OpenEXR with Cryptomatte, or a 3D precomp)"));
|
||||
let time = super::time_p(s, p, Some(comp));
|
||||
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time, expr: s.expr.as_deref(), footage: None };
|
||||
let frame = probe_frame(&ctx, layer, pos);
|
||||
let o = s.render_opts(Default::default());
|
||||
let prefs = (o.nested_switches, o.draft_shadows);
|
||||
Ok((ProbeKey { revision: s.revision, at: (cid, lid, fx_uid, pos), frame, scale, prefs }, time, layer, g, prop))
|
||||
}
|
||||
|
||||
/// The kept probe if it answers for the current revision, for [`keep_crypto_probe`] (taken
|
||||
/// before an undo, redo or history jump).
|
||||
pub(crate) fn kept_crypto_probe(s: &Session) -> Option<std::sync::Arc<CryptoProbe>> {
|
||||
s.crypto_probe.lock().ok()?.probe.clone().filter(|p| p.key.revision == s.revision)
|
||||
}
|
||||
|
||||
/// After an undo, redo or history jump from `before` (which dropped the probe): `kept`, the
|
||||
/// probe from before, still answers when only Cryptomatte Selections changed, which the probe
|
||||
/// doesn't read (undoing a pick, as the pick itself keeps it).
|
||||
pub(crate) fn keep_crypto_probe(s: &Session, kept: Option<std::sync::Arc<CryptoProbe>>, before: &effectcraft_project::Project) {
|
||||
let Some(p) = kept else { return };
|
||||
if !only_crypto_selections_differ(before, &s.project) {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut slot) = s.crypto_probe.lock()
|
||||
&& slot.probe.is_none()
|
||||
{
|
||||
slot.probe = Some(std::sync::Arc::new(CryptoProbe { key: ProbeKey { revision: s.revision, ..p.key }, grid: p.grid.clone() }));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether projects `a` and `b` differ at most in the Selections of Cryptomatte effects.
|
||||
fn only_crypto_selections_differ(a: &effectcraft_project::Project, b: &effectcraft_project::Project) -> bool {
|
||||
let mut x = a.clone();
|
||||
for (cid, it) in x.items.iter_mut() {
|
||||
let effectcraft_project::ItemKind::Comp(c) = &mut it.kind else { continue };
|
||||
let sels: Vec<(LayerId, Uid)> = c
|
||||
.layers
|
||||
.iter()
|
||||
.flat_map(|l| {
|
||||
l.effects()
|
||||
.into_iter()
|
||||
.flat_map(|fx| fx.groups())
|
||||
.filter(|g| g.match_id == "ec.3d.cryptomatte")
|
||||
.filter_map(move |g| g.get("selection").map(|p| (l.id, p.uid)))
|
||||
})
|
||||
.collect();
|
||||
if sels.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(bc) = b.comp(*cid) else { return false };
|
||||
let c = std::sync::Arc::make_mut(c);
|
||||
for (lid, uid) in sels {
|
||||
let Some(src) = bc.layer(lid).and_then(|l| l.props.find(uid)) else { return false };
|
||||
if let Some(dst) = c.layer_mut(lid).and_then(|l| l.props.find_mut(uid)) {
|
||||
*dst = src.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
x.same_content(b)
|
||||
}
|
||||
|
||||
/// The session's probe when it has `key`'s.
|
||||
fn cached_probe(s: &Session, key: ProbeKey) -> Option<std::sync::Arc<CryptoProbe>> {
|
||||
s.crypto_probe.lock().ok()?.probe.clone().filter(|p| p.key == key)
|
||||
}
|
||||
|
||||
/// [`Session::crypto_probe_ready`].
|
||||
pub(crate) fn crypto_probe_ready(s: &Session, p: &Value) -> Result<bool> {
|
||||
let (key, ..) = probe_key(s, p, "effect.cryptomatteAt")?;
|
||||
Ok(cached_probe(s, key).is_some())
|
||||
}
|
||||
|
||||
/// [`Session::crypto_probe_job`].
|
||||
pub(crate) fn crypto_probe_job(s: &Session, p: &Value) -> Result<Option<CryptoProbeJob>> {
|
||||
let (key, time, ..) = probe_key(s, p, "effect.cryptomatteAt")?;
|
||||
let generation = {
|
||||
let mut slot = s.crypto_probe.lock().map_err(|_| EngineError::Other("the Cryptomatte probe is unavailable".into()))?;
|
||||
if slot.probe.as_ref().is_some_and(|p| p.key == key) {
|
||||
return Ok(None);
|
||||
}
|
||||
// A request for another probe cancels the one in flight.
|
||||
if slot.want != Some(key) {
|
||||
(slot.want, slot.generation) = (Some(key), slot.generation + 1);
|
||||
}
|
||||
slot.generation
|
||||
};
|
||||
// The grid of the effect's input buffer (footage keeps native pixels from 3/4 scale up,
|
||||
// proxies have their own size, earlier effects can pad it), rendered and cached.
|
||||
let b = r.layer_input(&ctx, layer, pos).ok_or_else(|| bad(cmd, "this layer has no pixels"))?;
|
||||
let (project, footage, expr, cache, accel, slot) =
|
||||
(s.project.clone(), s.footage.clone(), s.expr.clone(), s.layer_cache.clone(), s.accel.clone(), s.crypto_probe.clone());
|
||||
let opts = s.render_opts(effectcraft_render::RenderOpts { scale: key.scale, ..Default::default() });
|
||||
// The slot while this is still the newest request.
|
||||
fn current(slot: &std::sync::Mutex<CryptoProbeSlot>, generation: u64) -> Option<std::sync::MutexGuard<'_, CryptoProbeSlot>> {
|
||||
slot.lock().ok().filter(|s| s.generation == generation)
|
||||
}
|
||||
Ok(Some(Box::new(move || {
|
||||
match slot.lock() {
|
||||
Ok(mut s) if s.generation == generation => (s.jobs, s.builds) = (s.jobs + 1, s.builds + 1),
|
||||
Ok(mut s) => return s.jobs += 1,
|
||||
Err(_) => return,
|
||||
}
|
||||
let mut r = effectcraft_render::Renderer::new(&project, footage.as_ref(), opts);
|
||||
r.expr = expr.as_deref();
|
||||
r.cache = Some(&cache);
|
||||
r.accel = accel.as_deref();
|
||||
// Checked between its stages too: a superseded build stops at the next one.
|
||||
let Some(probe) = CryptoProbe::build(&r, key, time, &|| current(&slot, generation).is_some()) else { return };
|
||||
let probe = std::sync::Arc::new(probe);
|
||||
// Only the newest request's result, and never over a newer probe.
|
||||
if let Some(mut s) = current(&slot, generation)
|
||||
&& s.probe.as_ref().is_none_or(|p| p.key.revision <= key.revision)
|
||||
{
|
||||
s.probe = Some(probe);
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
/// What a pick finds at a point.
|
||||
enum Hit {
|
||||
/// `probeOnly` and the probe isn't built yet.
|
||||
Pending,
|
||||
Nothing,
|
||||
Id(CryptoHit),
|
||||
}
|
||||
|
||||
/// The ID under `{x, y}` (effect space, which is layer space on footage and precomp layers) for
|
||||
/// the Cryptomatte effect `{layer?, effect?}`, from the aux channels its render reads. The point
|
||||
/// snaps to the centre of the pixel the effect's input buffer samples in a render at `scale`
|
||||
/// (the viewer's resolution). Without a kept probe it builds one, unless `probeOnly`.
|
||||
fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result<Hit> {
|
||||
let (key, time, layer, g, prop) = probe_key(s, p, cmd)?;
|
||||
let (Some(x), Some(y)) = (f_p(p, "x"), f_p(p, "y")) else { return Err(bad(cmd, "missing `x` / `y` (layer pixels)")) };
|
||||
let probe = match cached_probe(s, key) {
|
||||
Some(probe) => probe,
|
||||
None if b_p(p, "probeOnly").unwrap_or(false) => return Ok(Hit::Pending),
|
||||
None => {
|
||||
let r = s.renderer(effectcraft_render::RenderOpts { scale: key.scale, ..Default::default() });
|
||||
if let Ok(mut slot) = s.crypto_probe.lock() {
|
||||
slot.clear();
|
||||
slot.builds += 1;
|
||||
}
|
||||
// (Never cancelled here: nothing else runs on this thread.)
|
||||
let Some(probe) = CryptoProbe::build(&r, key, time, &|| true).map(std::sync::Arc::new) else { return Ok(Hit::Pending) };
|
||||
if let Ok(mut slot) = s.crypto_probe.lock() {
|
||||
slot.probe = Some(probe.clone());
|
||||
}
|
||||
probe
|
||||
}
|
||||
};
|
||||
let b = probe.grid.as_ref().map_err(|e| bad(cmd, e.clone()))?;
|
||||
let (bs, o) = (b.scale, b.offset);
|
||||
// The centre of its pixel under the point, at the aux point the effect reads there.
|
||||
let [ax, ay] = effectcraft_effects::aux_point(bs, o, [(x * bs + o[0]).floor() + 0.5, (y * bs + o[1]).floor() + 0.5]);
|
||||
let Some(hash) = aux.index_at(ax, ay).and_then(|i| aux.crypto_id_at(&layer_name, i)) else { return Ok(None) };
|
||||
let (selection, driven) = (params.s("selection").to_string(), prop.expr.as_ref().is_some_and(|e| e.enabled));
|
||||
Ok(Some(CryptoHit { at: (cid, lid, t), aux, layer: layer_name, hash, selection, prop: prop.uid, driven }))
|
||||
let Some(hash) = b.aux.index_at(ax, ay).and_then(|i| b.aux.crypto_id_at(&b.layer, i)) else { return Ok(Hit::Nothing) };
|
||||
let (cid, lid, ..) = key.at;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time, expr: s.expr.as_deref(), footage: None };
|
||||
let selection = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)).s("selection").to_string();
|
||||
let driven = prop.expr.as_ref().is_some_and(|e| e.enabled);
|
||||
let (aux, layer_name) = (b.aux.clone(), b.layer.clone());
|
||||
Ok(Hit::Id(CryptoHit { at: (cid, lid, time), probe: probe.clone(), aux, layer: layer_name, hash, selection, prop: prop.uid, driven }))
|
||||
}
|
||||
|
||||
/// `effect.cryptomatteAt`: the ID under a point, for the viewer's hover hint. The first call per
|
||||
/// frame renders the effect's input to learn its pixel grid (cached afterwards).
|
||||
/// frame renders the effect's input to learn its pixel grid (kept afterwards); with `probeOnly`
|
||||
/// it answers `{pending: true}` instead.
|
||||
fn crypto_at(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(crypto_hit(s, p, "effect.cryptomatteAt")?.map_or(Value::Null, |h| h.reply(&h.selection)))
|
||||
Ok(match crypto_hit(s, p, "effect.cryptomatteAt")? {
|
||||
Hit::Pending => json!({"pending": true}),
|
||||
Hit::Nothing => Value::Null,
|
||||
Hit::Id(h) => h.reply(&h.selection),
|
||||
})
|
||||
}
|
||||
|
||||
/// The Cryptomatte eyedropper: replace the effect's Selection with the ID under a point, add it
|
||||
@@ -264,7 +532,11 @@ fn pick_crypto(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"remove" => PickMode::Remove,
|
||||
m => return Err(bad(cmd, format!("unknown `mode` {m:?} (replace, add or remove)"))),
|
||||
};
|
||||
let h = crypto_hit(s, p, cmd)?.ok_or_else(|| bad(cmd, "no Cryptomatte ID there (empty or outside the layer)"))?;
|
||||
let h = match crypto_hit(s, p, cmd)? {
|
||||
Hit::Id(h) => h,
|
||||
Hit::Pending => return Ok(json!({"pending": true, "changed": false})),
|
||||
Hit::Nothing => return Err(bad(cmd, "no Cryptomatte ID there (empty or outside the layer)")),
|
||||
};
|
||||
if h.driven {
|
||||
return Err(bad(cmd, "the Selection is driven by an expression; disable it to pick"));
|
||||
}
|
||||
@@ -274,6 +546,19 @@ fn pick_crypto(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if changed {
|
||||
let (cid, lid, t) = h.at;
|
||||
s.execute("prop.set", json!({"comp": cid.0, "layer": lid.0, "prop": h.prop, "value": sel, "time": t.seconds(), "timeBase": "comp"}))?;
|
||||
// The step says what made it (Edit ▸ Undo Pick Cryptomatte, the History panel), not
|
||||
// "Change Property" like any other edit. (A changed Selection is always a new step.)
|
||||
if let Some(step) = s.history.undo.last_mut() {
|
||||
step.0 = "Pick Cryptomatte".into();
|
||||
}
|
||||
// Only the Selection changed, which the probe doesn't read: the next hover keeps it.
|
||||
// (The edit dropped it; a probe built since is newer.)
|
||||
if let Ok(mut slot) = s.crypto_probe.lock()
|
||||
&& slot.probe.is_none()
|
||||
{
|
||||
let key = ProbeKey { revision: s.revision, ..h.probe.key };
|
||||
slot.probe = Some(std::sync::Arc::new(CryptoProbe { key, grid: h.probe.grid.clone() }));
|
||||
}
|
||||
}
|
||||
let mut out = h.reply(&sel);
|
||||
out["selection"] = json!(sel);
|
||||
@@ -732,14 +1017,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Pick Cryptomatte",
|
||||
[],
|
||||
None,
|
||||
"{layer?, effect?: index|uid|name, x, y (effect space), mode?: replace|add|remove (default replace), scale? (preview resolution, default 1), time?} → {name, hash, layer, selection, changed, selected} — edits the Cryptomatte effect's Selection with the ID under the point",
|
||||
"{layer?, effect?: index|uid|name, x, y (effect space), mode?: replace|add|remove (default replace), scale? (preview resolution, default 1), time?, probeOnly?} → {name, hash, layer, selection, changed, selected} | {pending} — edits the Cryptomatte effect's Selection with the ID under the point",
|
||||
has_layers,
|
||||
pick_crypto
|
||||
),
|
||||
crate::query!(
|
||||
"effect.cryptomatteAt",
|
||||
"Cryptomatte At",
|
||||
"{layer?, effect?: index|uid|name, x, y (effect space), scale?, time?} → {name, hash, layer, selected} | null — the Cryptomatte ID under the point",
|
||||
"{layer?, effect?: index|uid|name, x, y (effect space), scale?, time?, probeOnly?: bool (don't build the probe)} → {name, hash, layer, selected} | {pending} | null — the Cryptomatte ID under the point",
|
||||
crypto_at
|
||||
),
|
||||
cmd!("effect.applyLast", "Last Effect", ["Effect"], Some("Cmd+Alt+Shift+E"), "{layers?}", has_layers, last),
|
||||
|
||||
@@ -430,6 +430,20 @@ fn collect_files(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// array or JSON lines of `{"command": id, "params": {…}}` (also accepts the control channel's
|
||||
/// `{"method": "engine.execute", "params": {"id", "params"}}`).
|
||||
fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// File ▸ Scripts ▸ Recent Script Files ▸ <script> (0 is the most recent).
|
||||
if let Some(i) = p.get("recent") {
|
||||
let i = i.as_u64().ok_or_else(|| bad("file.runScript", format!("`recent` must be an index, not {i}")))?;
|
||||
let script = match s.prefs.recent_scripts.get(usize::try_from(i).unwrap_or(usize::MAX)) {
|
||||
Some(x) => x.clone(),
|
||||
None if s.prefs.recent_scripts.is_empty() => return Err(bad("file.runScript", "no recent script files")),
|
||||
None => return Err(bad("file.runScript", format!("no recent script file #{i}"))),
|
||||
};
|
||||
let q = match script {
|
||||
crate::prefs::RecentScript::Path(path) => json!({"path": path}),
|
||||
crate::prefs::RecentScript::Name(name) => json!({"name": name}),
|
||||
};
|
||||
return run_script(s, &q);
|
||||
}
|
||||
// File ▸ Scripts ▸ <installed or sample script>.
|
||||
if let Some(name) = str_p(p, "name")
|
||||
&& p.get("path").is_none()
|
||||
@@ -446,15 +460,22 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("file.runScript", JSXBIN));
|
||||
}
|
||||
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
|
||||
let full = s.services.full_path(path);
|
||||
let code = String::from_utf8_lossy(&bytes).to_string();
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
let out = run_js(s, &code, path, false)?;
|
||||
return report_js(s, &name, out);
|
||||
let result = report_js(s, &name, out)?;
|
||||
if let Some(full) = full {
|
||||
remember_script(s, crate::prefs::RecentScript::Path(full));
|
||||
}
|
||||
return Ok(result);
|
||||
}
|
||||
let mut script_path = None;
|
||||
let steps: Vec<Value> = match (p.get("steps"), str_p(p, "path")) {
|
||||
(Some(Value::Array(a)), _) => a.clone(),
|
||||
(_, Some(path)) => {
|
||||
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
|
||||
script_path = s.services.full_path(path);
|
||||
let text = String::from_utf8_lossy(&bytes).to_string();
|
||||
match serde_json::from_str::<Value>(&text) {
|
||||
Ok(Value::Array(a)) => a,
|
||||
@@ -487,9 +508,19 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = r.map_err(|e| EngineError::Other(format!("step {} ({id}): {e}", i + 1)))?;
|
||||
results.push(r);
|
||||
}
|
||||
if let Some(path) = script_path {
|
||||
remember_script(s, crate::prefs::RecentScript::Path(path));
|
||||
}
|
||||
Ok(json!({"steps": results.len(), "results": results}))
|
||||
}
|
||||
|
||||
/// Remember a script that runs for File ▸ Scripts ▸ Recent Script Files.
|
||||
pub(super) fn remember_script(s: &mut Session, script: crate::prefs::RecentScript) {
|
||||
s.prefs.push_recent_script(script);
|
||||
s.save_prefs();
|
||||
s.prefs_revision += 1;
|
||||
}
|
||||
|
||||
/// `.jsx` / `.js` (and `.jsxbin`, rejected) are JavaScript; `.json` / `.jsonl` / other files are
|
||||
/// command scripts.
|
||||
fn is_js_script(path: &str) -> bool {
|
||||
@@ -1017,7 +1048,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Run Script File...",
|
||||
["File", "Scripts"],
|
||||
None,
|
||||
"{path (.jsx/.js JavaScript, or a .jsonl/.json command script) | name (an installed or sample script, see file.scripts.list) | steps: [{command, params}]}",
|
||||
"{path (.jsx/.js JavaScript, or a .jsonl/.json command script) | name (an installed or sample script, see file.scripts.list) | recent: index (File ▸ Scripts ▸ Recent Script Files, 0 = the most recent) | steps: [{command, params}]}",
|
||||
always,
|
||||
run_script
|
||||
),
|
||||
@@ -1035,7 +1066,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Main...",
|
||||
["File", "Interpret Footage"],
|
||||
Some("Cmd+Alt+G"),
|
||||
"{items?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start)}",
|
||||
"{items?|item?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?|premulColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start)}",
|
||||
has_footage_selection,
|
||||
interpret
|
||||
),
|
||||
@@ -1053,7 +1084,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Apply Interpretation",
|
||||
["File", "Interpret Footage"],
|
||||
Some("Cmd+Alt+V"),
|
||||
"{items?}",
|
||||
"{items?|item?}",
|
||||
has_interpretation,
|
||||
apply_interpretation
|
||||
),
|
||||
@@ -1084,7 +1115,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_footage_selection,
|
||||
replace_with_solid
|
||||
),
|
||||
cmd!("file.reloadFootage", "Reload Footage", ["File"], Some("Cmd+Alt+L"), "{items?}", has_footage_selection, reload),
|
||||
cmd!("file.reloadFootage", "Reload Footage", ["File"], Some("Cmd+Alt+L"), "{items?|item?}", has_footage_selection, reload),
|
||||
cmd!("file.revealInFinder", "Reveal in Finder", ["File"], None, "{item?}", has_footage_selection, reveal_in_finder),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Proxy...",
|
||||
["File", "Interpret Footage"],
|
||||
None,
|
||||
"{items?|item?, alpha?, matteColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?}",
|
||||
"{items?|item?, alpha?, guessAlpha?, matteColor?|premulColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?, preserveRgb?, overrideStart?, startTimecode? (as file.interpretFootage)}",
|
||||
has_proxy,
|
||||
interpret_proxy
|
||||
),
|
||||
|
||||
@@ -235,13 +235,17 @@ fn uninstall(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Run an installed or bundled script by name (File ▸ Scripts ▸ `<name>`). A ScriptUI panel run
|
||||
/// this way gets `this` = a floating palette-like panel window.
|
||||
/// this way gets `this` = a floating palette-like panel window. A script listed under File ▸
|
||||
/// Scripts runs before a panel of the same name.
|
||||
pub(crate) fn run_named(s: &mut Session, name: &str) -> Result<Value> {
|
||||
let (panel, code) = script_code(s, name, None).ok_or_else(|| bad("file.runScript", format!("no installed or bundled script `{name}`")))?;
|
||||
let found = script_code(s, name, Some(false)).or_else(|| script_code(s, name, None));
|
||||
let (panel, code) = found.ok_or_else(|| bad("file.runScript", format!("no installed or bundled script `{name}`")))?;
|
||||
if panel {
|
||||
return open_panel(s, &json!({"name": name}));
|
||||
}
|
||||
super::file_more::run_js_named(s, &code, name)
|
||||
let result = super::file_more::run_js_named(s, &code, name)?;
|
||||
super::file_more::remember_script(s, crate::prefs::RecentScript::Name(name.to_string()));
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// The code a ScriptUI panel runs with: `this` is a dockable Panel titled after the script.
|
||||
@@ -553,6 +557,175 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// File ▸ Scripts ▸ Recent Script Files: the ten scripts run last, newest first, kept in the
|
||||
/// settings; Cmd+Alt+Shift+D runs the first one again.
|
||||
#[test]
|
||||
fn recent_script_files_run_again() {
|
||||
use crate::prefs::{MAX_RECENT_SCRIPTS, PREFS_FILE, Prefs, RecentScript};
|
||||
let (mut s, root) = session_in_tmp("recent");
|
||||
assert_eq!(crate::menus::dynamic(&s, "recentScripts", &Default::default()), (vec![], Some("No Recent Script Files")));
|
||||
assert!(bad_params(&s.execute("file.runScript", json!({"recent": 0}))));
|
||||
assert_eq!(s.shortcuts().shortcut_of("file.runScript", &json!({"recent": 0})), Some("Cmd+Alt+Shift+D"));
|
||||
let tool = root.join("Tool.jsx");
|
||||
std::fs::write(&tool, "tool").unwrap();
|
||||
let tool = tool.to_string_lossy().into_owned();
|
||||
s.execute("file.runScript", json!({"path": tool})).unwrap();
|
||||
s.execute("file.runScript", json!({"name": "Rename Layers.jsx"})).unwrap();
|
||||
// A ScriptUI panel opened from File ▸ Scripts is not a recent script file.
|
||||
std::fs::write(root.join("settings").join(PANELS_DIR).join("Panel.jsx"), "panel").unwrap();
|
||||
s.execute("file.runScript", json!({"name": "Panel.jsx"})).unwrap();
|
||||
let (path, name) = (RecentScript::Path(tool.clone()), RecentScript::Name("Rename Layers.jsx".into()));
|
||||
assert_eq!(s.prefs.recent_scripts, [name.clone(), path.clone()]);
|
||||
let (e, _) = crate::menus::dynamic(&s, "recentScripts", &Default::default());
|
||||
assert_eq!(e.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(), ["Rename Layers.jsx", "Tool.jsx"]);
|
||||
assert_eq!((e[1].command.as_str(), &e[1].params), ("file.runScript", &json!({"recent": 1})));
|
||||
// Running one again runs that file and moves it to the top.
|
||||
assert_eq!(s.execute("file.runScript", json!({"recent": 1})).unwrap()["ran"], "tool");
|
||||
assert_eq!(s.prefs.recent_scripts, [path.clone(), name.clone()]);
|
||||
let sample = BUNDLED.iter().find(|b| b.0 == "Rename Layers.jsx").unwrap().3;
|
||||
assert_eq!(s.execute("file.runScript", json!({"recent": 1})).unwrap()["ran"], sample);
|
||||
assert!(bad_params(&s.execute("file.runScript", json!({"recent": 2}))));
|
||||
assert!(bad_params(&s.execute("file.runScript", json!({"recent": "Tool.jsx"}))));
|
||||
// A file named like a bundled script runs again as that file.
|
||||
let local = root.join("Rename Layers.jsx");
|
||||
std::fs::write(&local, "local").unwrap();
|
||||
s.execute("file.runScript", json!({"path": local.to_string_lossy()})).unwrap();
|
||||
assert_eq!(s.execute("file.runScript", json!({"recent": 0})).unwrap()["ran"], "local");
|
||||
assert_eq!(s.execute("file.runScript", json!({"recent": 1})).unwrap()["ran"], sample);
|
||||
// Ten at most, saved with the settings.
|
||||
let mut last = String::new();
|
||||
for i in 0..MAX_RECENT_SCRIPTS + 2 {
|
||||
let f = root.join(format!("Step {i}.jsonl"));
|
||||
std::fs::write(&f, "").unwrap();
|
||||
last = f.to_string_lossy().into_owned();
|
||||
s.execute("file.runScript", json!({"path": last})).unwrap();
|
||||
}
|
||||
assert_eq!(s.prefs.recent_scripts.len(), MAX_RECENT_SCRIPTS);
|
||||
assert_eq!(s.prefs.recent_scripts[0], RecentScript::Path(last));
|
||||
let saved = std::fs::read_to_string(root.join("settings").join(PREFS_FILE)).unwrap();
|
||||
assert_eq!(Prefs::from_json(&saved).recent_scripts, s.prefs.recent_scripts);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_script_history_uses_the_selected_script() {
|
||||
let (mut s, root) = session_in_tmp("recent-panel-collision");
|
||||
let name = "Rename Layers.jsx";
|
||||
std::fs::write(root.join("settings").join(PANELS_DIR).join(name), "panel").unwrap();
|
||||
assert!(super::scripts(&s).iter().any(|entry| entry.name == name && !entry.panel));
|
||||
assert!(script_code(&s, name, None).unwrap().0);
|
||||
let sample = BUNDLED.iter().find(|b| b.0 == name).unwrap().3;
|
||||
assert_eq!(s.execute("file.runScript", json!({"name": name})).unwrap()["ran"], sample);
|
||||
assert!(super::open_panel_names(&s).is_empty(), "the listed script runs instead of the panel");
|
||||
assert_eq!(s.prefs.recent_scripts, [crate::prefs::RecentScript::Name(name.to_string())]);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_script_history_keeps_successful_entries_when_scripts_fail() {
|
||||
fn fail_or_echo(_: &mut Session, request: &ScriptRequest) -> Value {
|
||||
if request.code == "fail" { json!({"error": {"message": "script failed"}}) } else { json!({"ran": request.code}) }
|
||||
}
|
||||
let (mut s, root) = session_in_tmp("recent-failed");
|
||||
s.script = Some(fail_or_echo);
|
||||
let good = root.join("good.jsonl");
|
||||
std::fs::write(&good, "[]").unwrap();
|
||||
s.execute("file.runScript", json!({"path": good})).unwrap();
|
||||
let remembered = s.prefs.recent_scripts.clone();
|
||||
for (name, code) in [("parse.jsonl", "{"), ("step.jsonl", r#"{"command":"does.not.exist"}"#), ("missing-command.jsonl", "{}"), ("runtime.jsx", "fail")]
|
||||
{
|
||||
let path = root.join(name);
|
||||
std::fs::write(&path, code).unwrap();
|
||||
assert!(s.execute("file.runScript", json!({"path": path})).is_err(), "{name}");
|
||||
assert_eq!(s.prefs.recent_scripts, remembered, "{name}");
|
||||
}
|
||||
std::fs::write(root.join("settings").join(SCRIPTS_DIR).join("Failed.jsx"), "fail").unwrap();
|
||||
assert!(s.execute("file.runScript", json!({"name": "Failed.jsx"})).is_err());
|
||||
assert_eq!(s.prefs.recent_scripts, remembered);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
/// Reads relative paths from a working directory that a test can change.
|
||||
struct InFolder(std::sync::Mutex<PathBuf>);
|
||||
|
||||
impl InFolder {
|
||||
fn at(&self, path: &str) -> PathBuf {
|
||||
self.0.lock().unwrap().join(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Services for InFolder {
|
||||
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
|
||||
std::fs::read(self.at(path))
|
||||
}
|
||||
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
std::fs::write(self.at(path), data)
|
||||
}
|
||||
fn full_path(&self, path: &str) -> Option<String> {
|
||||
Some(self.at(path).to_string_lossy().into_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads from a folder but can't make paths absolute.
|
||||
struct NoFullPath(PathBuf);
|
||||
|
||||
impl crate::Services for NoFullPath {
|
||||
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
|
||||
std::fs::read(self.0.join(path))
|
||||
}
|
||||
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
std::fs::write(self.0.join(path), data)
|
||||
}
|
||||
fn full_path(&self, _path: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A script whose path can't be made absolute still runs, and Recent Script Files does not
|
||||
/// keep its relative path.
|
||||
#[test]
|
||||
fn unresolved_script_paths_are_not_remembered() {
|
||||
let (mut s, root) = session_in_tmp("recent-unresolved");
|
||||
std::fs::write(root.join("Tool.jsx"), "a").unwrap();
|
||||
std::fs::write(root.join("steps.jsonl"), "[]").unwrap();
|
||||
s.services = Arc::new(NoFullPath(root.clone()));
|
||||
assert_eq!(s.execute("file.runScript", json!({"path": "Tool.jsx"})).unwrap()["ran"], "a");
|
||||
s.execute("file.runScript", json!({"path": "steps.jsonl"})).unwrap();
|
||||
assert!(s.prefs.recent_scripts.is_empty(), "{:?}", s.prefs.recent_scripts);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A script run from a relative path runs the same file again from Recent Script Files after
|
||||
/// the working directory changes, and two scripts with the same file name show their paths.
|
||||
#[test]
|
||||
fn recent_script_files_keep_the_file_that_ran() {
|
||||
let (mut s, root) = session_in_tmp("recent-relative");
|
||||
let (a, b) = (root.join("a"), root.join("b"));
|
||||
for (dir, code) in [(&a, "a"), (&b, "b")] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
std::fs::write(dir.join("Tool.jsx"), code).unwrap();
|
||||
}
|
||||
let services = Arc::new(InFolder(std::sync::Mutex::new(a.clone())));
|
||||
s.services = services.clone();
|
||||
assert_eq!(s.execute("file.runScript", json!({"path": "Tool.jsx"})).unwrap()["ran"], "a");
|
||||
*services.0.lock().unwrap() = b.clone();
|
||||
assert_eq!(s.execute("file.runScript", json!({"recent": 0})).unwrap()["ran"], "a");
|
||||
let (e, _) = crate::menus::dynamic(&s, "recentScripts", &Default::default());
|
||||
assert_eq!(e.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(), ["Tool.jsx"]);
|
||||
assert_eq!(s.execute("file.runScript", json!({"path": "Tool.jsx"})).unwrap()["ran"], "b");
|
||||
let (e, _) = crate::menus::dynamic(&s, "recentScripts", &Default::default());
|
||||
let (a, b) = (a.join("Tool.jsx").to_string_lossy().into_owned(), b.join("Tool.jsx").to_string_lossy().into_owned());
|
||||
assert_eq!(e.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(), [b.as_str(), a.as_str()]);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Native script paths are kept absolute.
|
||||
#[test]
|
||||
fn native_full_paths_are_absolute() {
|
||||
use crate::Services;
|
||||
assert!(crate::FsServices.full_path("Tool.jsx").is_some_and(|p| std::path::Path::new(&p).is_absolute()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_scripts_are_read_or_removed() {
|
||||
// A settings file in the Scripts folder that is not a script (.jsx / .js).
|
||||
|
||||
@@ -345,6 +345,7 @@ impl Session {
|
||||
let undo: Vec<(String, Arc<Project>)> = (0..k).map(|i| (a.label[new_line[i + 1]].clone(), a.state[new_line[i]].clone())).collect();
|
||||
let redo: Vec<(String, Arc<Project>)> = new_line[k + 1..].iter().rev().map(|&i| (a.label[i].clone(), a.state[i].clone())).collect();
|
||||
let edited = crate::commands::text_edit::active_editor_snapshot(self);
|
||||
let probe = crate::commands::kept_crypto_probe(self);
|
||||
let cur = std::mem::replace(&mut self.project, a.state[target].clone());
|
||||
self.keep_view_settings(&cur);
|
||||
self.history.undo = undo;
|
||||
@@ -353,6 +354,7 @@ impl Session {
|
||||
self.history.merge_key = None;
|
||||
self.sanitize_after_replacement(edited.as_ref());
|
||||
self.bump();
|
||||
crate::commands::keep_crypto_probe(self, probe, &cur);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -645,6 +645,21 @@ fn base_name(path: &str) -> String {
|
||||
path.rsplit(['/', '\\']).next().filter(|n| !n.is_empty()).unwrap_or("footage").to_string()
|
||||
}
|
||||
|
||||
/// A template entry's name, made safe to write inside a folder: a template is a file like any
|
||||
/// other and can name its entries anything. Nothing a platform reads as a separator, a drive or
|
||||
/// the folder above survives, and a name that leaves nothing usable becomes `footage` (#2123).
|
||||
fn safe_file_name(name: &str) -> String {
|
||||
let name: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '\0') { '_' } else { c }).collect();
|
||||
// `.` and `..`, and names of dots and spaces alone (Windows drops the trailing ones).
|
||||
if name.trim_matches(['.', ' ']).is_empty() { "footage".to_string() } else { name }
|
||||
}
|
||||
|
||||
/// What two entry names must differ by to be sure of two files: a filesystem may ignore case, and
|
||||
/// Win32 ignores trailing dots and spaces.
|
||||
fn same_file_key(name: &str) -> String {
|
||||
name.trim_end_matches(['.', ' ']).to_lowercase()
|
||||
}
|
||||
|
||||
/// What [`embed_footage`] did.
|
||||
#[derive(Default)]
|
||||
struct Embedded {
|
||||
@@ -738,9 +753,21 @@ fn extract_footage(s: &Session, proj: &mut Project, entries: &[(String, Vec<u8>)
|
||||
let mut used = std::collections::HashSet::new();
|
||||
for (k, v) in entries.iter().filter(|(k, _)| k.starts_with("media/")) {
|
||||
// `media/<item>/<file>` → one folder; a name another item took gets the item's id.
|
||||
let file = base_name(k);
|
||||
let item = k.trim_start_matches("media/").split('/').next().unwrap_or("0");
|
||||
let name = if used.insert(file.clone()) { file } else { format!("{item}-{file}") };
|
||||
let file = safe_file_name(&base_name(k));
|
||||
// The item's id, and only that: it is written as one ([`embed_footage`]), but a template
|
||||
// from elsewhere can hold anything there, and `..\` in it walks out of `dir` on Windows.
|
||||
let item = k.trim_start_matches("media/").split('/').next().filter(|i| i.bytes().all(|b| b.is_ascii_digit())).unwrap_or("0");
|
||||
// A name another entry has taken gets the item's id, and a number if that is taken too:
|
||||
// the names an entry can ask for are not distinct, and two files must not become one.
|
||||
// They are compared the way a filesystem might: `CLIP.png` is `clip.png` on a
|
||||
// case-insensitive volume (Windows and macOS as they come), and Win32 drops a trailing
|
||||
// dot or space, so `a.png.` is `a.png` there. The name written stays as it is.
|
||||
let mut name = file.clone();
|
||||
let mut n = 0usize;
|
||||
while !used.insert(same_file_key(&name)) {
|
||||
n += 1;
|
||||
name = if n == 1 { format!("{item}-{file}") } else { format!("{item}-{n}-{file}") };
|
||||
}
|
||||
let out = format!("{dir}/{name}");
|
||||
s.services.store_file(&out, v).map_err(|e| EngineError::Other(format!("cannot write {out}: {e}")))?;
|
||||
if let Some(imp) = &s.importer {
|
||||
@@ -970,6 +997,57 @@ mod tests {
|
||||
assert_eq!(l.iter().find(|t| t.id == "lower-third").unwrap().controls, ["Name", "Title", "Bar Color"]);
|
||||
}
|
||||
|
||||
/// #2123: a template holds its footage as `media/<item id>/<file>` entries, but a template
|
||||
/// from anywhere else can name them anything — and `..\\`, which only this platform reads as
|
||||
/// a plain character, is a separator on Windows, so a second entry with a base name already
|
||||
/// taken wrote `<dir>/..\\..\\..\\Startup\\x-evil.bat`. Whatever an entry is called, the file
|
||||
/// lands in the footage folder.
|
||||
#[test]
|
||||
fn extracted_footage_stays_in_its_folder() {
|
||||
let s = Session::default();
|
||||
let mut proj = effectcraft_project::Project::default();
|
||||
let dir = std::env::temp_dir().join(format!("ec-templates-media-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let ds = dir.to_string_lossy().into_owned();
|
||||
let entry = |k: &str| (k.to_string(), b"x".to_vec());
|
||||
let entries = [
|
||||
entry("media/0/clip.mp4"),
|
||||
// The base name is taken, so this one's "item id" is used — and it is not one.
|
||||
entry("media/..\\..\\..\\Startup\\x/clip.mp4"),
|
||||
entry("media/1/C:evil.bat"),
|
||||
// Three that collide once the first is made safe: each still gets its own file.
|
||||
entry("media/1/a:b.png"),
|
||||
entry("media/2/a_b.png"),
|
||||
entry("media/3/2-a_b.png"),
|
||||
// The same file on a case-insensitive volume, and on Windows, which drops the dot.
|
||||
entry("media/4/clip.png"),
|
||||
entry("media/5/CLIP.png"),
|
||||
entry("media/6/a.png"),
|
||||
entry("media/7/a.png."),
|
||||
];
|
||||
let written = extract_footage(&s, &mut proj, &entries, &ds).unwrap();
|
||||
assert_eq!(written.len(), entries.len());
|
||||
for w in &written {
|
||||
let name = w.strip_prefix(&format!("{ds}/")).unwrap_or_else(|| panic!("{w} is outside {ds}"));
|
||||
assert!(!name.contains(['/', '\\', ':']), "{w}");
|
||||
assert!(!name.trim_matches(['.', ' ']).is_empty(), "{w}");
|
||||
}
|
||||
// One file each, all of them in the folder, none overwritten by the next.
|
||||
assert_eq!(written.iter().collect::<std::collections::BTreeSet<_>>().len(), entries.len(), "{written:?}");
|
||||
assert_eq!(std::fs::read_dir(&dir).map(|d| d.count()).unwrap_or(0), entries.len());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
// And the names that would leave nothing to write.
|
||||
for n in ["..", ".", " .. ", "...", ""] {
|
||||
assert_eq!(safe_file_name(n), "footage", "{n:?}");
|
||||
}
|
||||
assert_eq!(safe_file_name("clip.mp4"), "clip.mp4");
|
||||
assert_eq!(safe_file_name(".hidden.mp4"), ".hidden.mp4");
|
||||
assert_eq!(safe_file_name("a/b\\c:d"), "a_b_c_d");
|
||||
// Names a filesystem may not tell apart (only the case half of this shows on macOS).
|
||||
assert_eq!(same_file_key("A.PNG. "), same_file_key("a.png"));
|
||||
assert_ne!(same_file_key("a.png"), same_file_key("b.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_opens_an_untitled_copy() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -162,6 +162,10 @@ pub struct Session {
|
||||
pub journal: Vec<(String, Value)>,
|
||||
/// Processed-layer pixels reused across frames and edits (content-keyed, never stale).
|
||||
pub layer_cache: Arc<LayerCache>,
|
||||
/// The last Cryptomatte pick probe (see [`Session::crypto_probe_job`]). Public only so that
|
||||
/// other crates can build a `Session { .. }`; its type is crate-private.
|
||||
#[doc(hidden)]
|
||||
pub crypto_probe: Arc<std::sync::Mutex<commands::CryptoProbeSlot>>,
|
||||
/// The persistent disk cache (Settings ▸ Media & Disk Cache), when enabled.
|
||||
pub disk_cache: Option<Arc<effectcraft_render::disk_cache::DiskCache>>,
|
||||
/// Settings (Preferences).
|
||||
@@ -202,6 +206,8 @@ pub struct Session {
|
||||
pub key_move: Option<KeyMove>,
|
||||
/// Reads the system's memory in the background for [`Session::memory_tick`].
|
||||
pub memory_watch: sysinfo::MemoryWatch,
|
||||
/// Reads the disk cache's free disk space in the background for [`Session::memory_tick`].
|
||||
pub disk_watch: sysinfo::DiskWatch,
|
||||
/// Switches Affect Nested Comps as last applied (a change empties the layer cache).
|
||||
pub applied_nested_switches: Option<bool>,
|
||||
/// Runs renders and analyses off the UI thread where there are no threads (the web app's
|
||||
@@ -310,6 +316,7 @@ impl Default for Session {
|
||||
events: vec![],
|
||||
journal: vec![],
|
||||
layer_cache: Arc::new(LayerCache::default()),
|
||||
crypto_probe: Default::default(),
|
||||
disk_cache: None,
|
||||
prefs: prefs::Prefs::default(),
|
||||
prefs_revision: 0,
|
||||
@@ -327,6 +334,7 @@ impl Default for Session {
|
||||
sys_memory: None,
|
||||
key_move: None,
|
||||
memory_watch: Default::default(),
|
||||
disk_watch: Default::default(),
|
||||
applied_nested_switches: None,
|
||||
offload: None,
|
||||
offloaded: vec![],
|
||||
|
||||
@@ -61,6 +61,12 @@ fn file_name(p: &str) -> String {
|
||||
std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.to_string())
|
||||
}
|
||||
|
||||
/// The labels of a recent-files list: `names[i]`, or the whole of `full[i]` when another entry
|
||||
/// has the same name, so entries for two files named `Tool.jsx` tell them apart.
|
||||
fn distinct_labels(names: Vec<String>, full: &[String]) -> Vec<String> {
|
||||
names.iter().zip(full).map(|(n, f)| if names.iter().filter(|m| *m == n).count() > 1 { f.clone() } else { n.clone() }).collect()
|
||||
}
|
||||
|
||||
fn dyn_entry(label: String, command: &str, params: Value) -> MenuEntry {
|
||||
MenuEntry { label, command: command.into(), params, shortcut: None, alt_shortcuts: vec![] }
|
||||
}
|
||||
@@ -72,26 +78,34 @@ fn holder_of(s: &Session, keys: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
/// The entries of a `@dynamic:<name>` node, and the disabled placeholder shown when there are
|
||||
/// none: `recentProjects`, `recentFootage`, `recentPresets`, `history` (undo steps, newest
|
||||
/// none: `recentProjects`, `recentFootage`, `recentPresets`, `recentScripts` (File ▸ Scripts ▸
|
||||
/// Recent Script Files), `history` (undo steps, newest
|
||||
/// first), `view3dShortcuts`, `workspaceShortcuts`, `savedWorkspaces` (Window ▸ Workspace) and
|
||||
/// `openViewers` (open compositions other than the active one), `scripts` (File ▸ Scripts) and
|
||||
/// `scriptPanels` (ScriptUI panels).
|
||||
pub fn dynamic(s: &Session, name: &str, cx: &DynCtx) -> (Vec<MenuEntry>, Option<&'static str>) {
|
||||
use serde_json::json;
|
||||
let indexed = |list: &[String], cmd: &str, stem: bool| -> Vec<MenuEntry> {
|
||||
list.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| {
|
||||
let n =
|
||||
if stem { std::path::Path::new(p).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.clone()) } else { file_name(p) };
|
||||
dyn_entry(n, cmd, json!({"index": i}))
|
||||
})
|
||||
.collect()
|
||||
let names = list
|
||||
.iter()
|
||||
.map(
|
||||
|p| if stem { std::path::Path::new(p).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.clone()) } else { file_name(p) },
|
||||
)
|
||||
.collect();
|
||||
distinct_labels(names, list).into_iter().enumerate().map(|(i, n)| dyn_entry(n, cmd, json!({"index": i}))).collect()
|
||||
};
|
||||
match name {
|
||||
"recentProjects" => (indexed(&s.prefs.recent_projects, "file.openRecent", false), Some("No Recent Projects")),
|
||||
"recentFootage" => (indexed(&s.prefs.recent_footage, "file.importRecent", false), Some("No Recent Footage")),
|
||||
"recentPresets" => (indexed(&s.prefs.recent_presets, "anim.applyRecentPreset", true), Some("No Recent Presets")),
|
||||
"recentScripts" => {
|
||||
let full: Vec<String> = s.prefs.recent_scripts.iter().map(|r| r.text().to_string()).collect();
|
||||
let names = full.iter().map(|p| file_name(p)).collect();
|
||||
(
|
||||
distinct_labels(names, &full).into_iter().enumerate().map(|(i, n)| dyn_entry(n, "file.runScript", json!({"recent": i}))).collect(),
|
||||
Some("No Recent Script Files"),
|
||||
)
|
||||
}
|
||||
"history" => (
|
||||
s.history.undo.iter().rev().enumerate().map(|(i, (l, _))| dyn_entry(format!("Undo {l}"), "edit.history", json!({"steps": i + 1}))).collect(),
|
||||
Some("No History"),
|
||||
@@ -576,6 +590,8 @@ File
|
||||
Install Script File... | file.installScript
|
||||
Install ScriptUI Panel... | file.installScriptUIPanel
|
||||
Run Script File... | file.runScript
|
||||
Recent Script Files
|
||||
@dynamic:recentScripts
|
||||
---
|
||||
Create Proxy
|
||||
Still... | file.createProxy {"kind":"still"}
|
||||
|
||||
@@ -37,6 +37,13 @@ pub trait Services: Send + Sync {
|
||||
fn is_dir(&self, path: &str) -> bool {
|
||||
std::path::Path::new(path).is_dir()
|
||||
}
|
||||
/// `path` as a list of recent files keeps it (File ▸ Scripts ▸ Recent Script Files): a
|
||||
/// relative path is made absolute against the working directory, so it opens the same file
|
||||
/// after the working directory changes. `None` when the path can't be made absolute (the
|
||||
/// working directory can't be read); the script is then not added to the list.
|
||||
fn full_path(&self, path: &str) -> Option<String> {
|
||||
std::path::absolute(path).ok().map(|p| p.to_string_lossy().into_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// Native filesystem.
|
||||
|
||||
@@ -91,6 +91,8 @@ impl Session {
|
||||
|
||||
pub fn bump(&mut self) {
|
||||
self.revision += 1;
|
||||
// A Cryptomatte probe is keyed by the revision: an older one never answers again.
|
||||
self.drop_crypto_probe();
|
||||
// Back at the saved state (undo, redo, a history jump or a rolled-back batch).
|
||||
if Arc::ptr_eq(&self.project, &self.saved_project) {
|
||||
self.saved_revision = self.revision;
|
||||
@@ -166,12 +168,14 @@ impl Session {
|
||||
pub fn undo(&mut self) -> bool {
|
||||
let Some((label, p)) = self.history.undo.pop() else { return false };
|
||||
let edited = commands::text_edit::active_editor_snapshot(self);
|
||||
let probe = commands::kept_crypto_probe(self);
|
||||
let cur = std::mem::replace(&mut self.project, p);
|
||||
self.keep_view_settings(&cur);
|
||||
self.history.redo.push((label, cur));
|
||||
self.history.merge_key = None;
|
||||
self.sanitize_after_replacement(edited.as_ref());
|
||||
self.bump();
|
||||
commands::keep_crypto_probe(self, probe, &cur);
|
||||
self.history.redo.push((label, cur));
|
||||
true
|
||||
}
|
||||
|
||||
@@ -182,12 +186,14 @@ impl Session {
|
||||
pub fn redo(&mut self) -> bool {
|
||||
let Some((label, p)) = self.history.redo.pop() else { return false };
|
||||
let edited = commands::text_edit::active_editor_snapshot(self);
|
||||
let probe = commands::kept_crypto_probe(self);
|
||||
let cur = std::mem::replace(&mut self.project, p);
|
||||
self.keep_view_settings(&cur);
|
||||
self.history.undo.push((label, cur));
|
||||
self.history.merge_key = None;
|
||||
self.sanitize_after_replacement(edited.as_ref());
|
||||
self.bump();
|
||||
commands::keep_crypto_probe(self, probe, &cur);
|
||||
self.history.undo.push((label, cur));
|
||||
true
|
||||
}
|
||||
|
||||
@@ -262,7 +268,9 @@ impl Session {
|
||||
/// (renamed / regrouped / added parameters, reordered popups; see
|
||||
/// [`effectcraft_effects::migrate`]), and a project from before
|
||||
/// [`effectcraft_project::SCHEMA_EFFECT_SPACE`] into effect space
|
||||
/// ([`effectcraft_effects::migrate::to_effect_space`]). Stamps the current schema.
|
||||
/// ([`effectcraft_effects::migrate::to_effect_space`]). Shape path operations get the
|
||||
/// properties added since ([`effectcraft_project::build::upgrade_shape_contents`]). Stamps the
|
||||
/// current schema.
|
||||
pub fn upgrade_effects(p: &mut Project) {
|
||||
let to_effect_space = p.schema < effectcraft_project::SCHEMA_EFFECT_SPACE;
|
||||
// Effect bounds per layer: (size, top-left in layer coordinates).
|
||||
@@ -285,6 +293,9 @@ pub fn upgrade_effects(p: &mut Project) {
|
||||
Some(id) => bounds.get(&(*cid, effectcraft_project::LayerId(id))).map_or([0.0; 2], |b| shift(b.1)),
|
||||
None => d,
|
||||
};
|
||||
if let Some(contents) = l.props.sub_mut("contents") {
|
||||
effectcraft_project::build::upgrade_shape_contents(&mut effectcraft_project::build::Ids(&mut next), contents);
|
||||
}
|
||||
let Some(fx) = l.props.sub_mut("effects") else { continue };
|
||||
for n in &mut fx.children {
|
||||
let effectcraft_project::Node::Group(g) = n else { continue };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{EngineError, Image, ItemId, RenderOpts, Renderer, Result, Session, ThumbnailJob, Tick};
|
||||
use super::{EngineError, Image, ItemId, RenderOpts, Renderer, Result, Session, ThumbnailJob, Tick, Value, commands};
|
||||
|
||||
impl Session {
|
||||
/// Render a comp frame with the session's footage + expression hosts.
|
||||
@@ -9,14 +9,54 @@ impl Session {
|
||||
/// The renderer [`Session::render`] uses: the session's footage, expressions, layer cache and
|
||||
/// accelerator, and the render prefs (nested switches, draft shadows) over `opts`.
|
||||
pub fn renderer(&self, opts: RenderOpts) -> Renderer<'_> {
|
||||
let opts = RenderOpts { nested_switches: self.prefs.general.switches_affect_nested_comps, draft_shadows: self.prefs.three_d.realtime_shadows, ..opts };
|
||||
let mut r = Renderer::new(&self.project, self.footage.as_ref(), opts);
|
||||
let mut r = Renderer::new(&self.project, self.footage.as_ref(), self.render_opts(opts));
|
||||
r.expr = self.expr.as_deref();
|
||||
r.cache = Some(&self.layer_cache);
|
||||
r.accel = self.accel.as_deref();
|
||||
r
|
||||
}
|
||||
|
||||
/// `opts` with the render prefs (nested switches, draft shadows), as [`Session::renderer`].
|
||||
pub fn render_opts(&self, opts: RenderOpts) -> RenderOpts {
|
||||
RenderOpts { nested_switches: self.prefs.general.switches_affect_nested_comps, draft_shadows: self.prefs.three_d.realtime_shadows, ..opts }
|
||||
}
|
||||
|
||||
/// Whether `effect.cryptomatteAt` with params `p` answers from the kept probe (a few array
|
||||
/// reads; `probeOnly` queries answer `pending` until then). Changes nothing: ask it every
|
||||
/// frame, and start [`Session::crypto_probe_job`] only when a build will actually run.
|
||||
pub fn crypto_probe_ready(&self, p: &Value) -> Result<bool> {
|
||||
commands::crypto_probe_ready(self, p)
|
||||
}
|
||||
|
||||
/// For the Cryptomatte eyedropper: `None` when `effect.cryptomatteAt` with params `p` answers
|
||||
/// from the kept probe, else a job that builds that probe (it may render a precomp's ID pass)
|
||||
/// off the UI thread, for the next query to use. Asking for a job supersedes a build in
|
||||
/// flight for another probe (it then stores nothing): ask only to run it.
|
||||
pub fn crypto_probe_job(&self, p: &Value) -> Result<Option<commands::CryptoProbeJob>> {
|
||||
commands::crypto_probe_job(self, p)
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
if let Ok(mut slot) = self.crypto_probe.lock() {
|
||||
slot.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a Cryptomatte probe is kept, and how many were built so far (for tests).
|
||||
#[doc(hidden)]
|
||||
pub fn crypto_probe_stats(&self) -> (bool, u64) {
|
||||
self.crypto_probe.lock().map_or((false, 0), |s| (s.kept(), s.builds().1))
|
||||
}
|
||||
|
||||
/// How many probe jobs ran so far, built or cancelled before they began (for tests: each
|
||||
/// is a thread in the eyedropper).
|
||||
#[doc(hidden)]
|
||||
pub fn crypto_probe_jobs(&self) -> u64 {
|
||||
self.crypto_probe.lock().map_or(0, |s| s.builds().0)
|
||||
}
|
||||
|
||||
/// Render a comp frame (comp time `t`) as opaque 8-bit RGBA over the comp background, scaled
|
||||
/// so the longest side is at most `max_side` pixels (0 = full size). Returns (width, height, rgba).
|
||||
pub fn render_rgba8(&self, comp: ItemId, t: Tick, max_side: u32) -> Result<(u32, u32, Vec<u8>)> {
|
||||
@@ -95,5 +135,6 @@ impl Session {
|
||||
self.footage.forget(p);
|
||||
}
|
||||
self.layer_cache.clear();
|
||||
self.drop_crypto_probe();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ pub use choices::{APPEARANCE_MODES, DARK_THEMES, LANGUAGES, LIGHT_THEMES, THEMES
|
||||
pub use dialog::{Item, Kind, Page, pages, pages_json, todo_keys};
|
||||
pub use document::{migrate, page_id, section_key};
|
||||
pub use queries::{CacheBudgets, WEB_LAYER_CACHE, WEB_MEDIA_CACHE, mask_color};
|
||||
pub use recent::{MAX_RECENT_SCRIPTS, RecentScript};
|
||||
pub use sections::{
|
||||
Appearance, Audio, AutoSave, CompPreset, CompositionPrefs, Disk, Export, FacePrefs, General, Grids, Import, LabelPref, Memory, Previews, ProjectPrefs,
|
||||
RotoPrefs, Scripting, Startup, ThreeD, TypePrefs, Video,
|
||||
@@ -35,7 +36,7 @@ pub use sections::{
|
||||
use sections::{Extra, default_labels, lenient_comp_presets};
|
||||
|
||||
/// Current settings layout version.
|
||||
pub const PREFS_VERSION: u32 = 2;
|
||||
pub const PREFS_VERSION: u32 = 3;
|
||||
/// Settings file name in the config store.
|
||||
pub const PREFS_FILE: &str = "prefs.json";
|
||||
|
||||
@@ -74,6 +75,8 @@ pub struct Prefs {
|
||||
/// Animation ▸ Recent Animation Presets (`.ecpreset` paths or built-in preset ids), newest
|
||||
/// first.
|
||||
pub recent_presets: Vec<String>,
|
||||
/// File ▸ Scripts ▸ Recent Script Files, newest first, at most [`MAX_RECENT_SCRIPTS`].
|
||||
pub recent_scripts: Vec<RecentScript>,
|
||||
/// Character panel: recently used font families, newest first.
|
||||
pub recent_fonts: Vec<String>,
|
||||
/// Effects & Presets: the starred effects' ids, in the order starred.
|
||||
@@ -117,6 +120,7 @@ impl Default for Prefs {
|
||||
recent_projects: vec![],
|
||||
recent_footage: vec![],
|
||||
recent_presets: vec![],
|
||||
recent_scripts: vec![],
|
||||
recent_fonts: vec![],
|
||||
effects_favorites: vec![],
|
||||
preview: crate::preview::PreviewSettings::default(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::choices::{APPEARANCE_MODES, DARK_THEMES, LANGUAGES, LIGHT_THEMES, THEMES, WINDOW_GRAPHICS, choices, is_choice, theme_family};
|
||||
use super::recent::MAX_EFFECTS_FAVORITES;
|
||||
use super::recent::{MAX_EFFECTS_FAVORITES, MAX_RECENT_SCRIPTS};
|
||||
use super::sections::{CompPreset, default_labels, parse_hex};
|
||||
use super::{Appearance, General, Map, PREFS_VERSION, Prefs, Startup, Value, json};
|
||||
|
||||
@@ -7,6 +7,8 @@ use super::{Appearance, General, Map, PREFS_VERSION, Prefs, Startup, Value, json
|
||||
///
|
||||
/// - version 0/1 (early builds) kept a flat object: `undoLevels`, `autoSaveEnabled`,
|
||||
/// `autoSaveMinutes`, `autoSaveVersions`, `labelNames` (array) and `theme` at the top level.
|
||||
/// - version 2 defaulted the disk cache to 100 GB, more than most laptops have free; the stored
|
||||
/// old default becomes the new one (a size the user chose is kept).
|
||||
pub fn migrate(mut v: Value) -> Value {
|
||||
let Some(obj) = v.as_object_mut() else { return json!({}) };
|
||||
let version = obj.get("version").and_then(Value::as_u64).unwrap_or(0);
|
||||
@@ -34,6 +36,12 @@ pub fn migrate(mut v: Value) -> Value {
|
||||
obj.insert("labels".into(), serde_json::to_value(labels).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
if version < 3
|
||||
&& let Some(d) = obj.get_mut("disk").and_then(Value::as_object_mut)
|
||||
&& d.get("diskCacheMaxGb").and_then(Value::as_u64) == Some(100)
|
||||
{
|
||||
d.insert("diskCacheMaxGb".into(), json!(20));
|
||||
}
|
||||
// Settings saved before appearance modes had one theme: keep showing it, as a fixed Dark or
|
||||
// Light mode, instead of switching established users to Auto.
|
||||
if let Some(a) = obj.get_mut("appearance").and_then(Value::as_object_mut)
|
||||
@@ -138,6 +146,7 @@ impl Prefs {
|
||||
self.grids.grid_spacing = self.grids.grid_spacing.clamp(1.0, 10_000.0);
|
||||
let n = self.general.recent_items as usize;
|
||||
self.recent_projects.truncate(n);
|
||||
self.recent_scripts.truncate(MAX_RECENT_SCRIPTS);
|
||||
}
|
||||
|
||||
/// The value at a dotted key (`general.undoLevels`, `labels.3.color`); `""` = everything.
|
||||
@@ -203,6 +212,7 @@ impl Prefs {
|
||||
recent_projects: std::mem::take(&mut self.recent_projects),
|
||||
recent_footage: std::mem::take(&mut self.recent_footage),
|
||||
recent_presets: std::mem::take(&mut self.recent_presets),
|
||||
recent_scripts: std::mem::take(&mut self.recent_scripts),
|
||||
recent_fonts: std::mem::take(&mut self.recent_fonts),
|
||||
effects_favorites: std::mem::take(&mut self.effects_favorites),
|
||||
preview: std::mem::take(&mut self.preview),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::Prefs;
|
||||
|
||||
impl Prefs {
|
||||
@@ -45,6 +47,34 @@ impl Prefs {
|
||||
pub fn push_recent_preset(&mut self, path: &str) {
|
||||
push_mru(&mut self.recent_presets, path, self.general.recent_items as usize);
|
||||
}
|
||||
|
||||
/// Remember a script that ran for File ▸ Scripts ▸ Recent Script Files.
|
||||
pub fn push_recent_script(&mut self, script: RecentScript) {
|
||||
self.recent_scripts.retain(|x| *x != script);
|
||||
self.recent_scripts.insert(0, script);
|
||||
self.recent_scripts.truncate(MAX_RECENT_SCRIPTS);
|
||||
}
|
||||
}
|
||||
|
||||
/// Most scripts File ▸ Scripts ▸ Recent Script Files lists.
|
||||
pub const MAX_RECENT_SCRIPTS: usize = 10;
|
||||
|
||||
/// A File ▸ Scripts ▸ Recent Script Files entry, stored as the `file.runScript` parameter that
|
||||
/// ran it: `{"path": …}` for a script file, `{"name": …}` for an installed or bundled script.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RecentScript {
|
||||
Path(String),
|
||||
Name(String),
|
||||
}
|
||||
|
||||
impl RecentScript {
|
||||
/// The file's path or the script's name.
|
||||
pub fn text(&self) -> &str {
|
||||
match self {
|
||||
RecentScript::Path(s) | RecentScript::Name(s) => s,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Most effects Effects & Presets keeps starred (a hand-edited settings file can hold more).
|
||||
|
||||
@@ -179,7 +179,7 @@ page!(Audio {
|
||||
|
||||
page!(Disk {
|
||||
disk_cache_enabled: bool = true,
|
||||
disk_cache_max_gb: u32 = 100,
|
||||
disk_cache_max_gb: u32 = 20,
|
||||
disk_cache_folder: String = String::new(),
|
||||
media_cache_folder: String = String::new(),
|
||||
conformed_media_folder: String = String::new(),
|
||||
|
||||
@@ -82,7 +82,9 @@ impl Session {
|
||||
/// Take the latest reading of the system's memory and start the next one in the background
|
||||
/// (desktop frontends call this every few seconds; it never waits for the system), then
|
||||
/// re-apply the cache budgets when the reading changes them. Returns the effective budgets.
|
||||
/// The disk cache's free disk space is read the same way (it keeps a reserve free).
|
||||
pub fn memory_tick(&mut self) -> crate::prefs::CacheBudgets {
|
||||
self.poll_disk_space();
|
||||
let before = self.prefs.cache_budgets(self.sys_memory);
|
||||
self.sys_memory = self.memory_watch.latest().or(self.sys_memory);
|
||||
self.memory_watch.poll();
|
||||
@@ -98,6 +100,19 @@ impl Session {
|
||||
after
|
||||
}
|
||||
|
||||
/// Read the disk cache's free disk space in the background and apply it there (the reserve
|
||||
/// it keeps free): the free space and the cache's size are taken at the same moment, and the
|
||||
/// reader holds its own cache, so a reading never reaches another folder's.
|
||||
fn poll_disk_space(&mut self) {
|
||||
if let Some(dc) = &self.disk_cache {
|
||||
let dc = dc.clone();
|
||||
self.disk_watch.poll_with("ec-disk-space", move || {
|
||||
dc.set_disk_space(crate::sysinfo::disk_space(dc.folder()));
|
||||
Some(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply Settings ▸ Media & Disk Cache ▸ Disk Cache: open (or close) the persistent cache
|
||||
/// and attach it to the layer cache. Without a folder setting the platform cache folder is
|
||||
/// used, but only by frontends with a settings store (headless sessions stay off disk).
|
||||
@@ -113,6 +128,7 @@ impl Session {
|
||||
// (no file system in the browser: the disk cache stays off there)
|
||||
let Some(folder) = folder.filter(|_| d.disk_cache_enabled && !cfg!(target_arch = "wasm32")) else {
|
||||
self.disk_cache = None;
|
||||
self.disk_watch = Default::default();
|
||||
self.layer_cache.set_disk(None);
|
||||
return;
|
||||
};
|
||||
@@ -126,17 +142,25 @@ impl Session {
|
||||
c.set_max_bytes(max);
|
||||
c.clone()
|
||||
}
|
||||
// A new folder: the free-space reading was for the old one (a reader still running
|
||||
// holds the old watcher's state, so it can't write into the new one).
|
||||
_ => match DiskCache::open(&folder, max) {
|
||||
Ok(c) => c,
|
||||
Ok(c) => {
|
||||
self.disk_watch = Default::default();
|
||||
c
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("disk cache at {}: {e}", folder.display());
|
||||
self.disk_cache = None;
|
||||
self.disk_watch = Default::default();
|
||||
self.layer_cache.set_disk(None);
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
self.disk_cache = Some(dc.clone());
|
||||
// The reserve applies from the start (a new folder's watcher is idle).
|
||||
self.poll_disk_space();
|
||||
let salt = self.refresh_disk_salt(true).unwrap_or_default();
|
||||
self.layer_cache.set_disk(Some((dc.clone(), salt)));
|
||||
// Roto Brush / Object Matte segmentations persist there too (held weakly: switching the
|
||||
|
||||
@@ -105,6 +105,21 @@ pub fn bindables(ui: &[UiCommand]) -> Vec<Bindable> {
|
||||
&mut out,
|
||||
);
|
||||
}
|
||||
// File ▸ Scripts ▸ Recent Script Files lists its scripts at run time; the first one, the most
|
||||
// recent, has a key.
|
||||
let recent = json!({"recent": 0});
|
||||
push(
|
||||
Bindable {
|
||||
key: binding_key("file.runScript", &recent),
|
||||
command: "file.runScript".into(),
|
||||
params: recent,
|
||||
label: "Run Most Recent Script File".into(),
|
||||
path: vec!["File".into(), "Scripts".into(), "Recent Script Files".into()],
|
||||
scope: APP_SCOPE.into(),
|
||||
defaults: normalize("Cmd+Alt+Shift+D").into_iter().collect(),
|
||||
},
|
||||
&mut out,
|
||||
);
|
||||
for c in crate::command_specs() {
|
||||
if !c.journal || entries.iter().any(|(_, e)| e.command == c.id) {
|
||||
continue;
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
//! terminal, so that file is what a bug report can attach. The last few hundred warnings and
|
||||
//! errors are also kept in memory for the System Compatibility Report.
|
||||
//!
|
||||
//! Levels: `info` for EffectCraft's own crates, `warn` for everything else (wgpu and naga are
|
||||
//! chatty). `RUST_LOG` replaces that (a level such as `debug`, or per target:
|
||||
//! Levels by default ([`DEFAULT_FILTER`]): `info` for EffectCraft's own crates, `warn` for
|
||||
//! everything else (wgpu and naga are chatty). `RUST_LOG` replaces that (a level such as `debug`, or per target:
|
||||
//! `effectcraft=debug,wgpu=warn`).
|
||||
//!
|
||||
//! Help ▸ Enable Logging ([`set_enabled`], the `help.enableLogging` command) additionally appends
|
||||
//! every EffectCraft record to `Logs/EffectCraft Log.txt`, across launches.
|
||||
//!
|
||||
//! Frontends call [`install`] once at startup and the desktop app [`attach_session_log`] once the
|
||||
//! settings folder is known; records logged before that are kept (up to [`MAX_PENDING`]) and
|
||||
//! Frontends call [`install`] (or [`install_with`] for another default, as the CLI does with `warn`)
|
||||
//! once at startup and the desktop app [`attach_session_log`] once the settings folder is known; records logged before that are kept (up to [`MAX_PENDING`]) and
|
||||
//! written first. Writing never panics: a file that can't be created or written leaves standard
|
||||
//! error as the only sink. Never log the control-channel token.
|
||||
|
||||
@@ -131,11 +131,21 @@ fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message:
|
||||
|
||||
/// Install the logger (once; later calls and other installed loggers are left alone).
|
||||
pub fn install() {
|
||||
install_with(DEFAULT_FILTER);
|
||||
}
|
||||
|
||||
/// The directive string: `RUST_LOG` when it is set and nonblank, otherwise `default`.
|
||||
fn filter_spec<'a>(rust_log: Option<&'a str>, default: &'a str) -> &'a str {
|
||||
rust_log.filter(|s| !s.trim().is_empty()).unwrap_or(default)
|
||||
}
|
||||
|
||||
/// [`install`] with `default` as the filter when `RUST_LOG` is unset or empty.
|
||||
pub fn install_with(default: &str) {
|
||||
static LOGGER: Logger = Logger;
|
||||
if log::set_logger(&LOGGER).is_ok() {
|
||||
let filter = FILTER.get_or_init(|| {
|
||||
let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
|
||||
parse_filter(spec.as_deref().unwrap_or(DEFAULT_FILTER))
|
||||
let spec = std::env::var("RUST_LOG").ok();
|
||||
parse_filter(filter_spec(spec.as_deref(), default))
|
||||
});
|
||||
log::set_max_level(filter.iter().map(|d| d.1).fold(LevelFilter::Info, Ord::max));
|
||||
}
|
||||
@@ -357,6 +367,21 @@ mod tests {
|
||||
assert_eq!(level_for(&f, "naga"), LevelFilter::Warn);
|
||||
}
|
||||
|
||||
/// The CLI's default (`warn`): warnings from every crate, no info chatter on stderr,
|
||||
/// while a nonblank `RUST_LOG` overrides `default`.
|
||||
#[test]
|
||||
fn the_cli_filter_shows_only_warnings() {
|
||||
assert_eq!(filter_spec(None, "warn"), "warn");
|
||||
assert_eq!(filter_spec(Some(" "), "warn"), "warn");
|
||||
assert_eq!(filter_spec(Some("info"), "warn"), "info");
|
||||
let f = parse_filter(filter_spec(None, "warn"));
|
||||
assert_eq!(level_for(&f, "effectcraft_gpu::context"), LevelFilter::Warn);
|
||||
assert_eq!(level_for(&f, "effectcraft_engine::render_queue"), LevelFilter::Warn);
|
||||
assert_eq!(level_for(&f, "wgpu_core::device"), LevelFilter::Warn);
|
||||
let over = parse_filter(filter_spec(Some("info"), "warn"));
|
||||
assert_eq!(level_for(&over, "effectcraft_gpu::context"), LevelFilter::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_utc_with_milliseconds() {
|
||||
assert_eq!(timestamp(0), "1970-01-01T00:00:00.000Z");
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
//! Host information for Settings ▸ Memory & CPU (RAM reserved for other applications, reducing
|
||||
//! caches when the system is low on memory) and Help ▸ System Compatibility Report.
|
||||
//! caches when the system is low on memory), the disk cache's free-space reserve and Help ▸
|
||||
//! System Compatibility Report.
|
||||
//!
|
||||
//! Pure Rust and dependency-free: the values come from the operating system's own reporting
|
||||
//! (`/proc/meminfo` on Linux, `sysctl`/`vm_stat` on macOS, `wmic`/PowerShell on Windows). Every
|
||||
//! query may fail (sandboxes, the web): callers treat `None` as "unknown" and keep their
|
||||
//! configured budgets. The periodic reading behind the cache budgets runs on a background
|
||||
//! thread ([`MemoryWatch`]): on Windows it starts PowerShell, which takes about a second.
|
||||
//! (`/proc/meminfo` on Linux, `sysctl`/`vm_stat` on macOS, `df` on Unix, `wmic`/PowerShell on
|
||||
//! Windows). Every query may fail (sandboxes, the web): callers treat `None` as "unknown" and
|
||||
//! keep their configured budgets. The periodic readings behind the cache budgets run on a
|
||||
//! background thread ([`Watch`]): on Windows they start PowerShell, which takes about a second.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
@@ -27,47 +28,74 @@ impl SysMemory {
|
||||
}
|
||||
}
|
||||
|
||||
/// The system's memory, read in the background: [`MemoryWatch::poll`] starts a reading (unless
|
||||
/// one is running) and [`MemoryWatch::latest`] returns the last one without waiting.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct MemoryWatch {
|
||||
latest: Arc<Mutex<Option<SysMemory>>>,
|
||||
/// A system value read in the background: [`Watch::poll_with`] starts a reading (unless one is
|
||||
/// running) and [`Watch::latest`] returns the last one without waiting.
|
||||
#[derive(Clone)]
|
||||
pub struct Watch<T> {
|
||||
latest: Arc<Mutex<Option<T>>>,
|
||||
busy: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl MemoryWatch {
|
||||
/// Start a new reading in the background (no threads on the web: read inline, it is
|
||||
/// unknown there anyway).
|
||||
pub fn poll(&self) {
|
||||
impl<T> Default for Watch<T> {
|
||||
fn default() -> Self {
|
||||
Watch { latest: Arc::new(Mutex::new(None)), busy: Arc::new(AtomicBool::new(false)) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The system's memory ([`memory`]).
|
||||
pub type MemoryWatch = Watch<SysMemory>;
|
||||
/// The disk cache's free-space reading ([`disk_space`]): the reader applies it to its cache
|
||||
/// itself, so only the busy guard is used.
|
||||
pub type DiskWatch = Watch<()>;
|
||||
|
||||
impl<T: Copy + Send + 'static> Watch<T> {
|
||||
/// Start a new reading in the background on a thread called `name` (no threads on the web:
|
||||
/// read inline, it is unknown there anyway).
|
||||
pub fn poll_with(&self, name: &str, read: impl FnOnce() -> Option<T> + Send + 'static) {
|
||||
if self.busy.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
let (latest, busy) = (self.latest.clone(), self.busy.clone());
|
||||
let read = move || {
|
||||
if let Some(m) = memory() {
|
||||
*latest.lock().unwrap_or_else(PoisonError::into_inner) = Some(m);
|
||||
}
|
||||
// A failed reading clears the last one: a stale value is worse than none.
|
||||
*latest.lock().unwrap_or_else(PoisonError::into_inner) = read();
|
||||
busy.store(false, Ordering::Release);
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if std::thread::Builder::new().name("ec-memory".into()).spawn(read).is_err() {
|
||||
if std::thread::Builder::new().name(name.into()).spawn(read).is_err() {
|
||||
self.busy.store(false, Ordering::Release);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
read();
|
||||
{
|
||||
let _ = name;
|
||||
read();
|
||||
}
|
||||
}
|
||||
|
||||
/// The last reading (`None` until one finished).
|
||||
pub fn latest(&self) -> Option<SysMemory> {
|
||||
/// The last reading (`None` until one finished or when the last one failed).
|
||||
pub fn latest(&self) -> Option<T> {
|
||||
*self.latest.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
}
|
||||
|
||||
impl MemoryWatch {
|
||||
/// Start a new reading of the system's memory in the background.
|
||||
pub fn poll(&self) {
|
||||
self.poll_with("ec-memory", memory);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[allow(dead_code)]
|
||||
fn run(cmd: &str, args: &[&str]) -> Option<String> {
|
||||
run_env(cmd, args, &[])
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[allow(dead_code)]
|
||||
fn run_env(cmd: &str, args: &[&str], env: &[(&str, &std::ffi::OsStr)]) -> Option<String> {
|
||||
let mut c = std::process::Command::new(cmd);
|
||||
c.args(args);
|
||||
c.args(args).envs(env.iter().copied());
|
||||
// A console program started from the windowed app would flash a console window.
|
||||
#[cfg(target_os = "windows")]
|
||||
std::os::windows::process::CommandExt::creation_flags(&mut c, 0x0800_0000); // CREATE_NO_WINDOW
|
||||
@@ -139,6 +167,46 @@ pub fn memory() -> Option<SysMemory> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse `df -Pk` output: the disk's total and available bytes.
|
||||
pub fn parse_df(text: &str) -> Option<(u64, u64)> {
|
||||
let line = text.lines().skip(1).find(|l| !l.trim().is_empty())?;
|
||||
let f: Vec<&str> = line.split_whitespace().collect();
|
||||
// A full disk into its root-reserved blocks reports negative numbers (Linux): none free.
|
||||
let num = |v: &str| match v.strip_prefix('-') {
|
||||
Some(d) => d.parse::<u64>().ok().map(|_| 0),
|
||||
None => v.parse::<u64>().ok(),
|
||||
};
|
||||
// Size, used, available and capacity follow the device name (which may contain spaces).
|
||||
let i = (0..f.len().saturating_sub(3))
|
||||
.find(|&i| f.get(i + 3).is_some_and(|c| c.ends_with('%')) && f.get(i..i + 3).is_some_and(|n| n.iter().all(|v| num(v).is_some())))?;
|
||||
let kb = |j: usize| f.get(j).and_then(|v| num(v)).map(|v| v.saturating_mul(1024));
|
||||
Some((kb(i)?, kb(i + 2)?))
|
||||
}
|
||||
|
||||
/// Total and available bytes of the disk holding `path`, when the system reports them.
|
||||
pub fn disk_space(path: &std::path::Path) -> Option<(u64, u64)> {
|
||||
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))]
|
||||
{
|
||||
return parse_df(&run("df", &["-Pk", &path.to_string_lossy()])?);
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// The path goes through the environment: no quoting.
|
||||
let text = run_env(
|
||||
"powershell",
|
||||
&["-NoProfile", "-Command", "$d=[IO.DriveInfo]::new($env:EC_DISK_PATH); \"$($d.TotalSize) $($d.AvailableFreeSpace)\""],
|
||||
&[("EC_DISK_PATH", path.as_os_str())],
|
||||
)?;
|
||||
let mut it = text.split_whitespace().filter_map(|v| v.parse::<u64>().ok());
|
||||
return Some((it.next()?, it.next()?));
|
||||
}
|
||||
#[allow(unreachable_code)]
|
||||
{
|
||||
let _ = path;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// CPU model name.
|
||||
pub fn cpu_name() -> Option<String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -225,6 +293,42 @@ mod tests {
|
||||
assert_eq!(m.available, 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_reading_clears_the_last_one() {
|
||||
let w = Watch::<u64>::default();
|
||||
let wait = || {
|
||||
for _ in 0..500 {
|
||||
if !w.busy.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
panic!("the reading never finished");
|
||||
};
|
||||
w.poll_with("ec-test", || Some(7));
|
||||
wait();
|
||||
assert_eq!(w.latest(), Some(7));
|
||||
w.poll_with("ec-test", || None);
|
||||
wait();
|
||||
assert_eq!(w.latest(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_df() {
|
||||
let linux = "Filesystem 1024-blocks Used Available Capacity Mounted on\n/dev/nvme0n1p2 488281250 123456789 364824461 26% /\n";
|
||||
assert_eq!(parse_df(linux), Some((488_281_250 * 1024, 364_824_461 * 1024)));
|
||||
// macOS: a device name with spaces and a mount point with spaces.
|
||||
let mac = "Filesystem 1024-blocks Used Available Capacity Mounted on\nmap auto_home 0 0 0 100% /System/Volumes/Data/home\n";
|
||||
assert_eq!(parse_df(mac), Some((0, 0)));
|
||||
let spaced = "Filesystem 1024-blocks Used Available Capacity Mounted on\n//nas/My Share 1000 400 600 40% /Volumes/My Share\n";
|
||||
assert_eq!(parse_df(spaced), Some((1000 * 1024, 600 * 1024)));
|
||||
// A full disk into its reserved blocks: nothing available.
|
||||
let full = "Filesystem 1024-blocks Used Available Capacity Mounted on\n/dev/sda2 1000 1010 -10 101% /\n";
|
||||
assert_eq!(parse_df(full), Some((1000 * 1024, 0)));
|
||||
assert_eq!(parse_df("Filesystem 1024-blocks\n"), None);
|
||||
assert_eq!(parse_df(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_proc_meminfo_and_vm_stat() {
|
||||
let m = parse_meminfo("MemTotal: 16000000 kB\nMemFree: 1000 kB\nMemAvailable: 8000000 kB\n").unwrap();
|
||||
|
||||
@@ -334,3 +334,454 @@ fn pick_matches_the_render_on_precomps_with_and_without_preserve_resolution() {
|
||||
assert!(bad.is_empty(), "preserve {preserve}: {} mismatches:\n{}", bad.len(), bad.join("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
/// `w` × `h` footage whose Cryptomatte has an ID per 37 × 29 px cell (`c<col>_<row>`, all in the
|
||||
/// manifest), one aux pixel per layer pixel. Built once, as the media pool keeps decoded aux.
|
||||
struct Cells(u32, u32, std::sync::OnceLock<Arc<AuxChannels>>);
|
||||
impl Cells {
|
||||
fn name(x: usize, y: usize) -> String {
|
||||
format!("c{}_{}", x / 37, y / 29)
|
||||
}
|
||||
}
|
||||
impl FootageSource for Cells {
|
||||
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(|| Arc::new(self.build())).clone())
|
||||
}
|
||||
}
|
||||
impl Cells {
|
||||
fn build(&self) -> AuxChannels {
|
||||
let (w, h) = (self.0 as usize, self.1 as usize);
|
||||
let ids = (0..w * h).map(|i| crypto_float(crypto_hash(&Cells::name(i % w, i / w)))).collect();
|
||||
let mut aux = AuxChannels::new(w as u32, h as u32, 1.0);
|
||||
aux.channels = vec![("CryptoObject00.R".into(), ids), ("CryptoObject00.G".into(), vec![1.0; w * h])];
|
||||
let names = (0..w.div_ceil(37)).flat_map(|x| (0..h.div_ceil(29)).map(move |y| Cells::name(x * 37, y * 29)));
|
||||
aux.manifests = vec![("CryptoObject".into(), names.map(|n| (n.clone(), crypto_hash(&n))).collect())];
|
||||
aux
|
||||
}
|
||||
}
|
||||
|
||||
struct CellsProbe(u32, u32);
|
||||
impl crate::Importer for CellsProbe {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
// `…_proxy` is a half-size proxy (rounded up); `…still…` a still image.
|
||||
let (width, height) = if path.contains("proxy") { (self.0.div_ceil(2), self.1.div_ceil(2)) } else { (self.0, self.1) };
|
||||
let (frame_rate, duration) = (FrameRate::from_f64(24.0), Tick::from_seconds_f64(2.0));
|
||||
let kind = if path.contains("still") { FootageKind::Still } else { FootageKind::Video };
|
||||
Ok(Footage { path: path.into(), kind, width, height, has_video: true, frame_rate, duration, ..Default::default() })
|
||||
}
|
||||
}
|
||||
|
||||
fn cells_session(w: u32, h: u32, blur: bool) -> Session {
|
||||
cells_session_at(w, h, blur, "/renders/cells.exr")
|
||||
}
|
||||
|
||||
fn cells_session_at(w: u32, h: u32, blur: bool, path: &str) -> Session {
|
||||
let mut s = Session { footage: Arc::new(Cells(w, h, Default::default())), importer: Some(Arc::new(CellsProbe(w, h))), ..Default::default() };
|
||||
let item = s.execute("file.import", json!({"paths": [path]})).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();
|
||||
if blur {
|
||||
s.execute_checked("effect.apply", json!({"effect": "Gaussian Blur"})).unwrap();
|
||||
s.execute_checked("prop.set", json!({"path": "effects/#1/blurriness", "value": 40.0})).unwrap();
|
||||
}
|
||||
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// Hover latency of `effect.cryptomatteAt` (dev profile): the first call after the viewer's frame
|
||||
/// and the next ones, on footage (1080p, 4K, 4K behind a blur) and a 1080p 3D precomp. Run with
|
||||
/// `--ignored --nocapture`; it measures, it doesn't assert.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn hover_latency() {
|
||||
let mut cases: Vec<(&str, Session)> = vec![
|
||||
("footage 1920x1080", cells_session(1920, 1080, false)),
|
||||
("footage 3840x2160", cells_session(3840, 2160, false)),
|
||||
("footage 3840x2160 + blur 40", cells_session(3840, 2160, true)),
|
||||
];
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Outer", "width": 1920, "height": 1080, "frameRate": 24, "duration": 2})).unwrap();
|
||||
let a = s.execute("layer.newSolid", json!({"name": "A", "width": 1920, "height": 1080})).unwrap()["layer"].clone();
|
||||
let b = s.execute("layer.newSolid", json!({"name": "B", "width": 400, "height": 1080})).unwrap()["layer"].clone();
|
||||
s.execute("layer.setSwitch", json!({"layers": [a, b], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("layer.precompose", json!({"layers": [a, b], "name": "Inner"})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [s.active_comp().unwrap().layers[0].id.0]})).ok();
|
||||
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
|
||||
cases.push(("3D precomp 1920x1080", s));
|
||||
for (name, mut s) in cases {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let _ = s.render(cid, Tick::ZERO, effectcraft_render::RenderOpts::default());
|
||||
let ms: Vec<String> = (0..6)
|
||||
.map(|i| {
|
||||
let t0 = std::time::Instant::now();
|
||||
let r = s.execute("effect.cryptomatteAt", json!({"effect": "Cryptomatte", "x": 100.0 + 40.0 * i as f64, "y": 500.0})).unwrap();
|
||||
assert!(!r.is_null(), "{name}");
|
||||
format!("{:.3}", t0.elapsed().as_secs_f64() * 1e3)
|
||||
})
|
||||
.collect();
|
||||
eprintln!("HOVER {name}: first {} ms, then {} ms", ms[0], ms[1..].join(" / "));
|
||||
}
|
||||
}
|
||||
|
||||
/// The hover's probe: built off the thread that asks, kept while only the Selection changes,
|
||||
/// rebuilt when anything it reads changes, and a job finished for an older state is not used.
|
||||
#[test]
|
||||
fn hover_probe_is_built_by_a_job_kept_across_picks_and_dropped_on_other_edits() {
|
||||
let mut s = session();
|
||||
let q = json!({"effect": 1, "x": 0.5, "y": 1.5});
|
||||
let job = s.crypto_probe_job(&q).unwrap().expect("nothing built yet");
|
||||
std::thread::spawn(job).join().unwrap();
|
||||
assert!(s.crypto_probe_job(&q).unwrap().is_none(), "built");
|
||||
assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["name"], "left");
|
||||
// Another scale or time is another probe.
|
||||
assert!(s.crypto_probe_job(&json!({"effect": 1, "x": 0.5, "y": 1.5, "scale": 0.5})).unwrap().is_some());
|
||||
assert!(s.crypto_probe_job(&json!({"effect": 1, "x": 0.5, "y": 1.5, "time": 1.0})).unwrap().is_some());
|
||||
// A pick edits only the Selection: the probe stays.
|
||||
pick(&mut s, 0.5, "replace");
|
||||
assert!(s.crypto_probe_job(&q).unwrap().is_none(), "kept across the pick");
|
||||
assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["selected"], true);
|
||||
// Other edits rebuild it, and the answer follows them.
|
||||
s.execute_checked("layer.enableTimeRemap", json!({})).unwrap();
|
||||
s.execute_checked("prop.set", json!({"path": "timeRemap", "value": 1.5})).unwrap();
|
||||
let stale = s.crypto_probe_job(&q).unwrap().expect("time remap changed it");
|
||||
assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["name"], "late");
|
||||
// A job that finishes after the next edit doesn't answer for it.
|
||||
s.execute_checked("prop.set", json!({"path": "timeRemap", "value": 0.5})).unwrap();
|
||||
stale();
|
||||
assert!(s.crypto_probe_job(&q).unwrap().is_some(), "stale job result is not used");
|
||||
assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["name"], "left");
|
||||
// Errors are kept too, and still errors.
|
||||
s.execute("layer.newSolid", json!({"name": "S"})).unwrap();
|
||||
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
|
||||
let solid = json!({"effect": 1, "x": 1, "y": 1});
|
||||
s.crypto_probe_job(&solid).unwrap().expect("solid")();
|
||||
assert!(s.crypto_probe_job(&solid).unwrap().is_none());
|
||||
assert!(s.execute("effect.cryptomatteAt", solid).unwrap_err().to_string().contains("no Cryptomatte channels"));
|
||||
}
|
||||
|
||||
/// Picks on 1080p, 4K and odd-sized footage, at the viewer's resolutions and on a half-size proxy,
|
||||
/// name the ID the rendered matte shows under the point (points next to cell edges).
|
||||
#[test]
|
||||
fn picks_match_the_render_at_1080p_4k_odd_sizes_and_on_proxies() {
|
||||
for (w, h) in [(1920, 1080), (3840, 2160), (1919, 1079)] {
|
||||
let mut s = cells_session(w, h, false);
|
||||
let (wl, hl) = (f64::from(w), f64::from(h));
|
||||
let xs = [36.6, 37.2, 0.4, wl / 2.0 + 0.3, wl - 0.6, wl - 37.4];
|
||||
let ys = [28.7, 29.1, hl - 0.5, hl / 3.0];
|
||||
let mut bad = mismatches(&mut s, &[1.0, 0.5, 0.33, 0.25], xs.iter().copied(), &ys);
|
||||
let item = *s.project.items.iter().find(|(_, i)| matches!(i.kind, effectcraft_project::ItemKind::Footage(_))).unwrap().0;
|
||||
s.execute("file.setProxy", json!({"item": item.0, "path": "/renders/cells_proxy.exr"})).unwrap();
|
||||
bad.extend(mismatches(&mut s, &[1.0, 0.5, 0.33, 0.25], xs.iter().copied(), &ys).into_iter().map(|b| format!("proxy {b}")));
|
||||
assert!(bad.is_empty(), "{w}x{h}: {} mismatches:\n{}", bad.len(), bad.join("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The kept probe, and the aux channels it holds, go when the eyedropper is disarmed, when its
|
||||
/// layer or effect is deleted, on Open / New, and when its footage changes.
|
||||
#[test]
|
||||
fn hover_probe_is_released_on_disarm_delete_open_and_footage_changes() {
|
||||
let q = json!({"effect": "Cryptomatte", "x": 100.0, "y": 100.0});
|
||||
let ends: [(&str, fn(&mut Session)); 5] = [
|
||||
("disarm", |s| s.drop_crypto_probe()),
|
||||
("layer delete", |s| {
|
||||
let lid = s.active_comp().unwrap().layers[0].id.0;
|
||||
s.execute_checked("edit.clear", json!({"layers": [lid]})).unwrap();
|
||||
}),
|
||||
("effect delete", |s| {
|
||||
s.execute_checked("effect.remove", json!({"effect": "Cryptomatte"})).unwrap();
|
||||
}),
|
||||
("open / new", |s| s.replace_project(effectcraft_project::Project::default(), None)),
|
||||
("footage change", |s| {
|
||||
let f = s.project.items.values().find_map(|i| match &i.kind {
|
||||
effectcraft_project::ItemKind::Footage(f) => Some(f.clone()),
|
||||
_ => None,
|
||||
});
|
||||
s.forget_decoded(&f.unwrap());
|
||||
}),
|
||||
];
|
||||
for (what, end) in ends {
|
||||
let mut s = cells_session(640, 360, false);
|
||||
let aux = s.footage.aux(ItemId(0), &Footage::default(), Tick::ZERO).unwrap();
|
||||
let unpinned = Arc::strong_count(&aux);
|
||||
assert!(!s.execute("effect.cryptomatteAt", q.clone()).unwrap().is_null());
|
||||
assert!(s.crypto_probe_stats().0 && Arc::strong_count(&aux) > unpinned, "{what}: kept");
|
||||
end(&mut s);
|
||||
assert!(!s.crypto_probe_stats().0, "{what}: the probe is gone");
|
||||
assert_eq!(Arc::strong_count(&aux), unpinned, "{what}: its aux channels are let go");
|
||||
}
|
||||
}
|
||||
|
||||
/// A probe is keyed by the frame it reads: on a still, or footage held by Time Remap, every comp
|
||||
/// time shares one; each footage frame has its own, and so does each comp time once something
|
||||
/// before the Cryptomatte effect is animated.
|
||||
#[test]
|
||||
fn hover_probe_is_keyed_by_the_source_frame() {
|
||||
let builds = |s: &mut Session, times: &[f64]| -> Vec<u64> {
|
||||
let b = s.crypto_probe_stats().1;
|
||||
times
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let r = s.execute("effect.cryptomatteAt", json!({"effect": "Cryptomatte", "x": 40.0, "y": 30.0, "time": t})).unwrap();
|
||||
assert_eq!(r["name"], "c1_1");
|
||||
s.crypto_probe_stats().1 - b
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let f = 1.0 / 24.0;
|
||||
let mut s = cells_session_at(320, 180, false, "/renders/still.exr");
|
||||
assert_eq!(builds(&mut s, &[0.0, f, 0.5, 1.9]), [1, 1, 1, 1], "a still: one probe");
|
||||
let mut s = cells_session(320, 180, false);
|
||||
assert_eq!(builds(&mut s, &[0.0, f * 0.4, f, f * 1.5, 0.0]), [1, 1, 2, 2, 3], "footage: a probe per frame");
|
||||
s.execute_checked("layer.enableTimeRemap", json!({})).unwrap();
|
||||
s.execute_checked("prop.toggleAnimation", json!({"path": "timeRemap", "value": false})).unwrap();
|
||||
s.execute_checked("prop.set", json!({"path": "timeRemap", "value": 0.5})).unwrap();
|
||||
assert_eq!(builds(&mut s, &[0.0, f, 1.2]), [1, 1, 1], "held by Time Remap: one source frame");
|
||||
let mut s = cells_session_at(320, 180, true, "/renders/still.exr");
|
||||
assert_eq!(builds(&mut s, &[0.0, f]), [1, 1], "a still blur");
|
||||
s.execute_checked("prop.addKey", json!({"path": "effects/#1/blurriness", "time": 0.0, "value": 2.0})).unwrap();
|
||||
s.execute_checked("prop.addKey", json!({"path": "effects/#1/blurriness", "time": 1.0, "value": 6.0})).unwrap();
|
||||
assert_eq!(builds(&mut s, &[0.0, f, f]), [1, 2, 2], "an animated blur before it: per comp time");
|
||||
}
|
||||
|
||||
/// Probes of two Cryptomatte effects (on two footage layers) at the same revision and frame
|
||||
/// don't answer for each other.
|
||||
#[test]
|
||||
fn hover_probes_are_per_layer_and_effect() {
|
||||
/// `…late…` files have the IDs [`Crypto`] has from 1 s.
|
||||
struct ByFile;
|
||||
impl FootageSource for ByFile {
|
||||
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
Some(Arc::new(Image::new(f.width, f.height)))
|
||||
}
|
||||
fn aux(&self, _: ItemId, f: &Footage, t: Tick) -> Option<Arc<AuxChannels>> {
|
||||
Some(Arc::new(aux_at(if f.path.contains("late") { Tick::from_seconds_f64(1.0) } else { t })))
|
||||
}
|
||||
}
|
||||
let mut s = Session { footage: Arc::new(ByFile), importer: Some(Arc::new(Probe)), ..Default::default() };
|
||||
let items = s.execute("file.import", json!({"paths": ["/renders/crypto.exr", "/renders/late.exr"]})).unwrap()["items"].clone();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap();
|
||||
let layers: Vec<Value> = (0..2).map(|i| s.execute("layer.addItem", json!({"item": items[i]})).unwrap()["layer"].clone()).collect();
|
||||
for l in &layers {
|
||||
s.execute_checked("effect.apply", json!({"layers": [l], "effect": "Cryptomatte"})).unwrap();
|
||||
}
|
||||
let on = |l: &Value| json!({"layer": l, "effect": 1, "x": 0.5, "y": 1.5});
|
||||
for (l, want) in [(&layers[0], "left"), (&layers[1], "late"), (&layers[0], "left")] {
|
||||
assert_eq!(s.execute("effect.cryptomatteAt", on(l)).unwrap()["name"], want);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Crypto`]'s IDs, counting the reads, and holding them while `hold` is set (a build caught
|
||||
/// in the middle).
|
||||
#[derive(Default)]
|
||||
struct Gate {
|
||||
reads: std::sync::atomic::AtomicUsize,
|
||||
hold: std::sync::atomic::AtomicBool,
|
||||
entered: std::sync::atomic::AtomicBool,
|
||||
/// Picture frames read (the effect's input render).
|
||||
frames: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
impl FootageSource for Gate {
|
||||
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
self.frames.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
Some(Arc::new(Image::new(f.width, f.height)))
|
||||
}
|
||||
fn aux(&self, _: ItemId, _: &Footage, t: Tick) -> Option<Arc<AuxChannels>> {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
self.reads.fetch_add(1, SeqCst);
|
||||
self.entered.store(true, SeqCst);
|
||||
while self.hold.load(SeqCst) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
Some(Arc::new(aux_at(t)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Only the newest request's build is kept: a job that finishes late doesn't evict a newer probe,
|
||||
/// a job superseded before it runs doesn't build, one superseded (or outdated by an edit) while
|
||||
/// it builds stores nothing, and `probeOnly` never builds on the asking thread.
|
||||
#[test]
|
||||
fn hover_probe_builds_store_only_the_newest_and_never_build_for_probe_only() {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
let gate = Arc::new(Gate::default());
|
||||
let mut s = session();
|
||||
s.footage = gate.clone();
|
||||
let at = |t: f64| json!({"effect": 1, "x": 0.5, "y": 1.5, "time": t});
|
||||
let reads = || gate.reads.load(SeqCst);
|
||||
// A late job for another frame doesn't evict the probe built since.
|
||||
let job0 = s.crypto_probe_job(&at(0.0)).unwrap().unwrap();
|
||||
s.execute("effect.cryptomatteAt", at(1.0)).unwrap();
|
||||
assert!(s.crypto_probe_job(&at(1.0)).unwrap().is_none());
|
||||
job0();
|
||||
assert!(s.crypto_probe_job(&at(1.0)).unwrap().is_none(), "the late job evicted the newer probe");
|
||||
// A request for another probe cancels the earlier job: it doesn't even build.
|
||||
let (old, new) = (s.crypto_probe_job(&at(0.5)).unwrap().unwrap(), s.crypto_probe_job(&at(0.25)).unwrap().unwrap());
|
||||
let before = reads();
|
||||
old();
|
||||
assert_eq!(reads(), before, "a superseded job doesn't build");
|
||||
new();
|
||||
assert!(reads() > before);
|
||||
assert!(s.crypto_probe_job(&at(0.25)).unwrap().is_none(), "the newest request's probe is kept");
|
||||
// The same request again doesn't cancel the job in flight.
|
||||
let (a, b) = (s.crypto_probe_job(&at(1.5)).unwrap().unwrap(), s.crypto_probe_job(&at(1.5)).unwrap().unwrap());
|
||||
a();
|
||||
drop(b);
|
||||
assert!(s.crypto_probe_job(&at(1.5)).unwrap().is_none());
|
||||
// Superseded, or outdated by an edit, while it builds: it stores nothing.
|
||||
for edit in [false, true] {
|
||||
let job = s.crypto_probe_job(&at(0.75)).unwrap().unwrap();
|
||||
gate.hold.store(true, SeqCst);
|
||||
gate.entered.store(false, SeqCst);
|
||||
let run = std::thread::spawn(job);
|
||||
while !gate.entered.load(SeqCst) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
if edit {
|
||||
s.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": true})).unwrap();
|
||||
} else {
|
||||
drop(s.crypto_probe_job(&at(0.8)).unwrap());
|
||||
}
|
||||
gate.hold.store(false, SeqCst);
|
||||
run.join().unwrap();
|
||||
let stored = if edit { s.crypto_probe_stats().0 } else { s.crypto_probe_job(&at(0.75)).unwrap().is_none() };
|
||||
assert!(!stored, "edit {edit}: the outdated build was stored");
|
||||
}
|
||||
// `probeOnly`: pending without a probe, and nothing built.
|
||||
let before = reads();
|
||||
let mut q = at(0.0);
|
||||
q["probeOnly"] = json!(true);
|
||||
assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap(), json!({"pending": true}));
|
||||
q["mode"] = json!("add");
|
||||
assert_eq!(s.execute("effect.pickCryptomatte", q.clone()).unwrap()["pending"], true);
|
||||
assert_eq!(reads(), before, "nothing built for probeOnly");
|
||||
s.crypto_probe_job(&q).unwrap().unwrap()();
|
||||
assert_eq!(s.execute("effect.pickCryptomatte", q.clone()).unwrap()["selection"], "left");
|
||||
}
|
||||
|
||||
/// A build cancelled while it reads the IDs (a newer request, disarming, an edit, another
|
||||
/// project) stops before it renders the effect's input, so the next build isn't kept waiting.
|
||||
#[test]
|
||||
fn a_cancelled_build_stops_before_rendering_its_input() {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
let cancels: [(&str, fn(&mut Session)); 4] = [
|
||||
("newer request", |s| drop(s.crypto_probe_job(&json!({"effect": 1, "x": 0.5, "y": 1.5, "time": 1.5})).unwrap())),
|
||||
("disarm", |s| s.drop_crypto_probe()),
|
||||
("edit", |s| drop(s.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": true})).unwrap())),
|
||||
("project switch", |s| s.replace_project(effectcraft_project::Project::default(), None)),
|
||||
];
|
||||
for (what, cancel) in [("not cancelled", None), ("newer request", Some(0)), ("disarm", Some(1)), ("edit", Some(2)), ("project switch", Some(3))] {
|
||||
let gate = Arc::new(Gate::default());
|
||||
let mut s = session();
|
||||
s.footage = gate.clone();
|
||||
let job = s.crypto_probe_job(&json!({"effect": 1, "x": 0.5, "y": 1.5, "time": 0.5})).unwrap().unwrap();
|
||||
gate.hold.store(true, SeqCst);
|
||||
let run = std::thread::spawn(job);
|
||||
while !gate.entered.load(SeqCst) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
if let Some(k) = cancel {
|
||||
cancels[k].1(&mut s);
|
||||
}
|
||||
gate.hold.store(false, SeqCst);
|
||||
run.join().unwrap();
|
||||
let rendered = gate.frames.load(SeqCst) > 0;
|
||||
assert_eq!(rendered, cancel.is_none(), "{what}: the input was rendered: {rendered}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The probe doesn't read the Cryptomatte effect's own Selection: keyframed, a still keeps one
|
||||
/// probe for every comp time (an animated effect before it still makes one per time).
|
||||
#[test]
|
||||
fn an_animated_selection_keeps_one_probe_on_a_still() {
|
||||
let mut s = cells_session_at(320, 180, false, "/renders/still.exr");
|
||||
for (t, v) in [(0.0, "c1_1"), (1.0, "c2_2")] {
|
||||
s.execute_checked("prop.addKey", json!({"path": "effects/#1/selection", "time": t, "value": v})).unwrap();
|
||||
}
|
||||
let b = s.crypto_probe_stats().1;
|
||||
for t in [0.0, 1.0 / 24.0, 0.5, 1.5] {
|
||||
assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": "Cryptomatte", "x": 40.0, "y": 30.0, "time": t})).unwrap()["name"], "c1_1");
|
||||
}
|
||||
assert_eq!(s.crypto_probe_stats().1 - b, 1, "one probe for the still");
|
||||
}
|
||||
|
||||
/// A panic while a probe is built on another thread becomes the probe's error: the job returns,
|
||||
/// the hover reports it, and it isn't retried for that frame.
|
||||
#[test]
|
||||
fn a_panicking_probe_build_becomes_an_error() {
|
||||
struct Broken;
|
||||
impl FootageSource for Broken {
|
||||
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>> {
|
||||
panic!("a broken EXR reader")
|
||||
}
|
||||
}
|
||||
let mut s = Session { footage: Arc::new(Broken), importer: Some(Arc::new(Probe)), ..Default::default() };
|
||||
let item = s.execute("file.import", json!({"paths": ["/renders/crypto.exr"]})).unwrap()["items"][0].clone();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 8, "height": 4, "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": 1, "x": 0.5, "y": 1.5, "probeOnly": true});
|
||||
let job = s.crypto_probe_job(&q).unwrap().unwrap();
|
||||
assert!(std::thread::spawn(job).join().is_ok(), "the job caught the panic");
|
||||
assert!(s.crypto_probe_job(&q).unwrap().is_none(), "the error is kept");
|
||||
let e = s.execute("effect.cryptomatteAt", q).unwrap_err().to_string();
|
||||
assert!(e.contains("Reading the Cryptomatte IDs failed") && e.contains("a broken EXR reader"), "{e}");
|
||||
}
|
||||
|
||||
/// Asking whether the probe is built changes nothing: asked for other frames while a build runs
|
||||
/// (the eyedropper's hover while a click waits), it doesn't cancel that build.
|
||||
#[test]
|
||||
fn asking_whether_a_probe_is_ready_cancels_no_build() {
|
||||
let s = session();
|
||||
let at = |t: f64| json!({"effect": 1, "x": 0.5, "y": 1.5, "time": t, "probeOnly": true});
|
||||
let job = s.crypto_probe_job(&at(0.0)).unwrap().unwrap();
|
||||
for t in [1.0, 0.5, 1.0, 0.25] {
|
||||
assert!(!s.crypto_probe_ready(&at(t)).unwrap());
|
||||
}
|
||||
job();
|
||||
assert!(s.crypto_probe_ready(&at(0.0)).unwrap(), "the build was cancelled");
|
||||
assert!(s.crypto_probe_job(&at(0.0)).unwrap().is_none());
|
||||
}
|
||||
|
||||
/// The probe renders with the render prefs (nested switches, draft shadows): changing them asks
|
||||
/// for a new one.
|
||||
#[test]
|
||||
fn a_render_prefs_change_asks_for_a_new_probe() {
|
||||
let mut s = session();
|
||||
let q = json!({"effect": 1, "x": 0.5, "y": 1.5});
|
||||
s.crypto_probe_job(&q).unwrap().unwrap()();
|
||||
assert!(s.crypto_probe_ready(&q).unwrap());
|
||||
s.prefs.three_d.realtime_shadows = !s.prefs.three_d.realtime_shadows;
|
||||
assert!(!s.crypto_probe_ready(&q).unwrap(), "draft shadows changed");
|
||||
s.crypto_probe_job(&q).unwrap().unwrap()();
|
||||
s.prefs.general.switches_affect_nested_comps = !s.prefs.general.switches_affect_nested_comps;
|
||||
assert!(!s.crypto_probe_ready(&q).unwrap(), "nested switches changed");
|
||||
}
|
||||
|
||||
/// A pick is an undo step named for it, and undoing, redoing or jumping over picks keeps the
|
||||
/// probe (only the Selection changed); undoing any other edit doesn't.
|
||||
#[test]
|
||||
fn undoing_picks_keeps_the_probe_and_the_step_says_pick() {
|
||||
let mut s = session();
|
||||
let q = json!({"effect": 1, "x": 0.5, "y": 1.5, "probeOnly": true});
|
||||
s.crypto_probe_job(&q).unwrap().unwrap()();
|
||||
let before = crate::history::state_id(&s.project);
|
||||
pick(&mut s, 0.5, "replace");
|
||||
assert_eq!(s.history.undo.last().unwrap().0, "Pick Cryptomatte");
|
||||
pick(&mut s, 4.5, "add");
|
||||
for (what, cmd) in [("undo", "edit.undo"), ("undo", "edit.undo"), ("redo", "edit.redo")] {
|
||||
s.execute_checked(cmd, json!({})).unwrap();
|
||||
assert!(s.crypto_probe_ready(&q).unwrap(), "{what} {}: probe dropped", selection(&s));
|
||||
}
|
||||
assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["selected"], true);
|
||||
assert!(s.goto_history(&before) && selection(&s).is_empty());
|
||||
assert!(s.crypto_probe_ready(&q).unwrap(), "history jump: probe dropped");
|
||||
s.execute_checked("prop.set", json!({"path": "transform/opacity", "value": 50})).unwrap();
|
||||
s.crypto_probe_job(&q).unwrap().unwrap()();
|
||||
s.execute_checked("edit.undo", json!({})).unwrap();
|
||||
assert!(!s.crypto_probe_ready(&q).unwrap(), "an undone opacity change needs a new probe");
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ fn disk_cache_settings_persistence_and_invalidation() {
|
||||
let mut s = session(&dir);
|
||||
let st = s.execute_checked("cache.diskStats", json!({})).unwrap();
|
||||
assert_eq!(st["enabled"], json!(true));
|
||||
assert_eq!(st["maxBytes"], json!(100u64 << 30));
|
||||
assert_eq!(st["maxBytes"], json!(20u64 << 30));
|
||||
let cid = project(&mut s);
|
||||
let t = s.time();
|
||||
let first = s.render(cid, t, RenderOpts::default());
|
||||
|
||||
@@ -772,6 +772,37 @@ fn preset_round_trips_effect_properties_by_match_name() {
|
||||
}
|
||||
}
|
||||
|
||||
/// An animation preset saved by an earlier version gets the upgrade a project gets on open: a
|
||||
/// Change to Color saved with its single Tolerance applies with the Tolerance group, that value
|
||||
/// as its Hue and the added parameters at their legacy defaults, and a lone property saved at the
|
||||
/// old id applies to the new one.
|
||||
#[test]
|
||||
fn applying_an_old_preset_upgrades_its_effects_and_property_paths() {
|
||||
let (mut s, a, b) = comp_with_two_solids();
|
||||
apply(&mut s, a, "Change to Color");
|
||||
let mut old = layer(&s, a).effects().unwrap().groups().next().unwrap().clone();
|
||||
old.children.retain(|c| c.match_id() != "toleranceGroup");
|
||||
old.children.push(effectcraft_project::Property::new(0, "tolerance", "Tolerance", KV::Scalar(12.0)).into());
|
||||
let preset = json!({"effectcraftAnimationPreset": 1, "name": "old", "properties": [], "effects": [old]});
|
||||
let r = s.execute("anim.applyPreset", json!({"preset": preset, "layers": [b]})).unwrap();
|
||||
assert_eq!(r["applied"], 1, "{r}");
|
||||
let l = layer(&s, b);
|
||||
let g = l.effects().unwrap().groups().next().unwrap();
|
||||
assert!(g.get("tolerance").is_none(), "the old Tolerance is still there");
|
||||
let tol = g.sub("toleranceGroup").expect("no Tolerance group");
|
||||
assert_eq!(tol.get("hue").unwrap().value, KV::Scalar(12.0));
|
||||
assert_eq!(tol.get("lightness").unwrap().value, KV::Scalar(100.0));
|
||||
assert_eq!(tol.get("saturation").unwrap().value, KV::Scalar(100.0));
|
||||
let path = "effects/ec.color.changetocolor/tolerance";
|
||||
let preset = json!({"effectcraftAnimationPreset": 1, "name": "old", "properties": [{"path": path, "value": KV::Scalar(30.0), "keys": []}], "effects": []});
|
||||
let r = s.execute("anim.applyPreset", json!({"preset": preset, "layers": [a]})).unwrap();
|
||||
assert_eq!(r["applied"], 1, "{r}");
|
||||
assert_eq!(r["skipped"], json!([]), "{r}");
|
||||
let l = layer(&s, a);
|
||||
let hue = l.effects().unwrap().groups().next().unwrap().sub("toleranceGroup").unwrap().get("hue").unwrap().value.clone();
|
||||
assert_eq!(hue, KV::Scalar(30.0));
|
||||
}
|
||||
|
||||
/// #2064: `prop.addKey` accepts the `effects/<match name>[#n]/<property>` path that
|
||||
/// `essential.list` reports.
|
||||
#[test]
|
||||
|
||||
@@ -708,6 +708,11 @@ fn dynamic_menus_and_window_viewer_labels() {
|
||||
s.prefs.push_recent("/p/One.ecproj");
|
||||
let (e, _) = crate::menus::dynamic(&s, "recentProjects", &Default::default());
|
||||
assert_eq!((e[0].label.as_str(), e[0].command.as_str()), ("One.ecproj", "file.openRecent"));
|
||||
// Two projects with the same file name show their paths.
|
||||
s.prefs.push_recent("/q/One.ecproj");
|
||||
s.prefs.push_recent("/p/Two.ecproj");
|
||||
let (e, _) = crate::menus::dynamic(&s, "recentProjects", &Default::default());
|
||||
assert_eq!(e.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(), ["Two.ecproj", "/q/One.ecproj", "/p/One.ecproj"]);
|
||||
assert_eq!(crate::menus::dynamic(&s, "history", &Default::default()).0.len(), s.history.undo.len());
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
s.state.selected_layers = vec![LayerId(a)];
|
||||
|
||||
@@ -14,7 +14,8 @@ use crate::Session;
|
||||
use crate::render::{FootageSource, RenderOpts};
|
||||
|
||||
/// Files by name: `red16.png` (16×16 red), `blue8.png` (8×8 blue), `lines.mov` (interlaced:
|
||||
/// even rows red, odd rows blue; 4×4, 10 fps), `matte_black.png` / `matte_white.png` (a
|
||||
/// even rows red, odd rows blue; 4×4, 10 fps), `flash.mov` (10×10, 10 fps: red, then blue from
|
||||
/// the second frame), `matte_black.png` / `matte_white.png` (a
|
||||
/// semi-transparent green premultiplied against black / white, read as straight), `half.png`
|
||||
/// (white at 25 % alpha), `wide.png` (10×10 white).
|
||||
struct Synthetic;
|
||||
@@ -52,9 +53,14 @@ fn size_of(path: &str) -> (u32, u32) {
|
||||
}
|
||||
|
||||
impl FootageSource for Synthetic {
|
||||
fn frame(&self, _: ItemId, f: &Footage, _t: Tick) -> Option<Arc<Image>> {
|
||||
fn frame(&self, _: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
|
||||
let (w, h) = size_of(&f.path);
|
||||
let mut img = Image::new(w, h);
|
||||
// `flash.mov` is red for its first frame and blue from its second (0.1 s) on.
|
||||
if f.path.ends_with("flash.mov") {
|
||||
let c = if t.seconds() >= 0.1 - 1e-9 { [0.0, 0.0, 1.0, 1.0] } else { [1.0, 0.0, 0.0, 1.0] };
|
||||
return Some(Arc::new(Image::filled(w, h, c)));
|
||||
}
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
// Decoders hand out premultiplied pixels (straight file colour × alpha).
|
||||
@@ -171,6 +177,118 @@ fn comp_proxy_and_create_proxy() {
|
||||
assert!(blue(px(&s, outer, 0.0, o, 8, 8)));
|
||||
}
|
||||
|
||||
/// Outer (4×4, 20 fps) holding a layer of Inner (4×4, 10 fps, a red solid): (inner, outer).
|
||||
fn nested(s: &mut Session) -> (ItemId, ItemId) {
|
||||
s.execute("comp.new", json!({"name": "Inner", "width": 4, "height": 4, "frameRate": 10, "duration": 2})).unwrap();
|
||||
let inner = s.active_comp_id().unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Outer", "width": 4, "height": 4, "frameRate": 20, "duration": 2})).unwrap();
|
||||
let outer = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": inner.0})).unwrap();
|
||||
(inner, outer)
|
||||
}
|
||||
|
||||
/// Interpret Footage ▸ Proxy's Invert Alpha and Interpret As Linear Light change what a
|
||||
/// composition proxy draws, as they do for the same file used as footage.
|
||||
#[test]
|
||||
fn comp_proxy_draws_with_its_interpretation() {
|
||||
let mut s = session();
|
||||
let (inner, outer) = nested(&mut s);
|
||||
let o = RenderOpts::default();
|
||||
// Invert Alpha: the 25 % proxy covers 75 %.
|
||||
s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/half.png"})).unwrap();
|
||||
s.execute("file.interpretProxy", json!({"item": inner.0, "alpha": "straight", "invertAlpha": true})).unwrap();
|
||||
let p = px(&s, outer, 0.0, o, 1, 1);
|
||||
assert!((p[3] - 0.75).abs() < 0.01, "{p:?}");
|
||||
// Interpret As Linear Light: the proxy draws as the file does as footage with that setting.
|
||||
let f = import(&mut s, "/m/matte_black.png");
|
||||
s.execute("file.interpretFootage", json!({"item": f.0, "linearLight": true})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Ref", "width": 4, "height": 4, "frameRate": 20, "duration": 2})).unwrap();
|
||||
let r = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": f.0})).unwrap();
|
||||
let want = px(&s, r, 0.0, o, 1, 1);
|
||||
assert!(want[1] / want[3] > 0.7, "linear light re-encodes the 0.5 green: {want:?}");
|
||||
s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/matte_black.png"})).unwrap();
|
||||
s.execute("file.interpretProxy", json!({"item": inner.0, "linearLight": true})).unwrap();
|
||||
let p = px(&s, outer, 0.0, o, 1, 1);
|
||||
assert!((0..4).all(|i| (p[i] - want[i]).abs() < 1e-3), "{p:?} != {want:?}");
|
||||
}
|
||||
|
||||
/// Interpret Footage ▸ Proxy ▸ Separate Fields: a composition proxy shows one field per frame
|
||||
/// at twice the rate, as footage does.
|
||||
#[test]
|
||||
fn comp_proxy_separates_fields() {
|
||||
let mut s = session();
|
||||
let (inner, outer) = nested(&mut s);
|
||||
let o = RenderOpts::default();
|
||||
s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/lines.mov"})).unwrap();
|
||||
assert!(red(px(&s, outer, 0.05, o, 1, 0)) && blue(px(&s, outer, 0.05, o, 1, 1)), "progressive");
|
||||
s.execute("file.interpretProxy", json!({"item": inner.0, "fields": "upper"})).unwrap();
|
||||
// At 0.05 s the lower field (odd rows, blue) is shown, with the even rows interpolated.
|
||||
for y in 0..3 {
|
||||
assert!(blue(px(&s, outer, 0.05, o, 1, y)), "lower field at row {y}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame Blending on a precomp layer blends the frames of the composition's proxy.
|
||||
#[test]
|
||||
fn comp_proxy_frames_blend() {
|
||||
let mut s = session();
|
||||
let (inner, outer) = nested(&mut s);
|
||||
let o = RenderOpts::default();
|
||||
s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/flash.mov"})).unwrap();
|
||||
assert!(red(px(&s, outer, 0.05, o, 1, 1)));
|
||||
s.execute("layer.frameBlending", json!({"mode": "frameMix"})).unwrap();
|
||||
s.execute("comp.setSwitch", json!({"switch": "frameBlending", "value": true})).unwrap();
|
||||
// Halfway between the red first frame and the blue second one.
|
||||
let p = px(&s, outer, 0.05, o, 1, 1);
|
||||
assert!((p[0] - 0.5).abs() < 0.05 && (p[2] - 0.5).abs() < 0.05 && p[3] > 0.99, "{p:?}");
|
||||
assert!(blue(px(&s, outer, 0.1, o, 1, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comp_proxy_frame_blend_cache_tracks_alpha_interpretation() {
|
||||
struct Interpreted(std::sync::atomic::AtomicUsize);
|
||||
impl FootageSource for Interpreted {
|
||||
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
self.0.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let pixel = match f.alpha {
|
||||
AlphaMode::Straight => [0.4, 0.2, 0.1, 0.5],
|
||||
AlphaMode::Premultiplied => [f.premul_color[0] * 0.5, 0.1, 0.0, 0.5],
|
||||
AlphaMode::Ignore => [0.8, 0.4, 0.2, 1.0],
|
||||
};
|
||||
Some(Arc::new(Image::filled(4, 4, pixel)))
|
||||
}
|
||||
}
|
||||
for mode in ["frameMix", "pixelMotion"] {
|
||||
let mut s = session();
|
||||
let footage = Arc::new(Interpreted(std::sync::atomic::AtomicUsize::new(0)));
|
||||
s.footage = footage.clone();
|
||||
let (inner, outer) = nested(&mut s);
|
||||
s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/alpha.mov"})).unwrap();
|
||||
s.execute("file.interpretProxy", json!({"item": inner.0, "alpha": "straight"})).unwrap();
|
||||
s.execute("layer.frameBlending", json!({"mode": mode})).unwrap();
|
||||
s.execute("comp.setSwitch", json!({"switch": "frameBlending", "value": true})).unwrap();
|
||||
let opts = RenderOpts::default();
|
||||
let first = px(&s, outer, 0.05, opts, 1, 1);
|
||||
let calls = footage.0.load(std::sync::atomic::Ordering::Relaxed);
|
||||
assert_eq!(px(&s, outer, 0.05, opts, 1, 1), first);
|
||||
assert_eq!(footage.0.load(std::sync::atomic::Ordering::Relaxed), calls);
|
||||
for params in [
|
||||
json!({"item": inner.0, "alpha": "ignore"}),
|
||||
json!({"item": inner.0, "alpha": "premultiplied", "premulColor": [0.0, 0.0, 0.0]}),
|
||||
json!({"item": inner.0, "alpha": "premultiplied", "premulColor": [1.0, 0.0, 0.0]}),
|
||||
] {
|
||||
s.execute("file.interpretProxy", params.clone()).unwrap();
|
||||
let mut fresh = s.renderer(opts);
|
||||
fresh.cache = None;
|
||||
let want = fresh.comp_frame(outer, Tick::from_seconds_f64(0.05)).get(1, 1);
|
||||
let actual = px(&s, outer, 0.05, opts, 1, 1);
|
||||
assert_eq!(actual, want, "{mode} after {params}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_proxy_settings_apply_to_the_proxy() {
|
||||
let mut s = session();
|
||||
@@ -187,6 +305,38 @@ fn interpret_proxy_settings_apply_to_the_proxy() {
|
||||
assert_eq!(f.alpha, AlphaMode::Straight, "the main footage keeps its interpretation");
|
||||
}
|
||||
|
||||
/// Agents and the control channel run commands through `execute_checked`, which refuses keys a
|
||||
/// params doc does not list. The Interpret Footage commands list every key they read.
|
||||
#[test]
|
||||
fn interpret_commands_accept_their_keys_when_checked() {
|
||||
let mut s = session();
|
||||
let mov = import(&mut s, "/m/lines.mov");
|
||||
let b = import(&mut s, "/m/red16.png");
|
||||
s.execute("file.setProxy", json!({"item": mov.0, "path": "/m/lines.mov"})).unwrap();
|
||||
s.execute("file.setProxy", json!({"item": b.0, "path": "/m/matte_black.png"})).unwrap();
|
||||
s.execute_checked("file.interpretProxy", json!({"item": mov.0, "preserveRgb": true, "overrideStart": true, "startTimecode": 25})).unwrap();
|
||||
let px = s.project.item(mov).unwrap().proxy.clone().unwrap().footage;
|
||||
assert!(px.preserve_rgb);
|
||||
assert_eq!(px.start_timecode, Some(25));
|
||||
s.execute_checked("file.interpretProxy", json!({"item": mov.0, "overrideStart": false})).unwrap();
|
||||
assert_eq!(s.project.item(mov).unwrap().proxy.as_ref().unwrap().footage.start_timecode, None);
|
||||
s.execute_checked("file.interpretProxy", json!({"item": b.0, "guessAlpha": true})).unwrap();
|
||||
assert_eq!(s.project.item(b).unwrap().proxy.as_ref().unwrap().footage.alpha, AlphaMode::Premultiplied);
|
||||
s.execute_checked("file.interpretProxy", json!({"item": b.0, "premulColor": [1.0, 0.0, 0.0]})).unwrap();
|
||||
assert_eq!(s.project.item(b).unwrap().proxy.as_ref().unwrap().footage.premul_color, [1.0, 0.0, 0.0]);
|
||||
// `item` names one footage item, as `items` names several.
|
||||
s.execute_checked("file.interpretFootage", json!({"item": b.0, "invertAlpha": true, "premulColor": [0.0, 1.0, 0.0]})).unwrap();
|
||||
s.execute_checked("file.rememberInterpretation", json!({"item": b.0})).unwrap();
|
||||
s.execute_checked("file.applyInterpretation", json!({"item": mov.0})).unwrap();
|
||||
let f = match &s.project.item(mov).unwrap().kind {
|
||||
ItemKind::Footage(f) => f.clone(),
|
||||
_ => panic!("not footage"),
|
||||
};
|
||||
assert!(f.invert_alpha);
|
||||
assert_eq!(f.premul_color, [0.0, 1.0, 0.0]);
|
||||
assert_eq!(s.execute_checked("file.reloadFootage", json!({"item": mov.0})).unwrap()["reloaded"], 1);
|
||||
}
|
||||
|
||||
/// Separate Fields: each field becomes a frame at twice the frame rate, the other field's lines
|
||||
/// interpolated; the dominant field comes first.
|
||||
#[test]
|
||||
|
||||
@@ -280,6 +280,31 @@ fn toolbar_fill_and_stroke_paint_selected_shape_layers() {
|
||||
let _ = s.render(cid, s.time(), Default::default());
|
||||
}
|
||||
|
||||
/// A Wiggle Paths saved without Spatial Phase gets the property, at 0°, after Temporal Phase when
|
||||
/// the project opens.
|
||||
#[test]
|
||||
fn opening_a_wiggle_paths_without_spatial_phase_adds_it() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap();
|
||||
let sh = s.execute("layer.newShape", json!({"kind": "rect", "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let uid = s.execute("layer.addShapeItem", json!({"layer": sh, "kind": "wiggle"})).unwrap()["uid"].as_u64().unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let mut old = effectcraft_project::Project::from_json(&s.project.to_file_json().unwrap()).unwrap();
|
||||
let l = old.comp_mut(cid).unwrap().layer_mut(effectcraft_project::LayerId(sh)).unwrap();
|
||||
let g = l.props.find_group_mut(uid).unwrap();
|
||||
g.children.retain(|c| c.match_id() != "spatialPhase");
|
||||
assert_eq!(g.children.len(), 7);
|
||||
let next = old.next_id;
|
||||
s.replace_project(old, None);
|
||||
let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(sh)).unwrap().clone();
|
||||
let g = l.props.find_group(uid).unwrap();
|
||||
let ids: Vec<&str> = g.children.iter().map(|c| c.match_id()).collect();
|
||||
assert_eq!(ids, ["size", "detail", "points", "speed", "correlation", "phase", "spatialPhase", "seed"]);
|
||||
let sp = g.get("spatialPhase").unwrap();
|
||||
assert_eq!((sp.name.as_str(), &sp.value), ("Spatial Phase", &effectcraft_keyframe::Value::Scalar(0.0)));
|
||||
assert!(sp.uid >= next && s.project.next_id > sp.uid, "a fresh uid: {} (next {next})", sp.uid);
|
||||
}
|
||||
|
||||
/// The toolbar's Fill Options / Stroke Options change a selected shape layer's paint kind (#732):
|
||||
/// None switches the fill off, a gradient turns the Fill into a Gradient Fill that keeps its
|
||||
/// opacity, Solid Color turns it back, and a Stroke kind adds a stroke where there is none.
|
||||
|
||||
@@ -71,6 +71,14 @@ fn prefs_migrate_old_layout_and_keep_unknown_keys() {
|
||||
assert_eq!(Prefs::from_json("not json"), Prefs::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefs_migrate_old_disk_cache_default() {
|
||||
// The old 100 GB default becomes 20 GB; a size the user chose stays (#2551).
|
||||
assert_eq!(Prefs::from_json(r#"{"version": 2, "disk": {"diskCacheMaxGb": 100}}"#).disk.disk_cache_max_gb, 20);
|
||||
assert_eq!(Prefs::from_json(r#"{"version": 2, "disk": {"diskCacheMaxGb": 50}}"#).disk.disk_cache_max_gb, 50);
|
||||
assert_eq!(Prefs::from_json(r#"{"version": 3, "disk": {"diskCacheMaxGb": 100}}"#).disk.disk_cache_max_gb, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefs_set_validates_keys_types_and_ranges() {
|
||||
let mut p = Prefs::default();
|
||||
|
||||
+39
-59
@@ -33,18 +33,29 @@ pub(crate) enum Pixels {
|
||||
Yuv420(Yuv420),
|
||||
}
|
||||
|
||||
/// BT.709 limited-range 8-bit 4:2:0 planes ([`rgba_to_yuv420`]).
|
||||
pub(crate) struct Yuv420 {
|
||||
y: Vec<u8>,
|
||||
u: Vec<u8>,
|
||||
v: Vec<u8>,
|
||||
/// BT.709 limited-range 8-bit 4:2:0 planes ([`rgba_to_yuv420`], or Y then U and V from the
|
||||
/// accelerator).
|
||||
pub(crate) enum Yuv420 {
|
||||
Planes { y: Vec<u8>, u: Vec<u8>, v: Vec<u8> },
|
||||
Packed(Vec<u8>),
|
||||
}
|
||||
|
||||
impl Yuv420 {
|
||||
fn from_rgba(rgba: &[u8], w: usize, h: usize) -> Yuv420 {
|
||||
let (mut y, mut u, mut v) = (vec![], vec![], vec![]);
|
||||
rgba_to_yuv420(rgba, w, h, &mut y, &mut u, &mut v);
|
||||
Yuv420 { y, u, v }
|
||||
Yuv420::Planes { y, u, v }
|
||||
}
|
||||
fn slices(&self, w: usize, h: usize) -> (&[u8], &[u8], &[u8]) {
|
||||
match self {
|
||||
Yuv420::Planes { y, u, v } => (y, u, v),
|
||||
Yuv420::Packed(p) => {
|
||||
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
|
||||
let (y, uv) = p.split_at(w * h);
|
||||
let (u, v) = uv.split_at(cw * ch);
|
||||
(y, u, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,18 +113,22 @@ impl VideoEncoder for H264 {
|
||||
SampleEntry::avc(cfg, self.w as u16, self.h as u16)
|
||||
}
|
||||
fn encode(&mut self, rgba: &[u8], index: u64) -> Result<Vec<Packet>> {
|
||||
let mut planes = Yuv420 { y: std::mem::take(&mut self.y), u: std::mem::take(&mut self.u), v: std::mem::take(&mut self.v) };
|
||||
rgba_to_yuv420(rgba, self.w as usize, self.h as usize, &mut planes.y, &mut planes.u, &mut planes.v);
|
||||
let (mut y, mut u, mut v) = (std::mem::take(&mut self.y), std::mem::take(&mut self.u), std::mem::take(&mut self.v));
|
||||
rgba_to_yuv420(rgba, self.w as usize, self.h as usize, &mut y, &mut u, &mut v);
|
||||
let planes = Yuv420::Planes { y, u, v };
|
||||
let r = self.encode_yuv420(&planes, index);
|
||||
(self.y, self.u, self.v) = (planes.y, planes.u, planes.v);
|
||||
if let Yuv420::Planes { y, u, v } = planes {
|
||||
(self.y, self.u, self.v) = (y, u, v);
|
||||
}
|
||||
r
|
||||
}
|
||||
fn takes_yuv420(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn encode_yuv420(&mut self, p: &Yuv420, index: u64) -> Result<Vec<Packet>> {
|
||||
let cw = (self.w as usize).div_ceil(2);
|
||||
let frame = filmcraft_h264enc::YuvFrame { y: &p.y, u: &p.u, v: &p.v, y_stride: self.w as usize, uv_stride: cw };
|
||||
let (w, cw) = (self.w as usize, (self.w as usize).div_ceil(2));
|
||||
let (y, u, v) = p.slices(w, self.h as usize);
|
||||
let frame = filmcraft_h264enc::YuvFrame { y, u, v, y_stride: w, uv_stride: cw };
|
||||
let ps = self.enc.try_encode(&frame, index as i64 * self.rate.den).map_err(enc)?;
|
||||
Ok(self.packets(ps))
|
||||
}
|
||||
@@ -126,51 +141,7 @@ impl VideoEncoder for H264 {
|
||||
}
|
||||
}
|
||||
|
||||
/// `v / 255` for every 8-bit `v` (the same values as dividing).
|
||||
pub(crate) const UNIT8: [f32; 256] = {
|
||||
let mut t = [0.0; 256];
|
||||
let mut i = 0;
|
||||
while i < 256 {
|
||||
t[i] = i as f32 / 255.0;
|
||||
i += 1;
|
||||
}
|
||||
t
|
||||
};
|
||||
|
||||
/// `v.round()` for `0 <= v < 2^23`, the range the colour conversions round in: truncation and
|
||||
/// the fraction are exact there, so this is `f32::round` without its libm call.
|
||||
#[inline(always)]
|
||||
pub(crate) fn round_pos(v: f32) -> f32 {
|
||||
let t = v as u32 as f32;
|
||||
if v - t >= 0.5 { t + 1.0 } else { t }
|
||||
}
|
||||
|
||||
/// BT.709 limited-range 8-bit 4:2:0 from RGBA8 (2×2 chroma average; alpha ignored).
|
||||
pub(crate) fn rgba_to_yuv420(rgba: &[u8], w: usize, h: usize, y: &mut Vec<u8>, u: &mut Vec<u8>, v: &mut Vec<u8>) {
|
||||
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
|
||||
y.resize(w * h, 0);
|
||||
u.resize(cw * ch, 0);
|
||||
v.resize(cw * ch, 0);
|
||||
y.par_chunks_mut(w * 2).zip(u.par_chunks_mut(cw).zip(v.par_chunks_mut(cw))).enumerate().for_each(|(cy, (yr, (ur, vr)))| {
|
||||
let mut acc = vec![(0f32, 0f32, 0f32); cw];
|
||||
for (dy, yrow) in yr.chunks_exact_mut(w).enumerate() {
|
||||
let src = &rgba[(cy * 2 + dy) * w * 4..][..w * 4];
|
||||
for (x, (s, yo)) in src.as_chunks::<4>().0.iter().zip(yrow.iter_mut()).enumerate() {
|
||||
let (r, g, b) = (UNIT8[s[0] as usize], UNIT8[s[1] as usize], UNIT8[s[2] as usize]);
|
||||
let l = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
*yo = round_pos(16.0 + 219.0 * l).clamp(1.0, 254.0) as u8;
|
||||
let a = &mut acc[x / 2];
|
||||
a.0 += (b - l) / 1.8556;
|
||||
a.1 += (r - l) / 1.5748;
|
||||
a.2 += 1.0;
|
||||
}
|
||||
}
|
||||
for (cx, a) in acc.iter().enumerate() {
|
||||
ur[cx] = round_pos(128.0 + 224.0 * a.0 / a.2).clamp(1.0, 254.0) as u8;
|
||||
vr[cx] = round_pos(128.0 + 224.0 * a.1 / a.2).clamp(1.0, 254.0) as u8;
|
||||
}
|
||||
});
|
||||
}
|
||||
pub(crate) use effectcraft_raster::yuv::rgba_to_yuv420;
|
||||
|
||||
// ---------------------------------------------------------------- ProRes
|
||||
|
||||
@@ -326,6 +297,13 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St
|
||||
let px = job.pixels(&img, comp, channels, w, h);
|
||||
if yuv { Pixels::Yuv420(Yuv420::from_rgba(&px, w as usize, h as usize)) } else { Pixels::Rgba(px) }
|
||||
};
|
||||
// H.264 frames the accelerator can convert itself come back as planes (12 MB instead of
|
||||
// 133 MB of RGBA f32 read back at 4K); everything else renders and converts as before.
|
||||
// Deferred readbacks (a browser worker) keep their pass-based path: no blocking there.
|
||||
let gpu_yuv = yuv && channels == Channels::Rgb && !job.deferred();
|
||||
let batch_frames = async |ks: Vec<u64>| -> Vec<Pixels> {
|
||||
if gpu_yuv { job.h264_frames(comp, ks, w, h, |p| Pixels::Yuv420(Yuv420::Packed(p)), render) } else { job.frames(comp, ks, render).await }
|
||||
};
|
||||
let encode = |venc: &mut Box<dyn VideoEncoder>, px: &Pixels, index: u64| match px {
|
||||
Pixels::Rgba(rgba) => venc.encode(rgba, index),
|
||||
Pixels::Yuv420(planes) => venc.encode_yuv420(planes, index),
|
||||
@@ -337,10 +315,10 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St
|
||||
// Encode the first batch before creating tracks: encoders finalise their config on frame 1.
|
||||
// Each batch encodes while the next one renders.
|
||||
let first_end = batch.min(total);
|
||||
let first: Vec<Pixels> = job.frames(comp, (0..first_end).collect(), render).await;
|
||||
let first: Vec<Pixels> = batch_frames((0..first_end).collect()).await;
|
||||
let next_end = (first_end + batch).min(total);
|
||||
let (pending, mut ready) = job
|
||||
.frames_beside(beside, comp, (first_end..next_end).collect(), render, || -> Result<Vec<Packet>> {
|
||||
.beside(beside, batch_frames((first_end..next_end).collect()), || -> Result<Vec<Packet>> {
|
||||
let mut pending = Vec::new();
|
||||
for (k, px) in first.iter().enumerate() {
|
||||
pending.extend(encode(&mut venc, px, k as u64)?);
|
||||
@@ -435,7 +413,7 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St
|
||||
let next_end = (end + batch).min(total);
|
||||
let frames = std::mem::take(&mut ready);
|
||||
let (done, next) = job
|
||||
.frames_beside(beside, comp, (end..next_end).collect(), render, || -> Result<()> {
|
||||
.beside(beside, batch_frames((end..next_end).collect()), || -> Result<()> {
|
||||
for (k, px) in frames.iter().enumerate() {
|
||||
let ps = encode(&mut venc, px, i + k as u64)?;
|
||||
write_video(&mut mux, ps)?;
|
||||
@@ -463,6 +441,8 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use effectcraft_raster::yuv::{UNIT8, round_pos};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The conversion as first written (per-pixel division and `f32::round`).
|
||||
|
||||
@@ -235,6 +235,7 @@ impl<'a> Cx<'a> {
|
||||
/// [`Cx::frames`] for `ks` while `encode` runs beside them on `beside`'s threads, so the
|
||||
/// render threads don't wait for the (largely sequential) encoder of the previous batch.
|
||||
/// With deferred readbacks, or where no encode thread could be started, `encode` runs first.
|
||||
#[cfg(test)]
|
||||
pub async fn frames_beside<T: Send, A: Send>(
|
||||
&self,
|
||||
beside: &Beside,
|
||||
@@ -242,13 +243,24 @@ impl<'a> Cx<'a> {
|
||||
ks: Vec<u64>,
|
||||
f: impl Fn(u64, Image) -> T + Sync + Send,
|
||||
encode: impl FnOnce() -> A + Send,
|
||||
) -> crate::Result<(A, Vec<T>)> {
|
||||
self.beside(beside, self.frames(comp, ks, f), encode).await
|
||||
}
|
||||
|
||||
/// Run `encode` on `beside`'s threads while `frames` renders the next batch. With deferred
|
||||
/// readbacks, or where no encode thread could be started, `encode` runs first.
|
||||
pub async fn beside<T: Send, A: Send>(
|
||||
&self,
|
||||
beside: &Beside,
|
||||
frames: impl std::future::Future<Output = Vec<T>>,
|
||||
encode: impl FnOnce() -> A + Send,
|
||||
) -> crate::Result<(A, Vec<T>)> {
|
||||
match &beside.0 {
|
||||
Some(pool) if !self.deferred() => {
|
||||
let mut done = None;
|
||||
let frames = pool.in_place_scope(|s| {
|
||||
s.spawn(|_| done = Some(std::panic::catch_unwind(std::panic::AssertUnwindSafe(encode))));
|
||||
effectcraft_render::passes::block_on(self.frames(comp, ks, f))
|
||||
effectcraft_render::passes::block_on(frames)
|
||||
});
|
||||
match done {
|
||||
Some(Ok(a)) => Ok((a, frames)),
|
||||
@@ -261,7 +273,7 @@ impl<'a> Cx<'a> {
|
||||
}
|
||||
_ => {
|
||||
let a = encode();
|
||||
Ok((a, self.frames(comp, ks, f).await))
|
||||
Ok((a, frames.await))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -304,6 +316,63 @@ impl<'a> Cx<'a> {
|
||||
img
|
||||
}
|
||||
|
||||
/// Output frame `i` as the H.264 export's 4:2:0 planes at `w`×`h`, rendered and converted
|
||||
/// by the accelerator ([`effectcraft_render::Accelerator::comp_frame_yuv420`]). Only for
|
||||
/// frames that need nothing done after the render (progressive, at the project's depth, no
|
||||
/// crop or resize, rendered at exactly `w`×`h`). `None`: render it with [`Cx::frame`].
|
||||
pub fn yuv420_frame(&self, comp: &Comp, i: u64, w: u32, h: u32) -> Option<Vec<u8>> {
|
||||
if self.deferred() {
|
||||
return None;
|
||||
}
|
||||
let FrameSample::Progressive(t) = self.job.settings.sample(comp, i) else { return None };
|
||||
let depth = self.job.settings.color_depth;
|
||||
if depth != ColorDepth::Current && depth.resolve(self.project.settings.bit_depth).levels().is_some() {
|
||||
return None;
|
||||
}
|
||||
let om = self.job.output;
|
||||
let scale = self.job.settings.resolution.clamp(0.01, 4.0);
|
||||
let size = |n: u32| ((n as f64 * scale).round() as u32).max(1);
|
||||
if om.resize.enabled || (size(comp.width), size(comp.height)) != (w, h) || om.crop.edges(w, h, scale) != (0, 0, 0, 0) {
|
||||
return None;
|
||||
}
|
||||
let t0 = Instant::now();
|
||||
let r = self.renderer();
|
||||
let planes = r.active_accel()?.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;
|
||||
}
|
||||
if let Some(ft) = &self.frame_times {
|
||||
ft.lock().unwrap_or_else(|e| e.into_inner()).push((i, t0.elapsed().as_secs_f64()));
|
||||
}
|
||||
Some(planes)
|
||||
}
|
||||
|
||||
/// Output frames `ks` for the H.264 encoder: the accelerator's planes
|
||||
/// ([`Cx::yuv420_frame`]) where it can, otherwise `fallback` of the frame rendered as usual.
|
||||
pub(crate) fn h264_frames<T: Send>(
|
||||
&self,
|
||||
comp: &Comp,
|
||||
ks: Vec<u64>,
|
||||
w: u32,
|
||||
h: u32,
|
||||
planes: impl Fn(Vec<u8>) -> T + Sync + Send,
|
||||
fallback: impl Fn(u64, Image) -> T + Sync + Send,
|
||||
) -> Vec<T> {
|
||||
// As in `frames`: once the GPU has stopped for good, the rest render in parallel.
|
||||
let gpu_live = || self.job.accel.is_none_or(|a| a.available());
|
||||
map_frames_while(
|
||||
ks,
|
||||
self.gpu_cap(),
|
||||
gpu_live,
|
||||
|k| self.yuv420_frame(comp, k, w, h).ok_or_else(|| self.frame(comp, k)),
|
||||
|k, r| match r {
|
||||
Ok(p) => planes(p),
|
||||
Err(img) => fallback(k, img),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Output Module ▸ Crop, then Resize.
|
||||
fn crop_resize(&self, img: Image) -> Image {
|
||||
let om = self.job.output;
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//! H.264 export with an accelerator that converts frames to 4:2:0 itself
|
||||
//! (`Accelerator::comp_frame_yuv420`): the file is the same as the CPU conversion's, and frames
|
||||
//! the accelerator can't take (odd sizes, crop) fall back to the RGBA path.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_export::render_queue::{OutputFormat, OutputModule, RenderSettings, TimeSpan};
|
||||
use effectcraft_export::{Job, export};
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid, build};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_render::{Accelerator, NoFootage, Renderer};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
/// Renders on the CPU; converts to 4:2:0 with the export's own function when `yuv` is set.
|
||||
/// `deferred`: claims deferred readbacks (a browser worker's GPU), whose frames render in passes.
|
||||
/// `stop_after`: the device stops for good after that many planes (`available` turns false).
|
||||
struct Fake {
|
||||
yuv: bool,
|
||||
yuv_frames: AtomicUsize,
|
||||
deferred: bool,
|
||||
stop_after: usize,
|
||||
/// Frames asking for planes right now, and the most at once after the stop.
|
||||
asking: AtomicUsize,
|
||||
most_after_stop: 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) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Accelerator for Fake {
|
||||
fn name(&self) -> String {
|
||||
"fake".into()
|
||||
}
|
||||
fn comp_frame(&self, r: &Renderer, comp: ItemId, t: Tick) -> Option<Image> {
|
||||
Some(r.comp_frame_cpu(comp, t))
|
||||
}
|
||||
fn comp_frame_yuv420(&self, r: &Renderer, comp: ItemId, t: Tick, bg: [f32; 3]) -> Option<Vec<u8>> {
|
||||
if !self.yuv {
|
||||
return None;
|
||||
}
|
||||
if self.yuv_frames.fetch_add(1, Ordering::SeqCst) >= self.stop_after {
|
||||
self.yuv_frames.fetch_sub(1, Ordering::SeqCst);
|
||||
// Stopped: every frame still asks first, so asks that overlap are frames rendering at once.
|
||||
let now = self.asking.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
self.most_after_stop.fetch_max(now, Ordering::SeqCst);
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
self.asking.fetch_sub(1, Ordering::SeqCst);
|
||||
return None;
|
||||
}
|
||||
Some(effectcraft_raster::yuv::yuv420_over(&r.comp_frame_cpu(comp, t), bg))
|
||||
}
|
||||
fn available(&self) -> bool {
|
||||
self.yuv_frames.load(Ordering::SeqCst) < self.stop_after
|
||||
}
|
||||
fn miss_gate(&self) -> Option<std::sync::Arc<std::sync::atomic::AtomicBool>> {
|
||||
self.deferred.then(Default::default)
|
||||
}
|
||||
fn supports_effect(&self, _id: &str) -> bool {
|
||||
false
|
||||
}
|
||||
fn effects(&self, _chain: &[effectcraft_render::FxStep], _buf: &effectcraft_effects::Buf, _levels: Option<f32>) -> Option<effectcraft_effects::Buf> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn out_dir(name: &str) -> PathBuf {
|
||||
let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export").join(name);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).expect("mkdir");
|
||||
d
|
||||
}
|
||||
|
||||
fn project() -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
p.settings.gpu_acceleration = true;
|
||||
let mut comp = Comp::new(64, 48, FrameRate::new(10, 1), Tick::from_seconds_f64(0.6));
|
||||
comp.background = [0.1, 0.1, 0.4];
|
||||
let sid = p.add_item("Red", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 0.2, 0.0], width: 30, height: 20, pixel_aspect: 1.0 }));
|
||||
let mut l = build::layer(&mut p, &comp, "Red", LayerSource::Solid { item: sid }, (30, 20), None);
|
||||
l.in_point = comp.frame_rate.tick_of(2);
|
||||
comp.layers.push(l);
|
||||
let cid = p.add_item("Comp 1", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
fn run(p: &Project, cid: ItemId, accel: Option<&dyn Accelerator>, s: &RenderSettings, path: &Path) -> Vec<u8> {
|
||||
let om = OutputModule { bitrate_kbps: 4000, ..OutputModule::for_format(OutputFormat::H264) };
|
||||
let path = path.to_string_lossy().to_string();
|
||||
let job = Job {
|
||||
project: p,
|
||||
footage: &NoFootage,
|
||||
expr: None,
|
||||
accel,
|
||||
comp: cid,
|
||||
settings: s,
|
||||
output: &om,
|
||||
path: &path,
|
||||
sink: None,
|
||||
options: Default::default(),
|
||||
nested_switches: true,
|
||||
};
|
||||
export(&job, &mut |_| true).expect("export");
|
||||
std::fs::read(&path).expect("read")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accelerator_planes_encode_like_the_cpu_conversion() {
|
||||
let d = out_dir("h264-gpu-yuv");
|
||||
let (mut p, cid) = project();
|
||||
// Across more than one 16-frame batch so the second batch renders beside the encoder.
|
||||
p.comp_mut(cid).unwrap().duration = Tick::from_seconds_f64(2.0);
|
||||
let s = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
|
||||
let yuv = Fake::new(true, false, usize::MAX);
|
||||
let rgba = Fake::new(false, false, usize::MAX);
|
||||
let yuv_mp4 = d.join("yuv.mp4");
|
||||
let a = run(&p, cid, Some(&yuv), &s, &yuv_mp4);
|
||||
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!(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));
|
||||
assert_eq!(f.frame_rate.frame_at(f.duration - Tick(1)) + 1, 20);
|
||||
let pool = effectcraft_media::MediaPool::new();
|
||||
for k in 0..20 {
|
||||
let img = pool.frame_at(&f, f.frame_rate.tick_of(k)).expect("decode frame");
|
||||
assert_eq!((img.width, img.height), (64, 48));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_the_accelerator_cannot_take_fall_back_to_rgba() {
|
||||
let d = out_dir("h264-gpu-yuv-fallback");
|
||||
let (p, cid) = project();
|
||||
let base = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
|
||||
// Renders at 62×47, encodes at 62×46 (the codec size): planes of the render would not fit.
|
||||
let odd = RenderSettings { resolution: 31.0 / 32.0, ..base.clone() };
|
||||
let yuv = Fake::new(true, false, usize::MAX);
|
||||
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!(a == c);
|
||||
}
|
||||
|
||||
/// With deferred readbacks the export keeps rendering frames in passes (`Cx::frames`), never
|
||||
/// through the blocking per-frame path the planes use.
|
||||
#[test]
|
||||
fn deferred_accelerators_keep_the_pass_based_path() {
|
||||
let d = out_dir("h264-gpu-yuv-deferred");
|
||||
let (p, cid) = project();
|
||||
let s = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
|
||||
let deferred = Fake::new(true, true, usize::MAX);
|
||||
let a = run(&p, cid, Some(&deferred), &s, &d.join("deferred.mp4"));
|
||||
let c = run(&p, cid, None, &s, &d.join("cpu.mp4"));
|
||||
assert_eq!(deferred.yuv_frames.load(Ordering::SeqCst), 0, "no planes asked for");
|
||||
assert!(a == c);
|
||||
}
|
||||
|
||||
/// An accelerator that stops for good mid-export (`available` false): the frames left render
|
||||
/// on the CPU side by side, not one at a time under the GPU's frames-in-flight cap (as
|
||||
/// `Cx::frames` does), and the file is still the CPU's.
|
||||
#[test]
|
||||
fn frames_after_the_accelerator_stops_render_on_the_cpu() {
|
||||
let d = out_dir("h264-gpu-yuv-stops");
|
||||
let (p, cid) = project();
|
||||
let s = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
|
||||
let stops = Fake::new(true, false, 1);
|
||||
// Enough threads for frames to overlap wherever the machine runs it.
|
||||
let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build().unwrap();
|
||||
let a = pool.install(|| run(&p, cid, Some(&stops), &s, &d.join("stops.mp4")));
|
||||
let c = run(&p, cid, None, &s, &d.join("cpu.mp4"));
|
||||
assert_eq!(stops.yuv_frames.load(Ordering::SeqCst), 1, "planes only until the stop");
|
||||
assert!(stops.most_after_stop.load(Ordering::SeqCst) > 1, "frames after the stop rendered one at a time");
|
||||
assert!(a == c);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
|
||||
|
||||
mod audio_sample_positions;
|
||||
mod h264_gpu_yuv;
|
||||
mod hevc_av1_roundtrip;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod mov_audio_sample_positions;
|
||||
|
||||
@@ -261,6 +261,9 @@ impl Mat3 {
|
||||
return Mat3([[x1 - x0, x3 - x0, x0], [y1 - y0, y3 - y0, y0], [0.0, 0.0, 1.0]]);
|
||||
}
|
||||
let den = dx1 * dy2 - dx2 * dy1;
|
||||
if den == 0.0 || !den.is_finite() {
|
||||
return Mat3::IDENTITY;
|
||||
}
|
||||
let g = (sx * dy2 - dx2 * sy) / den;
|
||||
let h = (dx1 * sy - sx * dy1) / den;
|
||||
Mat3([[x1 - x0 + g * x1, x3 - x0 + h * x3, x0], [y1 - y0 + g * y1, y3 - y0 + h * y3, y0], [g, h, 1.0]])
|
||||
@@ -449,6 +452,19 @@ pub fn look_at(pos: Vec3, poi: Vec3, extra: Mat4) -> Mat4 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A quad with three collinear corners has no projective map: identity, not NaN.
|
||||
#[test]
|
||||
fn square_to_quad_degenerate_is_finite() {
|
||||
let m = Mat3::square_to_quad([vec2(0.0, 5.0), vec2(0.0, 0.0), vec2(10.0, 0.0), vec2(20.0, 0.0)]);
|
||||
assert!(m.0.iter().flatten().all(|v| v.is_finite()), "non-finite: {m:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn square_to_quad_regular_is_finite() {
|
||||
let m = Mat3::square_to_quad([vec2(0.0, 0.0), vec2(10.0, 0.0), vec2(8.0, 6.0), vec2(0.0, 5.0)]);
|
||||
assert!(m.0.iter().flatten().all(|v| v.is_finite()));
|
||||
}
|
||||
|
||||
fn close(a: Vec2, b: Vec2) -> bool {
|
||||
(a - b).length() < 1e-9
|
||||
}
|
||||
|
||||
@@ -126,6 +126,10 @@ const LIGHT_BYTES: usize = 6 * 16;
|
||||
/// Composite a prepared Classic 3D run over `canvas`. `None` when it cannot run here (the
|
||||
/// caller draws it on the CPU).
|
||||
pub(crate) fn draw_run(e: &mut Enc, run: &Run3d, canvas: &GpuImage) -> Option<GpuImage> {
|
||||
// FXC (DirectX 12) may fail to build the kernel (#613): the caller renders the run on the CPU.
|
||||
if !e.g.has_kernel("classic3d") {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = (canvas.width, canvas.height);
|
||||
let boxes: Vec<[i64; 4]> = run.planes.iter().filter(|p| p.draw).map(|p| p.bbox).collect();
|
||||
if boxes.is_empty() {
|
||||
|
||||
+85
-10
@@ -30,7 +30,8 @@ const ENTRIES: &[&str] = &[
|
||||
const EFFECT_ENTRIES: &[&str] = &["box_h", "box_v", "directional", "glow_bright", "glow_combine", "shadow_make", "shadow_combine", "pointwise"];
|
||||
|
||||
/// Entry points that also bind group 1 (four read-only storage buffers; see
|
||||
/// [`Enc::dispatch_ext`]).
|
||||
/// [`Enc::dispatch_ext`]). Like the effect kernels, one that fails to build (FXC on DirectX 12
|
||||
/// can't unroll `classic3d`'s loops, #613) leaves its work to the CPU.
|
||||
const EXT_ENTRIES: &[&str] = &["classic3d"];
|
||||
|
||||
/// Pixel format of every working texture: premultiplied RGBA, 32-bit float (32 bpc headroom;
|
||||
@@ -384,6 +385,12 @@ pub struct GpuContext {
|
||||
pub(crate) particle_states: crate::particles::StatesCell,
|
||||
/// Deferred readbacks (a browser worker's WebGPU device), see [`crate::deferred`].
|
||||
pub(crate) deferred: Option<Arc<crate::deferred::Deferred>>,
|
||||
/// The adapter is a software rasteriser (llvmpipe, WARP): its float maths follows the
|
||||
/// CPU's exactly, so the YUV tests hold it to exact parity.
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(crate) software: bool,
|
||||
/// Export frame → YUV 4:2:0 kernel (built on first use).
|
||||
pub(crate) yuv: std::sync::OnceLock<Result<crate::yuv::YuvPipe, String>>,
|
||||
readbacks: Arc<crate::readback::Readbacks>,
|
||||
}
|
||||
|
||||
@@ -437,7 +444,7 @@ pub(crate) fn init_resource<T>(device: &wgpu::Device, label: &str, create: impl
|
||||
/// Browser scope completion is asynchronous. Preserve the existing browser constructor;
|
||||
/// never block its event loop or introduce a guard across an asynchronous boundary here.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn init_resource<T>(_device: &wgpu::Device, _label: &str, create: impl FnOnce() -> T) -> Result<T, String> {
|
||||
pub(crate) fn init_resource<T>(_device: &wgpu::Device, _label: &str, create: impl FnOnce() -> T) -> Result<T, String> {
|
||||
Ok(create())
|
||||
}
|
||||
|
||||
@@ -616,15 +623,21 @@ impl GpuContext {
|
||||
})
|
||||
};
|
||||
// The compositor's own kernels are required; an effect kernel that doesn't build is
|
||||
// left out, and its effects render on the CPU (see `Enc::take_missing`).
|
||||
let mut pipelines = ENTRIES
|
||||
.iter()
|
||||
.chain(crate::adv3d::SKY_KERNELS)
|
||||
.map(|e| (e, &layout))
|
||||
.chain(EXT_ENTRIES.iter().map(|e| (e, &layout_ext)))
|
||||
.map(|(e, layout)| Ok((*e, build(e, layout)?)))
|
||||
.collect::<Result<HashMap<_, _>, String>>()?;
|
||||
// left out, and its effects render on the CPU (see `Enc::take_missing`). So is a
|
||||
// missing Classic 3D kernel: its 3D runs render on the CPU.
|
||||
let mut pipelines = ENTRIES.iter().chain(crate::adv3d::SKY_KERNELS).map(|e| Ok((*e, build(e, &layout)?))).collect::<Result<HashMap<_, _>, String>>()?;
|
||||
let mut unavailable = vec![];
|
||||
for e in EXT_ENTRIES {
|
||||
match build(e, &layout_ext) {
|
||||
Ok(p) => {
|
||||
pipelines.insert(*e, p);
|
||||
}
|
||||
Err(why) => {
|
||||
log::warn!("gpu {name}: kernel {e} unavailable, its 3D layers render on the CPU: {why}");
|
||||
unavailable.push((*e, why));
|
||||
}
|
||||
}
|
||||
}
|
||||
for e in EFFECT_ENTRIES
|
||||
.iter()
|
||||
.chain(crate::fx_color::KERNELS)
|
||||
@@ -731,6 +744,8 @@ impl GpuContext {
|
||||
transfers: Default::default(),
|
||||
zeros: Default::default(),
|
||||
adv3d: std::sync::OnceLock::new(),
|
||||
yuv: std::sync::OnceLock::new(),
|
||||
software: info.device_type == wgpu::DeviceType::Cpu,
|
||||
adv3d_raster: adv3d_unsupported.is_none(),
|
||||
particles: Default::default(),
|
||||
particle_states: Default::default(),
|
||||
@@ -746,6 +761,18 @@ impl GpuContext {
|
||||
self.readbacks.check()
|
||||
}
|
||||
|
||||
/// Export frame → YUV 4:2:0 kernel, initialized once and `None` when shader or pipeline creation fails.
|
||||
pub(crate) fn yuv_pipe(&self) -> Option<&crate::yuv::YuvPipe> {
|
||||
self.yuv
|
||||
.get_or_init(|| {
|
||||
crate::yuv::YuvPipe::new(&self.device).inspect_err(|e| {
|
||||
log::warn!(target: "effectcraft_gpu", "yuv420 kernel unavailable: {e}");
|
||||
})
|
||||
})
|
||||
.as_ref()
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// A notification hook for the owner of a shared device's error/loss handlers.
|
||||
/// Invoke it with diagnostic context when the host observes a device failure.
|
||||
/// It retires this context and settles pending readbacks without replacing any
|
||||
@@ -907,6 +934,11 @@ impl GpuContext {
|
||||
&self.unavailable
|
||||
}
|
||||
|
||||
/// Whether kernel `entry` was built (an effect or Classic 3D kernel can be missing, #613).
|
||||
pub(crate) fn has_kernel(&self, entry: &str) -> bool {
|
||||
self.pipelines.contains_key(entry)
|
||||
}
|
||||
|
||||
/// Leave kernel `entry` out, as if it had failed to build (tests of the CPU fallback).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn drop_kernel(&mut self, entry: &'static str) {
|
||||
@@ -914,6 +946,49 @@ impl GpuContext {
|
||||
self.unavailable.push((entry, "dropped by a test".into()));
|
||||
}
|
||||
|
||||
/// A copy of this context sharing the same [`wgpu::Device`] and [`wgpu::Queue`] with
|
||||
/// kernel `entry` left out, as if it had failed to build (tests of the CPU fallback
|
||||
/// without opening a second device on `llvmpipe`).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn without_kernel(&self, entry: &'static str) -> GpuContext {
|
||||
let mut pipelines = self.pipelines.clone();
|
||||
pipelines.remove(entry);
|
||||
let mut unavailable = self.unavailable.clone();
|
||||
unavailable.push((entry, "dropped by a test".into()));
|
||||
GpuContext {
|
||||
device: self.device.clone(),
|
||||
queue: self.queue.clone(),
|
||||
name: self.name.clone(),
|
||||
max_dim: self.max_dim,
|
||||
median_kernel: self.median_kernel,
|
||||
bgl: self.bgl.clone(),
|
||||
bgl_ext: self.bgl_ext.clone(),
|
||||
pipelines,
|
||||
unavailable,
|
||||
display_bgl: self.display_bgl.clone(),
|
||||
display: self.display.clone(),
|
||||
display_mip: self.display_mip.clone(),
|
||||
dummy_tex: self.dummy_tex.clone(),
|
||||
dummy: self.dummy.clone(),
|
||||
dummy_buf: self.dummy_buf.clone(),
|
||||
uploads: Mutex::new(Uploads::default()),
|
||||
upload_budget: std::sync::atomic::AtomicUsize::new(UPLOAD_BUDGET),
|
||||
upload_full_warned: Default::default(),
|
||||
pool: self.pool.clone(),
|
||||
staging: Default::default(),
|
||||
transfers: Default::default(),
|
||||
zeros: Default::default(),
|
||||
adv3d: std::sync::OnceLock::new(),
|
||||
adv3d_raster: self.adv3d_raster,
|
||||
particles: Default::default(),
|
||||
particle_states: Default::default(),
|
||||
deferred: self.deferred.clone(),
|
||||
readbacks: self.readbacks.clone(),
|
||||
software: self.software,
|
||||
yuv: std::sync::OnceLock::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes of readback staging buffers kept for reuse (tests).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn staging_bytes(&self) -> u64 {
|
||||
|
||||
+39
-13
@@ -2,7 +2,6 @@
|
||||
//! radii, parameter conversions), with the pixel loops as compute kernels.
|
||||
|
||||
use effectcraft_effects::{Buf, EffectCtx};
|
||||
use effectcraft_geom::{Mat3, vec2};
|
||||
use effectcraft_raster::Sampling;
|
||||
use effectcraft_render::FxStep;
|
||||
|
||||
@@ -27,6 +26,24 @@ impl GBuf {
|
||||
(p[0] * self.scale + self.offset[0], p[1] * self.scale + self.offset[1])
|
||||
}
|
||||
|
||||
/// Buf::pad_sides: grow by transparent pixels (left, top, right, bottom).
|
||||
pub fn pad_sides(&mut self, e: &mut Enc, sides: [u32; 4]) -> Option<()> {
|
||||
let [l, t, r, b] = sides;
|
||||
if l == 0 && t == 0 && r == 0 && b == 0 {
|
||||
return Some(());
|
||||
}
|
||||
let (w, h) = (self.img.width.checked_add(l)?.checked_add(r)?, self.img.height.checked_add(t)?.checked_add(b)?);
|
||||
if !e.g.fits(w, h) {
|
||||
return None;
|
||||
}
|
||||
let out = e.image(w, h);
|
||||
e.copy_into(&self.img, &out, l, t);
|
||||
self.img = out;
|
||||
self.offset[0] += l as f64;
|
||||
self.offset[1] += t as f64;
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Buf::pad: grow by `pad` transparent pixels on every side.
|
||||
pub fn pad(&mut self, e: &mut Enc, pad: u32) -> Option<()> {
|
||||
if pad == 0 {
|
||||
@@ -339,10 +356,11 @@ fn glow(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
|
||||
p.f[2] = cb;
|
||||
let bright = e.image(b.img.width, b.img.height);
|
||||
e.pixels("glow_bright", &p, &b.img, None, &bright, None);
|
||||
let s = (radius / 2.0).max(0.5);
|
||||
let s = effectcraft_effects::glow_sigma(radius);
|
||||
let blurred = gaussian_blur(e, &bright, s * kx, s * ky, false);
|
||||
let mut p = Params::default();
|
||||
p.u[0][0] = ctx.params.e("operation").min(2);
|
||||
p.u[0][1] = (ctx.params.e("based") == 0) as u32;
|
||||
p.f[0][0] = intensity;
|
||||
let out = e.scratch(b.img.width, b.img.height);
|
||||
e.pixels("glow_combine", &p, &b.img, Some(&blurred), &out, None);
|
||||
@@ -384,16 +402,14 @@ fn transform(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
|
||||
if ctx.env.host.is_some() && effectcraft_effects::transform_shutter(ctx).is_some() {
|
||||
return None;
|
||||
}
|
||||
let anchor = b.to_px(ctx.params.v2("anchor"));
|
||||
let pos = b.to_px(ctx.params.v2("position"));
|
||||
let sh = ctx.params.f("scaleHeight");
|
||||
let sw = if ctx.params.b("uniform") { sh } else { ctx.params.f("scaleWidth") };
|
||||
let opacity = ctx.params.f("opacity") as f32 / 100.0;
|
||||
let m = Mat3::translate(vec2(pos.0, pos.1))
|
||||
* Mat3::rotate_deg(ctx.params.f("rotation"))
|
||||
* Mat3::skew_deg(ctx.params.f("skew"), ctx.params.f("skewAxis"))
|
||||
* Mat3::scale(vec2(sw / 100.0, sh / 100.0))
|
||||
* Mat3::translate(vec2(-anchor.0, -anchor.1));
|
||||
// As distort::transform: After Effects' matrix, drawn outside the layer's bounds.
|
||||
if !ctx.adjustment {
|
||||
let m = effectcraft_effects::transform_matrix_px(ctx.params, |p| b.to_px(p));
|
||||
let sides = effectcraft_effects::transform_grow(&[m], b.img.width, b.img.height);
|
||||
b.pad_sides(e, sides)?;
|
||||
}
|
||||
let m = effectcraft_effects::transform_matrix_px(ctx.params, |p| b.to_px(p));
|
||||
let sampling = if ctx.params.e("sampling") == 1 { Sampling::Bicubic } else { Sampling::Bilinear };
|
||||
let empty = e.image(b.img.width, b.img.height);
|
||||
b.img = ops::warp(e, &empty, &b.img, &m, ops::WarpOptions { sampling, mode: effectcraft_color::BlendMode::Normal, opacity, seed: 0 }, None);
|
||||
@@ -543,9 +559,19 @@ fn pointwise(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let sub = (f("subSettings/subScaling") / 100.0).clamp(0.1, 1.0);
|
||||
p.u[0] = [10, kind, octaves.ceil() as u32, f("evolutionOptions/seed") as u32 ^ 0x51ed];
|
||||
p.u[1] = [pr.b("invert") as u32, pr.e("overflow"), mode, 0];
|
||||
p.f[0] = [f("contrast") as f32 / 100.0, f("brightness") as f32 / 100.0, (sw * b.scale).max(1.0) as f32, (octaves - octaves.floor()) as f32];
|
||||
p.f[0] = [
|
||||
f("contrast") as f32 / 100.0,
|
||||
f("brightness") as f32 / 100.0,
|
||||
(sw * effectcraft_effects::NOISE_CELL * b.scale).max(1.0) as f32,
|
||||
(octaves - octaves.floor()) as f32,
|
||||
];
|
||||
p.f[1] = [ox as f32, oy as f32, sr, cr];
|
||||
p.f[2] = [f("evolution") as f32 / 360.0, f("blend") as f32 / 100.0, (f("opacity") as f32 / 100.0).clamp(0.0, 1.0), (sh * b.scale).max(1.0) as f32];
|
||||
p.f[2] = [
|
||||
f("evolution") as f32 / 360.0,
|
||||
f("blend") as f32 / 100.0,
|
||||
(f("opacity") as f32 / 100.0).clamp(0.0, 1.0),
|
||||
(sh * effectcraft_effects::NOISE_CELL * b.scale).max(1.0) as f32,
|
||||
];
|
||||
p.f[3] = [(f("subSettings/subInfluence") as f32 / 100.0).clamp(0.0, 1.0), 1.0 / sub as f32, 0.0, 0.0];
|
||||
}
|
||||
_ => return None,
|
||||
|
||||
@@ -207,55 +207,53 @@ fn linear_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
}
|
||||
|
||||
fn venetian(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let done = ctx.params.f("completion") / 100.0;
|
||||
let done = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0);
|
||||
if done <= 0.0 {
|
||||
return Some(b);
|
||||
}
|
||||
let ang = ctx.params.f("direction").to_radians();
|
||||
let width = (ctx.params.f("width") * b.scale).max(1.0);
|
||||
let feather = (ctx.params.f("feather") * b.scale).max(0.001);
|
||||
let sigma = ctx.params.f("feather").max(0.0) * b.scale / 2.0;
|
||||
// The CPU's even veil and term count (effects::venetian_cover).
|
||||
if sigma > 8.0 * width {
|
||||
return None;
|
||||
}
|
||||
let (dx, dy) = (ang.cos(), ang.sin());
|
||||
let mut data = Vec::with_capacity((6 * b.img.width + 2 * b.img.height) as usize);
|
||||
let (ox, oy) = b.to_px(effectcraft_effects::venetian_origin(ctx.layer_size));
|
||||
let mut data = Vec::with_capacity((4 * b.img.width + 2 * b.img.height) as usize);
|
||||
for x in 0..b.img.width {
|
||||
let cx = ((x as f64 + 0.5) * dx).rem_euclid(width);
|
||||
data.extend(hl(cx - done * width));
|
||||
data.extend(hl(cx - width - done * width));
|
||||
let cx = ((x as f64 + 0.5 - ox) * dx).rem_euclid(width);
|
||||
data.extend(hl(cx));
|
||||
data.extend(hl(cx - width));
|
||||
}
|
||||
for y in 0..b.img.height {
|
||||
data.extend(hl(((y as f64 + 0.5) * dy).rem_euclid(width)));
|
||||
data.extend(hl(((y as f64 + 0.5 - oy) * dy).rem_euclid(width)));
|
||||
}
|
||||
let mut p = Params::default();
|
||||
p.u[0][0] = 6 * b.img.width;
|
||||
p.f[0][0] = feather as f32;
|
||||
p.u[0][0] = 4 * b.img.width;
|
||||
p.u[0][1] = if sigma < 1e-3 { 0 } else { ((4.0 * sigma / width).ceil() as u32 + 1).clamp(1, 40) };
|
||||
p.f[0] = [width as f32, (done * width) as f32, sigma as f32, 0.0];
|
||||
run(e, "gen_venetian", &p, b, None, Some(&data))
|
||||
}
|
||||
|
||||
fn radial_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let done = ctx.params.f("completion") / 100.0;
|
||||
let done = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0);
|
||||
if done <= 0.0 {
|
||||
return Some(b);
|
||||
}
|
||||
let start = ctx.params.f("startAngle");
|
||||
let c = b.to_px(ctx.params.v2("center"));
|
||||
let dir = ctx.params.e("wipe");
|
||||
let feather = ctx.params.f("feather").max(0.01);
|
||||
let edge = if dir == 2 { done * 180.0 } else { done * 360.0 };
|
||||
// Rotations: the start angle, and the start angle ± the edge (where the wipe's edge lies).
|
||||
let rots = [start, start + edge, start - edge].map(|r| r.to_radians().sin_cos());
|
||||
let mut data = Vec::with_capacity(9 * (b.img.width + b.img.height) as usize);
|
||||
for x in 0..b.img.width {
|
||||
let ux = x as f64 + 0.5 - c.0;
|
||||
for (s, co) in rots {
|
||||
data.extend(hl(ux * co));
|
||||
data.push((ux * s) as f32);
|
||||
}
|
||||
}
|
||||
for y in 0..b.img.height {
|
||||
let uy = -(y as f64 + 0.5 - c.1);
|
||||
for (s, co) in rots {
|
||||
data.extend(hl(uy * s));
|
||||
data.push((uy * co) as f32);
|
||||
}
|
||||
}
|
||||
let (w0, span) = effectcraft_effects::radial_wipe_wedge(ctx.params.e("wipe"), done);
|
||||
let sigma = effectcraft_effects::radial_wipe_sigma(ctx.params.f("feather")) * b.scale;
|
||||
let ray0 = start + w0;
|
||||
let (s0, c0) = ray0.to_radians().sin_cos();
|
||||
let (s1, c1) = (ray0 + span).to_radians().sin_cos();
|
||||
let mut p = Params::default();
|
||||
p.u[0] = [9 * b.img.width, dir.min(2), 0, 0];
|
||||
p.f[0] = [edge as f32, feather as f32, 0.0, 0.0];
|
||||
run(e, "gen_radial_wipe", &p, b, None, Some(&data))
|
||||
p.u[0] = [(done >= 1.0) as u32, (sigma < 1e-3) as u32, 0, 0];
|
||||
p.f[0] = [c.0 as f32, c.1 as f32, sigma.max(1e-3) as f32, ray0.rem_euclid(360.0) as f32];
|
||||
p.f[1] = [s0 as f32, c0 as f32, s1 as f32, c1 as f32];
|
||||
p.f[2][0] = span as f32;
|
||||
run(e, "gen_radial_wipe", &p, b, None, None)
|
||||
}
|
||||
|
||||
fn gradient_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
|
||||
@@ -937,11 +937,12 @@ pub(crate) fn glow(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
|
||||
p.f[2] = ctx.params.color("colorB");
|
||||
e.pixels("glow_bright", &p, &b.img, None, &bright, None);
|
||||
}
|
||||
let s = (radius / 2.0).max(0.5);
|
||||
let s = effectcraft_effects::glow_sigma(radius);
|
||||
let blurred = gaussian_blur(e, &bright, s * kx, s * ky, false);
|
||||
let operation = ctx.params.e("operation");
|
||||
let glow_op = effectcraft_effects::glow_operation(ctx);
|
||||
let mut p = Params::default();
|
||||
p.u[0][1] = (ctx.params.e("based") == 0) as u32;
|
||||
p.f[0][0] = intensity;
|
||||
let out = e.scratch(b.img.width, b.img.height);
|
||||
if operation == 0 && glow_op != BlendMode::Add {
|
||||
@@ -963,15 +964,7 @@ pub(crate) fn transform_blur(ctx: &EffectCtx) -> bool {
|
||||
|
||||
/// distort::transform_matrix.
|
||||
fn transform_matrix(pr: &effectcraft_effects::Params, b: &GBuf) -> Mat3 {
|
||||
let anchor = b.to_px(pr.v2("anchor"));
|
||||
let pos = b.to_px(pr.v2("position"));
|
||||
let sh = pr.f("scaleHeight");
|
||||
let sw = if pr.b("uniform") { sh } else { pr.f("scaleWidth") };
|
||||
Mat3::translate(vec2(pos.0, pos.1))
|
||||
* Mat3::rotate_deg(pr.f("rotation"))
|
||||
* Mat3::skew_deg(pr.f("skew"), pr.f("skewAxis"))
|
||||
* Mat3::scale(vec2(sw / 100.0, sh / 100.0))
|
||||
* Mat3::translate(vec2(-anchor.0, -anchor.1))
|
||||
effectcraft_effects::transform_matrix_px(pr, |p| b.to_px(p))
|
||||
}
|
||||
|
||||
/// distort::transform's motion blur: the transform at Samples instants across the shutter,
|
||||
@@ -981,15 +974,24 @@ pub(crate) fn transform(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBu
|
||||
let host = ctx.env.host?;
|
||||
let sampling = if ctx.params.e("sampling") == 1 { Sampling::Bicubic } else { Sampling::Bilinear };
|
||||
let fd = 1.0 / ctx.fps();
|
||||
let samples: Vec<effectcraft_effects::Params> =
|
||||
(0..n).filter_map(|k| host.params_at(ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd)).collect();
|
||||
if samples.is_empty() {
|
||||
// The CPU renders the frame sharp.
|
||||
return None;
|
||||
}
|
||||
if !ctx.adjustment {
|
||||
let ms: Vec<Mat3> = samples.iter().map(|pr| transform_matrix(pr, &b)).collect();
|
||||
let sides = effectcraft_effects::transform_grow(&ms, b.img.width, b.img.height);
|
||||
b.pad_sides(e, sides)?;
|
||||
}
|
||||
let (w, h) = (b.img.width, b.img.height);
|
||||
let mut acc = e.image(w, h);
|
||||
let mut count = 0u32;
|
||||
for k in 0..n {
|
||||
let t = ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd;
|
||||
let Some(pr) = host.params_at(t) else { continue };
|
||||
for pr in &samples {
|
||||
let op = pr.f("opacity") as f32 / 100.0;
|
||||
let empty = e.image(w, h);
|
||||
let one = ops::warp(e, &empty, &b.img, &transform_matrix(&pr, &b), ops::WarpOptions { sampling, mode: BlendMode::Normal, opacity: op, seed: 0 }, None);
|
||||
let one = ops::warp(e, &empty, &b.img, &transform_matrix(pr, &b), ops::WarpOptions { sampling, mode: BlendMode::Normal, opacity: op, seed: 0 }, None);
|
||||
let sum = e.image(w, h);
|
||||
e.pixels("fxs_add", &Params::default(), &acc, Some(&one), &sum, None);
|
||||
acc = sum;
|
||||
|
||||
@@ -83,6 +83,7 @@ mod ops;
|
||||
mod particles;
|
||||
mod readback;
|
||||
mod walk;
|
||||
mod yuv;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -339,6 +340,25 @@ impl Accelerator for Gpu {
|
||||
img
|
||||
}
|
||||
|
||||
fn comp_frame_yuv420(&self, r: &Renderer, comp: ItemId, t: Tick, bg: [f32; 3]) -> Option<Vec<u8>> {
|
||||
self.ctx.check_health().ok()?;
|
||||
if !self.ctx.can_readback() || self.ctx.deferred.is_some() {
|
||||
return None;
|
||||
}
|
||||
// Sizes the kernel can't take fall back before any GPU work, not after a render.
|
||||
let (w, h) = walk::frame_size(r, comp, t)?;
|
||||
if !yuv::supported(w, h) || !yuv::fits_limits(w, h, &self.ctx.device.limits()) {
|
||||
return None;
|
||||
}
|
||||
let yp = self.ctx.yuv_pipe()?;
|
||||
let mut e = Enc::new(&self.ctx);
|
||||
let img = walk::render(&mut e, r, comp, t)?;
|
||||
let planes = yuv::convert(&mut e, yp, &img, bg)?;
|
||||
// A device fault seen while the frame recorded makes the planes untrustworthy.
|
||||
self.ctx.check_health().ok()?;
|
||||
Some(planes)
|
||||
}
|
||||
|
||||
fn supports_effect(&self, id: &str) -> bool {
|
||||
self.ctx.check_health().is_ok() && self.ctx.can_readback() && effects::supports(id)
|
||||
}
|
||||
|
||||
@@ -194,6 +194,25 @@ fn tap_hash(x: i32, y: i32) -> u32 {
|
||||
return h;
|
||||
}
|
||||
|
||||
fn tap_jitter_at(x: i32, y: i32) -> vec2<f32> {
|
||||
let h = tap_hash(x, y);
|
||||
return vec2<f32>(f32(h & 0xFFFFu) * (1.0 / 65536.0) - 0.5, f32((h >> 16u) & 0xFFFFu) * (1.0 / 65536.0) - 0.5);
|
||||
}
|
||||
|
||||
// shade::tap_jitter: the hash at the four corners of the receiver's 1/8-texel lattice
|
||||
// cell (cx, cy), blended bilinearly by the receiver's position w in the cell.
|
||||
fn tap_jitter(cx: i32, cy: i32, w: vec2<f32>, i: i32, j: i32) -> vec2<f32> {
|
||||
let x = cx + i * 7919;
|
||||
let y = cy + j * 7919;
|
||||
let c00 = tap_jitter_at(x, y);
|
||||
let c10 = tap_jitter_at(x + 1, y);
|
||||
let c01 = tap_jitter_at(x, y + 1);
|
||||
let c11 = tap_jitter_at(x + 1, y + 1);
|
||||
let a = c00 + (c10 - c00) * w.x;
|
||||
let b = c01 + (c11 - c01) * w.x;
|
||||
return a + (b - a) * w.y;
|
||||
}
|
||||
|
||||
const PCF_MAX_TAPS: i32 = 10;
|
||||
const PLANE_SLOPE_MAX: f32 = 64.0;
|
||||
|
||||
@@ -244,7 +263,8 @@ fn shadow_factor(m: u32, p: vec3<f32>, ng: vec3<f32>, n_l: f32) -> f32 {
|
||||
// shadows off their casters), each texel compared against the receiver plane's
|
||||
// depth at its centre so wide kernels never self-shadow a lit surface, an N×N grid
|
||||
// jittered by an integer hash (bit-identical across backends) of the sub-texel
|
||||
// cell and tap index, and bilinear compares of the four nearest texel centres.
|
||||
// lattice and tap index, blended across the receiver's lattice cell, and bilinear
|
||||
// compares of the four nearest texel centers.
|
||||
// extra.y is the wanted penumbra in output pixels; dividing by the receiver's
|
||||
// texel footprint keeps it in output pixels at any map resolution, and the reach
|
||||
// is at least two texels so hard edges ramp over texel steps instead of showing them.
|
||||
@@ -268,16 +288,17 @@ fn shadow_factor(m: u32, p: vec3<f32>, ng: vec3<f32>, n_l: f32) -> f32 {
|
||||
let n = clamp(i32(ceil(reach)) + 1, 5, PCF_MAX_TAPS);
|
||||
let step = reach / f32(n - 1);
|
||||
let half = f32(n - 1) * 0.5;
|
||||
let cx = i32(floor(u * 8.0));
|
||||
let cy = i32(floor(v * 8.0));
|
||||
let gu = u * 8.0;
|
||||
let gv = v * 8.0;
|
||||
let cx = i32(floor(gu));
|
||||
let cy = i32(floor(gv));
|
||||
let wc = vec2<f32>(gu - floor(gu), gv - floor(gv));
|
||||
var lit = 0.0;
|
||||
for (var j = 0; j < n; j = j + 1) {
|
||||
for (var i = 0; i < n; i = i + 1) {
|
||||
let h = tap_hash(cx + i * 7919, cy + j * 7919);
|
||||
let jx = f32(h & 0xFFFFu) * (1.0 / 65536.0) - 0.5;
|
||||
let jy = f32((h >> 16u) & 0xFFFFu) * (1.0 / 65536.0) - 0.5;
|
||||
let su = u + ((f32(i) - half) + jx) * step;
|
||||
let sv = v + ((f32(j) - half) + jy) * step;
|
||||
let jit = tap_jitter(cx, cy, wc, i, j);
|
||||
let su = u + ((f32(i) - half) + jit.x) * step;
|
||||
let sv = v + ((f32(j) - half) + jit.y) * step;
|
||||
// Texel (x, y) holds the depth at its centre (x + 0.5, y + 0.5); each of the
|
||||
// four is compared against the receiver plane at that centre.
|
||||
let tu = su - 0.5;
|
||||
|
||||
@@ -199,6 +199,12 @@ fn attenuation(l: u32, p: vec3<f32>) -> f32 {
|
||||
return k;
|
||||
}
|
||||
|
||||
// compose::shadow_taps: samples per side of the Shadow Diffusion kernel.
|
||||
fn shadow_taps(r_px: f32) -> u32 {
|
||||
let n = clamp(u32(ceil(2.0 * r_px / 1.5)), 3u, 15u);
|
||||
return n | 1u;
|
||||
}
|
||||
|
||||
// compose::occlusion: RGB transmittance of light `l` reaching `p` past caster `c`.
|
||||
fn occlusion(c: u32, p: vec3<f32>, l: u32) -> vec3<f32> {
|
||||
let pl = planes[c];
|
||||
@@ -243,9 +249,14 @@ fn occlusion(c: u32, p: vec3<f32>, l: u32) -> vec3<f32> {
|
||||
if (r * pl.tex.z < 0.5) {
|
||||
px = texel(c, hit.x, hit.y);
|
||||
} else {
|
||||
for (var j = -1; j <= 1; j++) {
|
||||
for (var i = -1; i <= 1; i++) {
|
||||
px += texel(c, hit.x + f32(i) * r * 0.66, hit.y + f32(j) * r * 0.66) / 9.0;
|
||||
// compose::shadow_taps: n × n box filter over the penumbra square.
|
||||
let n = shadow_taps(r * pl.tex.z);
|
||||
let step = 2.0 * r / f32(n);
|
||||
let h = i32(n / 2u);
|
||||
let w = 1.0 / f32(n * n);
|
||||
for (var j = -h; j <= h; j++) {
|
||||
for (var i = -h; i <= h; i++) {
|
||||
px += texel(c, hit.x + f32(i) * step, hit.y + f32(j) * step) * w;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -255,16 +266,14 @@ fn occlusion(c: u32, p: vec3<f32>, l: u32) -> vec3<f32> {
|
||||
}
|
||||
let tr = pl.mat1.y;
|
||||
let dark = li.shadow.x;
|
||||
var o = vec3<f32>(1.0);
|
||||
for (var k = 0; k < 3; k++) {
|
||||
var st = 0.0;
|
||||
if (px.w > 0.0) {
|
||||
st = clamp(px[k] / px.w, 0.0, 1.0);
|
||||
}
|
||||
let through = (1.0 - alpha) + alpha * tr * st;
|
||||
o[k] = 1.0 - dark * (1.0 - through);
|
||||
// Per channel, as whole vectors: FXC (DirectX 12) can't write a vector component through a
|
||||
// loop index without unrolling every loop around the call, and fails (#613).
|
||||
var st = vec3<f32>(0.0);
|
||||
if (px.w > 0.0) {
|
||||
st = clamp(px.xyz / px.w, vec3<f32>(0.0), vec3<f32>(1.0));
|
||||
}
|
||||
return o;
|
||||
let through = (1.0 - alpha) + alpha * tr * st;
|
||||
return 1.0 - dark * (1.0 - through);
|
||||
}
|
||||
|
||||
fn shadow_of(it: u32, l: u32, p: vec3<f32>) -> vec3<f32> {
|
||||
|
||||
@@ -42,6 +42,15 @@ fn gen_sum(a: vec2<f32>, b: vec2<f32>) -> f32 {
|
||||
return (a.x + b.x) + (a.y + b.y);
|
||||
}
|
||||
|
||||
// Standard normal CDF (Abramowitz & Stegun 7.1.26 erf; effects::misc::norm_cdf).
|
||||
fn gen_norm_cdf(z: f32) -> f32 {
|
||||
let x = abs(z) * 0.70710678;
|
||||
let t = 1.0 / (1.0 + 0.3275911 * x);
|
||||
let poly = t * (0.254829592 + t * (-0.284496736 + t * (1.421413741 + t * (-1.453152027 + t * 1.061405429))));
|
||||
let erf = 1.0 - poly * exp(-x * x);
|
||||
return select(0.5 * (1.0 - erf), 0.5 * (1.0 + erf), z >= 0.0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Radial Blur (raster::radial_blur)
|
||||
|
||||
// u[0] = (zoom, samples); f[0] = (cx, cy); data = per sample (sin, cos) (spin) or k (zoom).
|
||||
@@ -67,7 +76,10 @@ fn gen_radial_blur(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let cs = data[2u * i + 1u];
|
||||
s = vec2<f32>(c.x + v.x * cs - v.y * sn, c.y + v.x * sn + v.y * cs);
|
||||
}
|
||||
acc += sample_bilinear(src, s.x, s.y);
|
||||
// Edge samples repeat (raster::radial_blur).
|
||||
let hi = vec2<f32>(f32(dims.x), f32(dims.y)) - vec2<f32>(0.5);
|
||||
let sc = clamp(s, vec2<f32>(0.5), max(hi, vec2<f32>(0.5)));
|
||||
acc += sample_bilinear(src, sc.x, sc.y);
|
||||
}
|
||||
textureStore(out, p, acc / f32(n));
|
||||
}
|
||||
@@ -235,9 +247,9 @@ fn gen_linear_wipe(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
textureStore(out, p, textureLoad(src, p, 0) * k);
|
||||
}
|
||||
|
||||
// Venetian Blinds. Per column (6 floats): (cx − done·w), (cx − w − done·w), (cx − w) as
|
||||
// (hi, lo), with cx = ((x + 0.5)·dx) mod w; per row (2 floats): ((y + 0.5)·dy) mod w.
|
||||
// u[0].x = row table offset; f[0].x = feather.
|
||||
// Venetian Blinds (effects::venetian_cover). Per column (4 floats): cx, (cx − w) as (hi, lo),
|
||||
// with cx = ((x + 0.5 − ox)·dx) mod w; per row (2 floats): ((y + 0.5 − oy)·dy) mod w.
|
||||
// u[0] = (row table offset, Gaussian terms per side, 0 = hard edge); f[0] = (w, band, σ).
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn gen_venetian(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let dims = out_dims();
|
||||
@@ -245,34 +257,33 @@ fn gen_venetian(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if (p.x >= dims.x || p.y >= dims.y) {
|
||||
return;
|
||||
}
|
||||
let col = 6u * u32(p.x);
|
||||
let col = 4u * u32(p.x);
|
||||
let row = gen_hl(P.u[0].x + 2u * u32(p.y));
|
||||
// (d mod w) wraps once the column and row terms reach w.
|
||||
let wrapped = gen_sum(gen_hl(col + 4u), row) >= 0.0;
|
||||
var t: f32;
|
||||
if (wrapped) {
|
||||
t = gen_sum(gen_hl(col + 2u), row);
|
||||
} else {
|
||||
t = gen_sum(gen_hl(col), row);
|
||||
var d = gen_sum(gen_hl(col), row);
|
||||
if (gen_sum(gen_hl(col + 2u), row) >= 0.0) {
|
||||
d = gen_sum(gen_hl(col + 2u), row);
|
||||
}
|
||||
let k = clamp(t / P.f[0].x + 0.5, 0.0, 1.0);
|
||||
textureStore(out, p, textureLoad(src, p, 0) * k);
|
||||
let w = P.f[0].x;
|
||||
let band = P.f[0].y;
|
||||
let sigma = P.f[0].z;
|
||||
let n = i32(P.u[0].y);
|
||||
var t = 0.0;
|
||||
if (n == 0) {
|
||||
t = select(0.0, 1.0, d < band);
|
||||
} else {
|
||||
for (var k = -n; k <= n; k++) {
|
||||
let u = d + f32(k) * w;
|
||||
t += gen_norm_cdf(u / sigma) - gen_norm_cdf((u - band) / sigma);
|
||||
}
|
||||
}
|
||||
let cover = clamp(t, 0.0, 1.0);
|
||||
textureStore(out, p, textureLoad(src, p, 0) * (1.0 - cover));
|
||||
}
|
||||
|
||||
// Angle (degrees, clockwise from up) of the pixel vector rotated by rotation `r` (0 = start,
|
||||
// 1 = start + edge, 2 = start − edge), from the per-column / per-row tables of Radial Wipe.
|
||||
fn gen_psi(r: u32, x: u32, y: u32) -> f32 {
|
||||
let c = 9u * x + 3u * r;
|
||||
let w = P.u[0].x + 9u * y + 3u * r;
|
||||
// x' = ux·cos R − uy·sin R (cancels at the edge), y' = uy·cos R + ux·sin R.
|
||||
let xr = (data[c] - data[w]) + (data[c + 1u] - data[w + 1u]);
|
||||
let yr = data[w + 2u] + data[c + 2u];
|
||||
return atan2(xr, yr) * GEN_DEG;
|
||||
}
|
||||
|
||||
// Radial Wipe. data: per column 9 floats (for each rotation: ux·cos R (hi, lo), ux·sin R),
|
||||
// per row 9 floats (uy·sin R (hi, lo), uy·cos R); u[0] = (row offset, wipe);
|
||||
// f[0] = (edge, feather).
|
||||
// Radial Wipe (effects::misc::radial_wipe_alpha). u[0] = (fully wiped, hard edge);
|
||||
// f[0] = (centre x, centre y, σ, first ray in degrees); f[1] = (sin, cos) of both rays;
|
||||
// f[2].x = span in degrees.
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn gen_radial_wipe(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let dims = out_dims();
|
||||
@@ -280,30 +291,31 @@ fn gen_radial_wipe(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if (p.x >= dims.x || p.y >= dims.y) {
|
||||
return;
|
||||
}
|
||||
let x = u32(p.x);
|
||||
let y = u32(p.y);
|
||||
let dir = P.u[0].y;
|
||||
let edge = P.f[0].x;
|
||||
var a = gen_psi(0u, x, y);
|
||||
if (a < 0.0) {
|
||||
a += 360.0;
|
||||
}
|
||||
var base: f32;
|
||||
if (dir == 0u || (dir == 2u && a <= 180.0)) {
|
||||
base = a - edge;
|
||||
if (abs(base) < 90.0) {
|
||||
base = gen_psi(1u, x, y);
|
||||
}
|
||||
} else {
|
||||
base = (360.0 - a) - edge;
|
||||
if (abs(base) < 90.0) {
|
||||
base = -gen_psi(2u, x, y);
|
||||
var k = 0.0;
|
||||
if (P.u[0].x == 0u) {
|
||||
let ux = f32(p.x) + 0.5 - P.f[0].x;
|
||||
let uy = f32(p.y) + 0.5 - P.f[0].y;
|
||||
var a = atan2(ux, -uy) * GEN_DEG - P.f[0].w;
|
||||
a = a - 360.0 * floor(a / 360.0);
|
||||
let wiped = a < P.f[2].x;
|
||||
let d = min(gen_ray_dist(ux, uy, P.f[1].x, P.f[1].y), gen_ray_dist(ux, uy, P.f[1].z, P.f[1].w));
|
||||
if (P.u[0].y != 0u) {
|
||||
k = select(1.0, 0.0, wiped);
|
||||
} else {
|
||||
k = gen_norm_cdf(select(d, -d, wiped) / P.f[0].z);
|
||||
}
|
||||
}
|
||||
let k = clamp(base / P.f[0].y + 0.5, 0.0, 1.0);
|
||||
textureStore(out, p, textureLoad(src, p, 0) * k);
|
||||
}
|
||||
|
||||
// Distance from (ux, uy) to the ray from the centre in direction (s, −c) (y down).
|
||||
fn gen_ray_dist(ux: f32, uy: f32, s: f32, c: f32) -> f32 {
|
||||
if (ux * s - uy * c > 0.0) {
|
||||
return abs(ux * c + uy * s);
|
||||
}
|
||||
return length(vec2<f32>(ux, uy));
|
||||
}
|
||||
|
||||
// Gradient Wipe: aux = the placed gradient layer. u[0].x = invert; f[0] = (done, softness).
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn gen_gradient_wipe(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
|
||||
@@ -647,7 +647,9 @@ fn fp2_hextile(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
// stylize3::hex_cell (cube rounding).
|
||||
let q = (0.5773502691896257 * u - v / 3.0) / r;
|
||||
let rr = (2.0 / 3.0 * v) / r;
|
||||
let ccy = -q - rr;
|
||||
// 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);
|
||||
|
||||
@@ -922,18 +922,28 @@ fn fxs_glow_map(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let k = clamp((l - thr) / max(1.0 - thr, 1e-3), 0.0, 1.0);
|
||||
let t = glow_ab_t(l);
|
||||
let c = vec3<f32>(curve_eval(P.f[1].x, t), curve_eval(P.f[1].y, t), curve_eval(P.f[1].z, t));
|
||||
textureStore(out, p, vec4<f32>(c * k * a, k * a));
|
||||
// Based on the colour channels the glow covers the layer's whole alpha (effects::misc::glow).
|
||||
var ga = a;
|
||||
if (P.u[0].y != 0u) {
|
||||
ga = k * a;
|
||||
}
|
||||
textureStore(out, p, vec4<f32>(c * k * a, ga));
|
||||
}
|
||||
|
||||
// On Top with another Glow Operation: blend_pixel(mode, original, glow · intensity, 0).
|
||||
// aux = the blurred glow; u[0].x = blend mode id; f[0].x = intensity
|
||||
// aux = the blurred glow; u[0].x = blend mode id; u[0].y = based on alpha (intensity also
|
||||
// scales the glow's alpha); f[0].x = intensity
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn fxs_glow_op(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!fxs_inside(p)) {
|
||||
return;
|
||||
}
|
||||
let g = textureLoad(aux, p, 0) * P.f[0].x;
|
||||
let gs = textureLoad(aux, p, 0);
|
||||
var g = vec4<f32>(gs.xyz * P.f[0].x, gs.w);
|
||||
if (P.u[0].y != 0u) {
|
||||
g.w = gs.w * P.f[0].x;
|
||||
}
|
||||
var o = blend_pixel(P.u[0].x, textureLoad(src, p, 0), g, 0.0);
|
||||
o.w = clamp(o.w, 0.0, 1.0);
|
||||
textureStore(out, p, o);
|
||||
|
||||
@@ -380,19 +380,31 @@ fn glow_bright(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
l = a;
|
||||
}
|
||||
let k = clamp((l - thr) / max(1.0 - thr, 1e-3), 0.0, 1.0);
|
||||
// Based on the colour channels the glow covers the layer's whole alpha (effects::misc::glow).
|
||||
var ga = a;
|
||||
if (P.u[0].y != 0u) {
|
||||
ga = k * a;
|
||||
}
|
||||
if (P.u[0].x != 0u) {
|
||||
let t = glow_ab_t(l);
|
||||
let ca = P.f[1].xyz;
|
||||
let cb = P.f[2].xyz;
|
||||
let c = ca + (cb - ca) * t;
|
||||
textureStore(out, p, vec4<f32>(c * k * a, k * a));
|
||||
textureStore(out, p, vec4<f32>(c * k * a, ga));
|
||||
} else if (P.u[0].y != 0u) {
|
||||
textureStore(out, p, vec4<f32>(px.xyz * k, ga));
|
||||
} else {
|
||||
textureStore(out, p, px * k);
|
||||
// Original colours, per channel (effects::misc::glow_bright_channel): gain 8.0 is
|
||||
// effects::GLOW_GAIN, 0.0611 is effects::GLOW_KNEE (checked by the test
|
||||
// glow_constants_match_the_cpu).
|
||||
let c = max(8.0 * (px.xyz / a - vec3<f32>(thr - 0.0611)), vec3<f32>(0.0));
|
||||
textureStore(out, p, vec4<f32>(c * a, ga));
|
||||
}
|
||||
}
|
||||
|
||||
// Glow composite: original `src` with the blurred glow `aux`. u[0].x = 0 on top, 1 behind,
|
||||
// 2 none (glow only); f[0].x = intensity.
|
||||
// 2 none (glow only); u[0].y = based on alpha (intensity also scales the glow's alpha);
|
||||
// f[0].x = intensity.
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn glow_combine(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let dims = out_dims();
|
||||
@@ -401,7 +413,11 @@ fn glow_combine(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
return;
|
||||
}
|
||||
var o = textureLoad(src, p, 0);
|
||||
let g = textureLoad(aux, p, 0) * P.f[0].x;
|
||||
let gs = textureLoad(aux, p, 0);
|
||||
var g = vec4<f32>(gs.xyz * P.f[0].x, gs.w);
|
||||
if (P.u[0].y != 0u) {
|
||||
g.w = gs.w * P.f[0].x;
|
||||
}
|
||||
if (P.u[0].x == 2u) {
|
||||
o = g;
|
||||
} else if (P.u[0].x == 1u) {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Finished frame → 8-bit BT.709 limited-range 4:2:0 (Y, U, V planes packed 4 bytes per word),
|
||||
// composited over the comp background: the GPU twin of `Image::to_rgba8_over` followed by
|
||||
// export's `rgba_to_yuv420`, operation for operation (same order of float operations, the same
|
||||
// rounding: `as u8` truncation of `x * 255 + 0.5`, `f32::round` half away from zero).
|
||||
|
||||
struct YP {
|
||||
// width, height, chroma width, Y words
|
||||
size: vec4<u32>,
|
||||
bg: vec4<f32>,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var<uniform> Y: YP;
|
||||
@group(0) @binding(1) var frame: texture_2d<f32>;
|
||||
@group(0) @binding(2) var<storage, read_write> outw: array<u32>;
|
||||
|
||||
// NaN by its bits: drivers may assume arithmetic never sees NaN.
|
||||
fn is_nan(x: f32) -> bool {
|
||||
return (bitcast<u32>(x) & 0x7fffffffu) > 0x7f800000u;
|
||||
}
|
||||
|
||||
// `(c.clamp(0, 1) * 255 + 0.5) as u8`; the CPU's `as u8` turns NaN into 0.
|
||||
fn q8(c: f32, nan: bool) -> f32 {
|
||||
if (nan) {
|
||||
return 0.0;
|
||||
}
|
||||
return floor(clamp(c, 0.0, 1.0) * 255.0 + 0.5);
|
||||
}
|
||||
|
||||
// f32::round for x >= 0 (half away from zero).
|
||||
fn round_pos(x: f32) -> f32 {
|
||||
let f = floor(x);
|
||||
return select(f, f + 1.0, x - f >= 0.5);
|
||||
}
|
||||
|
||||
fn px(x: u32, y: u32) -> vec3<f32> {
|
||||
let p = textureLoad(frame, vec2<i32>(i32(x), i32(y)), 0);
|
||||
// A NaN alpha makes every channel NaN on the CPU (k is NaN).
|
||||
let na = is_nan(p.w);
|
||||
let k = select(1.0 - clamp(p.w, 0.0, 1.0), 0.0, na);
|
||||
let c = vec3<f32>(q8(p.x + Y.bg.x * k, na || is_nan(p.x)), q8(p.y + Y.bg.y * k, na || is_nan(p.y)), q8(p.z + Y.bg.z * k, na || is_nan(p.z)));
|
||||
return c / 255.0;
|
||||
}
|
||||
|
||||
fn luma(c: vec3<f32>) -> f32 {
|
||||
return 0.2126 * c.x + 0.7152 * c.y + 0.0722 * c.z;
|
||||
}
|
||||
|
||||
fn y8(l: f32) -> u32 {
|
||||
return u32(clamp(round_pos(16.0 + 219.0 * l), 1.0, 254.0));
|
||||
}
|
||||
|
||||
// One invocation: 4 chroma samples = 8 × 2 pixels.
|
||||
@compute @workgroup_size(8, 8)
|
||||
fn yuv420(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let w = Y.size.x;
|
||||
let h = Y.size.y;
|
||||
let cw = Y.size.z;
|
||||
if (gid.x * 4u >= cw || gid.y * 2u >= h) {
|
||||
return;
|
||||
}
|
||||
let x0 = gid.x * 8u;
|
||||
let cy = gid.y;
|
||||
var uw = 0u;
|
||||
var vw = 0u;
|
||||
var yw = array<u32, 4>(0u, 0u, 0u, 0u);
|
||||
for (var q = 0u; q < 4u; q++) {
|
||||
// Accumulate in the CPU's order: row 0 left, right; row 1 left, right.
|
||||
var a0 = 0.0;
|
||||
var a1 = 0.0;
|
||||
for (var dy = 0u; dy < 2u; dy++) {
|
||||
for (var dx = 0u; dx < 2u; dx++) {
|
||||
let x = x0 + q * 2u + dx;
|
||||
let c = px(x, cy * 2u + dy);
|
||||
let l = luma(c);
|
||||
let byte = q * 2u + dx;
|
||||
yw[dy * 2u + byte / 4u] |= y8(l) << (8u * (byte % 4u));
|
||||
a0 += (c.z - l) / 1.8556;
|
||||
a1 += (c.x - l) / 1.5748;
|
||||
}
|
||||
}
|
||||
let u = u32(clamp(round_pos(128.0 + 224.0 * a0 / 4.0), 1.0, 254.0));
|
||||
let v = u32(clamp(round_pos(128.0 + 224.0 * a1 / 4.0), 1.0, 254.0));
|
||||
uw |= u << (8u * q);
|
||||
vw |= v << (8u * q);
|
||||
}
|
||||
let r0 = (cy * 2u * w + x0) / 4u;
|
||||
let r1 = ((cy * 2u + 1u) * w + x0) / 4u;
|
||||
outw[r0] = yw[0];
|
||||
outw[r0 + 1u] = yw[1];
|
||||
outw[r1] = yw[2];
|
||||
outw[r1 + 1u] = yw[3];
|
||||
let cwords = cw / 4u * (h / 2u);
|
||||
let ci = (cy * cw) / 4u + gid.x;
|
||||
outw[Y.size.w + ci] = uw;
|
||||
outw[Y.size.w + cwords + ci] = vw;
|
||||
}
|
||||
@@ -958,6 +958,16 @@ fn effects_whose_kernels_failed_render_on_the_cpu() {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn glow_constants_match_the_cpu() {
|
||||
use effectcraft_effects::{GLOW_GAIN, GLOW_KNEE};
|
||||
let kernels = include_str!("shaders/kernels.wgsl");
|
||||
let want = format!("max({GLOW_GAIN:?} * (px.xyz / a - vec3<f32>(thr - {GLOW_KNEE:?})), vec3<f32>(0.0))");
|
||||
assert!(kernels.contains(&want), "Glow's bright pass in kernels.wgsl is not `{want}`");
|
||||
}
|
||||
|
||||
/// The GPU Tint gives After Effects 2026's greys for full-frame solids at its defaults (sRGB
|
||||
/// working space, not linearised): Rec. 601 weights, as the CPU (`color_fx::luma_601`).
|
||||
#[test]
|
||||
|
||||
@@ -148,23 +148,28 @@ fn opengl_adapters_get_cpu_compositing_at_once() {
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(1), "{:?}", t.elapsed());
|
||||
}
|
||||
|
||||
/// #613: FXC (DirectX 12's default shader compiler) builds Classic 3D, so the compositor starts
|
||||
/// on DirectX 12 with its 3D kernel, and initialization leaves the caller's error scope and the
|
||||
/// host's error handler untouched. Ignored by default: FXC compiles every kernel, which takes
|
||||
/// minutes. Skips where there is no DirectX 12 adapter.
|
||||
#[test]
|
||||
#[cfg(target_os = "windows")]
|
||||
#[ignore = "requires the reproduced Windows DX12/FXC pointwise compiler failure; no adapter skips"]
|
||||
fn reproduced_fxc_pointwise_failure_returns_error_without_escaping_or_notifying_host() {
|
||||
#[ignore = "slow: FXC compiles every kernel"]
|
||||
fn fxc_builds_classic3d_and_initialization_errors_stay_scoped() {
|
||||
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
|
||||
desc.backends = wgpu::Backends::DX12;
|
||||
desc.backend_options.dx12.shader_compiler = wgpu::Dx12Compiler::Fxc;
|
||||
let (adapter, device, queue) = native_device(desc, true).expect("required DX12 device");
|
||||
let Some((adapter, device, queue)) = native_device(desc, false) else { return };
|
||||
assert_eq!(adapter.get_info().backend, wgpu::Backend::Dx12);
|
||||
let host = count_host_errors(&device);
|
||||
let outer = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| GpuContext::new(&adapter, device.clone(), queue)));
|
||||
let error = match result.expect("FXC compiler failure must not escape the constructor") {
|
||||
Err(error) => error,
|
||||
Ok(_) => panic!("the platform-specific FXC failure no longer reproduces; reassess this diagnostic"),
|
||||
let ctx = match result.expect("an FXC compiler failure must not escape the constructor") {
|
||||
Ok(ctx) => ctx,
|
||||
Err(error) => panic!("the compositor starts on DirectX 12 with FXC: {}", brief(error)),
|
||||
};
|
||||
assert!(error.contains("GPU initialization (pointwise)") && error.contains("FXC D3DCompile"), "{}", brief(error));
|
||||
let missing: Vec<&str> = ctx.unavailable_kernels().iter().map(|(e, _)| *e).collect();
|
||||
assert!(ctx.has_kernel("classic3d"), "FXC builds classic3d (unavailable: {missing:?})");
|
||||
assert!(pollster::block_on(outer.pop()).is_none(), "constructor must drain its own scopes and leave the caller's scope clean");
|
||||
assert_eq!(host.load(Ordering::SeqCst), 0);
|
||||
valid_pipeline(&device);
|
||||
|
||||
@@ -11,7 +11,7 @@ use effectcraft_project::{BitDepth, Comp, ItemKind, Layer, LayerSource, LightKin
|
||||
use effectcraft_render::{Backend, RenderOpts, Renderer};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
use crate::tests::{Pattern, Scene, check, compare_at, gpu, n, opts, set};
|
||||
use crate::tests::{Pattern, Scene, check, compare_at, diff, gpu, n, opts, set, tolerance};
|
||||
|
||||
const W: u32 = 160;
|
||||
const H: u32 = 100;
|
||||
@@ -133,6 +133,22 @@ fn lights_specular_transmission_and_shadows() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_shadow_diffusion() {
|
||||
// #2444: a wide penumbra takes more than the 3 × 3 kernel's samples; both sides pick the
|
||||
// same count.
|
||||
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
|
||||
let mut s = crossing(depth, BlendMode::Normal);
|
||||
for l in &mut s.p.comp_mut(s.cid).unwrap().layers {
|
||||
if l.switches.three_d {
|
||||
set(l, "materialOptions/castsShadows", Value::Enum(1));
|
||||
}
|
||||
}
|
||||
light(&mut s, LightKind::Point, [150.0, 20.0, -80.0], &[("castsShadows", Value::Bool(true)), ("shadowDiffusion", n(60.0))]);
|
||||
check(&format!("wide shadow diffusion {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth_of_field_and_motion_blur() {
|
||||
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
|
||||
@@ -209,6 +225,10 @@ fn the_gpu_draws_classic_3d_runs_itself() {
|
||||
assert_eq!(prep.planes.iter().filter(|p| p.draw).count(), 3);
|
||||
let mut e = crate::context::Enc::new(g.context());
|
||||
let canvas = e.image(W, H);
|
||||
if !g.context().has_kernel("classic3d") {
|
||||
// FXC (DirectX 12) can't build the kernel (#613); the CPU fallback is tested below.
|
||||
return;
|
||||
}
|
||||
let out = crate::classic3d::draw_run(&mut e, &prep, &canvas).expect("the kernel draws the run");
|
||||
let img = e.download(&out).unwrap();
|
||||
let mut cpu = effectcraft_raster::Image::new(W, H);
|
||||
@@ -253,7 +273,9 @@ fn bokeh_depth_of_field_runs_on_the_gpu() {
|
||||
assert!(prep.planes.iter().filter(|p| p.draw).all(|p| p.dof.is_some()), "{label}: depth of field deferred to the GPU");
|
||||
let mut e = crate::context::Enc::new(g.context());
|
||||
let canvas = e.image(W, H);
|
||||
assert!(crate::classic3d::draw_run(&mut e, &prep, &canvas).is_some(), "{label}: drawn on the GPU");
|
||||
if g.context().has_kernel("classic3d") {
|
||||
assert!(crate::classic3d::draw_run(&mut e, &prep, &canvas).is_some(), "{label}: drawn on the GPU");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -288,3 +310,27 @@ fn two_d_layer_with_a_three_d_track_matte() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #613: FXC on DirectX 12 fails to build `classic3d` (it can't unroll its loops). The
|
||||
/// compositor still starts without it, lists it, and 3D runs render on the CPU, matching the
|
||||
/// CPU render.
|
||||
#[test]
|
||||
fn classic_3d_runs_render_on_the_cpu_without_the_kernel() {
|
||||
let Some(base) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let ctx = base.context().without_kernel("classic3d");
|
||||
assert!(!ctx.has_kernel("classic3d"));
|
||||
let g = crate::Gpu::from_context(ctx);
|
||||
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
|
||||
let mut s = crossing(depth, BlendMode::Normal);
|
||||
camera(&mut s, [30.0, 20.0, -260.0], None);
|
||||
let cpu = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Cpu, ..opts() }).comp_frame_cpu(s.cid, Tick::ZERO);
|
||||
let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() });
|
||||
r.accel = Some(&g);
|
||||
let img = g.render(&r, s.cid, Tick::ZERO).expect("the GPU renders the frame");
|
||||
let mut d = diff(&cpu, &img, tolerance(depth));
|
||||
d.quantized = depth != BitDepth::Bpc32;
|
||||
check(&format!("classic 3D without its kernel {depth:?}"), Some(d), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ use effectcraft_time::Tick;
|
||||
use crate::context::{Enc, GpuImage};
|
||||
use crate::ops;
|
||||
|
||||
/// The size [`render`] renders `comp_id` at, without rendering it.
|
||||
pub(crate) fn frame_size(r: &Renderer, comp_id: ItemId, t: Tick) -> Option<(u32, u32)> {
|
||||
let ctx = r.eval_ctx(comp_id, t)?;
|
||||
Some(effectcraft_render::frame_size(ctx.comp.width, ctx.comp.height, r.opts.scale))
|
||||
}
|
||||
|
||||
/// Render a comp frame into a GPU image (the canvas in the output space, as
|
||||
/// `Renderer::comp_frame_cpu` returns it). `None` when something cannot run here (the caller
|
||||
/// renders on the CPU instead).
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
//! Export frames converted on the device to the H.264 encoder's input (8-bit BT.709 limited
|
||||
//! range 4:2:0, see `shaders/yuv.wgsl`): a 4K frame reads back 12 MB of planes instead of
|
||||
//! 133 MB of RGBA f32, and the CPU skips `to_rgba8_over` + `rgba_to_yuv420`.
|
||||
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::context::{Enc, GpuImage};
|
||||
|
||||
pub(crate) struct YuvPipe {
|
||||
bgl: wgpu::BindGroupLayout,
|
||||
pipe: wgpu::ComputePipeline,
|
||||
}
|
||||
|
||||
impl YuvPipe {
|
||||
pub(crate) fn new(device: &wgpu::Device) -> Result<YuvPipe, String> {
|
||||
crate::context::init_resource(device, "yuv420", || {
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("yuv420"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/yuv.wgsl").into()),
|
||||
});
|
||||
let storage =
|
||||
wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: false }, has_dynamic_offset: false, min_binding_size: None };
|
||||
let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("yuv420"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None },
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry { binding: 2, visibility: wgpu::ShaderStages::COMPUTE, ty: storage, count: None },
|
||||
],
|
||||
});
|
||||
let layout =
|
||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("yuv420"), bind_group_layouts: &[Some(&bgl)], immediate_size: 0 });
|
||||
let pipe = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("yuv420"),
|
||||
layout: Some(&layout),
|
||||
module: &module,
|
||||
entry_point: Some("yuv420"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
YuvPipe { bgl, pipe }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame sizes the kernel handles: width a multiple of 8 (4 chroma samples per
|
||||
/// invocation), height even.
|
||||
pub(crate) fn supported(w: u32, h: u32) -> bool {
|
||||
w > 0 && h > 0 && w.is_multiple_of(8) && h.is_multiple_of(2)
|
||||
}
|
||||
|
||||
/// Bytes of the planes: Y `w×h`, then U and V `w/2 × h/2`.
|
||||
pub(crate) fn planes_len(w: u32, h: u32) -> u64 {
|
||||
w as u64 * h as u64 + 2 * ((w / 2) as u64 * (h / 2) as u64)
|
||||
}
|
||||
|
||||
/// Whether the [`planes_len`] storage and staging buffers for `w×h` fit within `limits`.
|
||||
pub(crate) fn fits_limits(w: u32, h: u32, limits: &wgpu::Limits) -> bool {
|
||||
let size = planes_len(w, h);
|
||||
size <= limits.max_storage_buffer_binding_size && size <= limits.max_buffer_size
|
||||
}
|
||||
|
||||
/// Record the conversion of `img` over `bg` into a new `MAP_READ` buffer of
|
||||
/// [`planes_len`] bytes and submit it. `None`: the size is not [`supported`], exceeds the
|
||||
/// device's buffer limits, or buffer allocation failed.
|
||||
pub(crate) fn record(e: &mut Enc, yp: &YuvPipe, img: &GpuImage, bg: [f32; 3]) -> Option<wgpu::Buffer> {
|
||||
let (w, h) = (img.width, img.height);
|
||||
let g = e.g;
|
||||
if !supported(w, h) || !fits_limits(w, h, &g.device.limits()) {
|
||||
return None;
|
||||
}
|
||||
let cw = w / 2;
|
||||
let size = planes_len(w, h);
|
||||
let mut params = [0u8; 32];
|
||||
// w * h fits in u32: both are at most the texture limit (16384).
|
||||
for (i, v) in [w, h, cw, w * h / 4].into_iter().enumerate() {
|
||||
params[i * 4..i * 4 + 4].copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
for (i, v) in [bg[0], bg[1], bg[2], 1.0].into_iter().enumerate() {
|
||||
params[16 + i * 4..16 + i * 4 + 4].copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
let (out, stg, ub) = match crate::context::init_resource(&g.device, "yuv420 buffers", || {
|
||||
let out = g.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("yuv420 out"),
|
||||
size,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let stg = g.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("yuv420 staging"),
|
||||
size,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let ub = g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("yuv420 params"),
|
||||
contents: ¶ms,
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
(out, stg, ub)
|
||||
}) {
|
||||
Ok(buffers) => buffers,
|
||||
Err(err) => {
|
||||
log::warn!(target: "effectcraft_gpu", "yuv420 buffers: {err}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let view = img.texture.create_view(&Default::default());
|
||||
let group = g.device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("yuv420"),
|
||||
layout: &yp.bgl,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry { binding: 0, resource: ub.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&view) },
|
||||
wgpu::BindGroupEntry { binding: 2, resource: out.as_entire_binding() },
|
||||
],
|
||||
});
|
||||
{
|
||||
let enc = e.encoder();
|
||||
{
|
||||
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("yuv420"), timestamp_writes: None });
|
||||
pass.set_pipeline(&yp.pipe);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.dispatch_workgroups((w / 8).div_ceil(8), (h / 2).div_ceil(8), 1);
|
||||
}
|
||||
enc.copy_buffer_to_buffer(&out, 0, &stg, 0, size);
|
||||
}
|
||||
e.submit();
|
||||
Some(stg)
|
||||
}
|
||||
|
||||
/// Convert `img` over `bg` and read the planes back, waiting for the device.
|
||||
pub(crate) fn convert(e: &mut Enc, yp: &YuvPipe, img: &GpuImage, bg: [f32; 3]) -> Option<Vec<u8>> {
|
||||
let stg = record(e, yp, img, bg)?;
|
||||
let n = planes_len(img.width, img.height) as usize;
|
||||
e.g.read_buffer(&stg, n, n, 1).map_err(|err| log::error!(target: "effectcraft_gpu", "yuv420 readback: {err}")).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use effectcraft_raster::Image;
|
||||
|
||||
/// The export's conversion (`to_rgba8_over` + `rgba_to_yuv420`), the reference the kernel
|
||||
/// is held to.
|
||||
pub(crate) fn cpu(img: &Image, bg: [f32; 3]) -> Vec<u8> {
|
||||
effectcraft_raster::yuv::yuv420_over(img, bg)
|
||||
}
|
||||
|
||||
/// Premultiplied noise with some out-of-range values, opaque pixels, exact 8-bit levels and
|
||||
/// a few NaN and infinite components (the CPU writes 0 for NaN).
|
||||
pub(crate) fn noise(w: u32, h: u32, seed: u32) -> Image {
|
||||
let mut img = Image::new(w, h);
|
||||
let mut s = seed | 1;
|
||||
let mut rnd = || {
|
||||
s ^= s << 13;
|
||||
s ^= s >> 17;
|
||||
s ^= s << 5;
|
||||
s as f32 / u32::MAX as f32
|
||||
};
|
||||
for (i, p) in img.data.iter_mut().enumerate() {
|
||||
let a = if i % 7 == 0 { 1.0 } else { rnd() };
|
||||
*p = if i % 97 == 3 {
|
||||
[f32::NAN, 0.5, f32::INFINITY, a]
|
||||
} else if i % 101 == 5 {
|
||||
[0.2, 0.3, 0.4, f32::NAN]
|
||||
} else if i % 103 == 7 {
|
||||
[f32::NEG_INFINITY, 0.1, 0.9, f32::INFINITY]
|
||||
} else if i % 5 == 0 {
|
||||
let q = (i % 256) as f32 / 255.0;
|
||||
[q, 1.0 - q, q * 0.5, 1.0]
|
||||
} else {
|
||||
[rnd() * a * 1.1 - 0.02, rnd() * a, rnd() * a, a]
|
||||
};
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
/// Check `got` against the CPU's `want`. Software adapters (llvmpipe, WARP) must match
|
||||
/// exactly. A hardware driver may contract multiply-adds or divide less exactly, which
|
||||
/// moves a value near a rounding boundary by one level (seen on an RTX 3090 under Vulkan):
|
||||
/// there every byte must be within one level and at most `budget` of them off (about 2–3×
|
||||
/// the rate measured on the 3090). A wrong formula, rounding mode or NaN rule fails either
|
||||
/// way on software adapters.
|
||||
pub(crate) fn assert_parity(g: &crate::Gpu, got: &[u8], want: &[u8], budget: f64, what: &str) {
|
||||
assert_eq!(got.len(), want.len(), "{what}: length");
|
||||
let diff: Vec<usize> = (0..got.len()).filter(|&i| got[i] != want[i]).collect();
|
||||
let max = diff.iter().map(|&i| (got[i] as i32 - want[i] as i32).abs()).max().unwrap_or(0);
|
||||
eprintln!("{what} on {}: {} of {} bytes differ (max {max})", g.ctx.name, diff.len(), got.len());
|
||||
if g.ctx.software {
|
||||
assert!(diff.is_empty(), "{what}: a software adapter must match exactly, first diffs at {:?}", &diff[..diff.len().min(10)]);
|
||||
return;
|
||||
}
|
||||
assert!(max <= 1, "{what}: a byte is {max} levels off, first diffs at {:?}", &diff[..diff.len().min(10)]);
|
||||
assert!(diff.len() as f64 <= budget * got.len() as f64, "{what}: {} of {} bytes off by one", diff.len(), got.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yuv420_matches_cpu() {
|
||||
let Some(g) = crate::tests::gpu() else { return };
|
||||
let yp = super::YuvPipe::new(&g.ctx.device).expect("yuv420 pipe");
|
||||
for (w, h, bg) in [(264u32, 150u32, [0.05, 0.1, 0.2]), (8, 2, [1.0, 1.0, 1.0]), (1928, 6, [0.0, 0.0, 0.0])] {
|
||||
let img = noise(w, h, w ^ (h << 16));
|
||||
let src = g.ctx.upload_image(&img).expect("upload");
|
||||
let got = super::convert(&mut crate::context::Enc::new(&g.ctx), &yp, &src, bg).expect("readback");
|
||||
assert_parity(g, &got, &cpu(&img, bg), 0.00005, &format!("noise {w}x{h}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_sizes_are_declined() {
|
||||
assert!(super::supported(8, 2));
|
||||
for (w, h) in [(0, 2), (8, 0), (12, 2), (8, 3), (7, 4)] {
|
||||
assert!(!super::supported(w, h), "{w}x{h}");
|
||||
}
|
||||
assert_eq!(super::planes_len(8, 2), 16 + 2 * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sizes_exceeding_buffer_limits_are_declined() {
|
||||
let def = wgpu::Limits::default();
|
||||
assert!(super::fits_limits(3840, 2160, &def));
|
||||
// 16384×16384 planes are 384 MiB, over default max_storage_buffer_binding_size (128 MiB)
|
||||
// and max_buffer_size (256 MiB).
|
||||
assert!(!super::fits_limits(16384, 16384, &def));
|
||||
let size = super::planes_len(64, 64);
|
||||
assert!(super::fits_limits(64, 64, &wgpu::Limits { max_storage_buffer_binding_size: size, max_buffer_size: size, ..def.clone() }));
|
||||
assert!(!super::fits_limits(64, 64, &wgpu::Limits { max_storage_buffer_binding_size: size - 1, max_buffer_size: size, ..def.clone() }));
|
||||
assert!(!super::fits_limits(64, 64, &wgpu::Limits { max_storage_buffer_binding_size: size, max_buffer_size: size - 1, ..def.clone() }));
|
||||
|
||||
let Some(_) = crate::tests::gpu() else { return };
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).expect("adapter");
|
||||
let required_limits = wgpu::Limits { max_storage_buffer_binding_size: 4096, max_buffer_size: 4096, ..def };
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&crate::context::device_descriptor(required_limits))).expect("limited device");
|
||||
let g = crate::Gpu::new(&adapter, device, queue).expect("limited Gpu");
|
||||
let yp = super::YuvPipe::new(&g.ctx.device).expect("yuv420 pipe");
|
||||
let src = g.ctx.upload_image(&noise(64, 64, 1)).expect("upload");
|
||||
assert!(super::convert(&mut crate::context::Enc::new(&g.ctx), &yp, &src, [0.0; 3]).is_none());
|
||||
assert!(g.ctx.check_health().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_sizes_are_not_converted() {
|
||||
let Some(g) = crate::tests::gpu() else { return };
|
||||
let yp = super::YuvPipe::new(&g.ctx.device).expect("yuv420 pipe");
|
||||
let src = g.ctx.upload_image(&noise(12, 4, 3)).expect("upload");
|
||||
assert!(super::convert(&mut crate::context::Enc::new(&g.ctx), &yp, &src, [0.0; 3]).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod accel_tests {
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{BitDepth, Comp, ItemKind};
|
||||
use effectcraft_render::{Accelerator, Backend, RenderOpts, Renderer};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
use crate::tests::{Pattern, Scene, opts};
|
||||
|
||||
fn scene(w: u32, h: u32) -> Scene {
|
||||
let mut s = Scene::new(BitDepth::Bpc8);
|
||||
let comp = Comp::new(w, h, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
s.cid = s.p.add_item("Comp yuv", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||||
s.comp = comp;
|
||||
let l = s.footage(w + 7, h + 3);
|
||||
s.push(l);
|
||||
let sol = s.solid([0.9, 0.2, 0.4], w / 2, h / 3);
|
||||
s.push(sol);
|
||||
s
|
||||
}
|
||||
|
||||
/// The accelerator's YUV frame is the export's CPU conversion of the very frame
|
||||
/// `comp_frame` returns.
|
||||
#[test]
|
||||
fn comp_frame_yuv420_is_the_cpu_conversion_of_comp_frame() {
|
||||
let Some(g) = crate::tests::gpu() else { return };
|
||||
let s = scene(96, 60);
|
||||
let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() });
|
||||
r.accel = Some(g);
|
||||
let bg = [0.2, 0.3, 0.4];
|
||||
let rgba = g.comp_frame(&r, s.cid, Tick::ZERO).expect("rgba frame");
|
||||
let yuv = g.comp_frame_yuv420(&r, s.cid, Tick::ZERO, bg).expect("yuv frame");
|
||||
super::tests::assert_parity(g, &yuv, &super::tests::cpu(&rgba, bg), 0.006, "comp frame");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_sized_comps_fall_back_to_the_cpu() {
|
||||
let Some(g) = crate::tests::gpu() else { return };
|
||||
let s = scene(97, 61);
|
||||
let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() });
|
||||
r.accel = Some(g);
|
||||
assert!(g.comp_frame(&r, s.cid, Tick::ZERO).is_some());
|
||||
assert!(g.comp_frame_yuv420(&r, s.cid, Tick::ZERO, [0.0; 3]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comp_frame_yuv420_falls_back_when_pipeline_init_failed() {
|
||||
let Some(_) = crate::tests::gpu() else { return };
|
||||
let g = crate::Gpu::try_headless().expect("headless Gpu");
|
||||
let _ = g.ctx.yuv.set(Err("simulated yuv420 init failure".into()));
|
||||
let s = scene(96, 60);
|
||||
let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() });
|
||||
r.accel = Some(&g);
|
||||
assert!(g.comp_frame_yuv420(&r, s.cid, Tick::ZERO, [0.0; 3]).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tie_tests {
|
||||
use effectcraft_raster::Image;
|
||||
|
||||
fn lum(r: u8, g: u8, b: u8) -> f32 {
|
||||
let (r, g, b) = (r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0);
|
||||
0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
}
|
||||
|
||||
fn px(p: [u8; 3]) -> [f32; 4] {
|
||||
[p[0] as f32 / 255.0, p[1] as f32 / 255.0, p[2] as f32 / 255.0, 1.0]
|
||||
}
|
||||
|
||||
/// Opaque pixels whose Y (or whose 2×2 block's U or V) lands exactly on .5 in the CPU's f32
|
||||
/// math: a kernel that rounds ties the other way moves most of them.
|
||||
#[test]
|
||||
fn rounding_ties_match_cpu() {
|
||||
let mut ties: Vec<[u8; 3]> = vec![];
|
||||
for r in 0..=255u8 {
|
||||
for g in 0..=255u8 {
|
||||
for b in 0..=255u8 {
|
||||
let x = 16.0 + 219.0 * lum(r, g, b);
|
||||
if x - x.floor() == 0.5 {
|
||||
ties.push([r, g, b]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(!ties.is_empty());
|
||||
let mut blocks: Vec<[[u8; 3]; 4]> = vec![];
|
||||
let mut s = 12345u32;
|
||||
let mut rnd = || {
|
||||
s ^= s << 13;
|
||||
s ^= s >> 17;
|
||||
s ^= s << 5;
|
||||
(s >> 24) as u8
|
||||
};
|
||||
// Bounded so the search stays quick in debug builds (a few hundred blocks).
|
||||
for _ in 0..8_000_000 {
|
||||
let blk = [[rnd(), rnd(), rnd()], [rnd(), rnd(), rnd()], [rnd(), rnd(), rnd()], [rnd(), rnd(), rnd()]];
|
||||
let (mut a0, mut a1) = (0f32, 0f32);
|
||||
for p in blk {
|
||||
let l = lum(p[0], p[1], p[2]);
|
||||
a0 += (p[2] as f32 / 255.0 - l) / 1.8556;
|
||||
a1 += (p[0] as f32 / 255.0 - l) / 1.5748;
|
||||
}
|
||||
let u = 128.0 + 224.0 * a0 / 4.0;
|
||||
let v = 128.0 + 224.0 * a1 / 4.0;
|
||||
if u - u.floor() == 0.5 || v - v.floor() == 0.5 {
|
||||
blocks.push(blk);
|
||||
}
|
||||
}
|
||||
eprintln!("Y tie pixels: {}, chroma tie blocks: {}", ties.len(), blocks.len());
|
||||
assert!(!blocks.is_empty());
|
||||
let Some(g) = crate::tests::gpu() else { return };
|
||||
let yp = super::YuvPipe::new(&g.ctx.device).expect("yuv420 pipe");
|
||||
let w = 1024usize;
|
||||
let h = (ties.len().div_ceil(w) + 1) & !1;
|
||||
let mut img = Image::new(w as u32, h as u32);
|
||||
for (i, p) in img.data.iter_mut().enumerate() {
|
||||
*p = px(ties[i % ties.len()]);
|
||||
}
|
||||
let src = g.ctx.upload_image(&img).expect("upload");
|
||||
let got = super::convert(&mut crate::context::Enc::new(&g.ctx), &yp, &src, [0.0; 3]).expect("readback");
|
||||
super::tests::assert_parity(g, &got, &super::tests::cpu(&img, [0.0; 3]), 0.02, "Y ties");
|
||||
let cw = 512usize;
|
||||
let rows = blocks.len().div_ceil(cw);
|
||||
let mut img = Image::new((cw * 2) as u32, (rows * 2) as u32);
|
||||
for cy in 0..rows {
|
||||
for cx in 0..cw {
|
||||
let b = blocks[(cy * cw + cx) % blocks.len()];
|
||||
for (k, p) in b.iter().enumerate() {
|
||||
let (x, y) = (cx * 2 + k % 2, cy * 2 + k / 2);
|
||||
img.data[y * cw * 2 + x] = px(*p);
|
||||
}
|
||||
}
|
||||
}
|
||||
let src = g.ctx.upload_image(&img).expect("upload");
|
||||
let got = super::convert(&mut crate::context::Enc::new(&g.ctx), &yp, &src, [0.0; 3]).expect("readback");
|
||||
super::tests::assert_parity(g, &got, &super::tests::cpu(&img, [0.0; 3]), 0.06, "chroma ties");
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,11 @@ pub fn import(
|
||||
if depth > MAX_DEPTH {
|
||||
return Err(Error::TooDeep(MAX_DEPTH));
|
||||
}
|
||||
// FilmCraft's parser expands internal entities without a node limit (#2129); Final Cut Pro
|
||||
// and FCPXML exports never declare any.
|
||||
if matches!(format, TimelineFormat::Fcp7Xml | TimelineFormat::Fcpxml) && bytes.windows(8).any(|w| w == b"<!ENTITY") {
|
||||
return Err(Error::XmlEntities);
|
||||
}
|
||||
let fc_opts = filmcraft_interchange::ImportOptions {
|
||||
base_dir: opts.base_dir.clone(),
|
||||
edl_frame_rate: opts.edl_frame_rate.map(|f| {
|
||||
|
||||
@@ -160,6 +160,8 @@ pub enum Error {
|
||||
NoComp(u64),
|
||||
#[error("the timeline document nests more than {0} levels deep")]
|
||||
TooDeep(usize),
|
||||
#[error("the timeline document declares XML entities, which timeline files don't use and which can expand without bound")]
|
||||
XmlEntities,
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
@@ -520,6 +520,21 @@ fn export_survives_a_precomp_layer_pointing_at_a_non_comp() {
|
||||
assert!(matches!(export(&p, footage, &ExportOptions::default(), &HashMap::new()), Err(Error::NoComp(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_timelines_declaring_entities_are_refused() {
|
||||
// #2129: the timeline parser expands internal entities without a node limit.
|
||||
let dtd = "<!DOCTYPE x [<!ENTITY a \"aaaaaaaaaa\"><!ENTITY b \"&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;\">]>";
|
||||
let docs = [
|
||||
(TimelineFormat::Fcp7Xml, format!("<?xml version=\"1.0\"?>{dtd}<xmeml version=\"5\">&b;</xmeml>")),
|
||||
(TimelineFormat::Fcpxml, format!("<?xml version=\"1.0\"?>{dtd}<fcpxml version=\"1.9\">&b;</fcpxml>")),
|
||||
];
|
||||
for (format, doc) in docs {
|
||||
let mut p = Project::default();
|
||||
let r = import(&mut p, doc.as_bytes(), Some(format), &ImportOptions::default(), &mut |_| None);
|
||||
assert!(matches!(r, Err(Error::XmlEntities)), "{format:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeply_nested_documents_error_instead_of_overflowing_the_stack() {
|
||||
// A few hundred kilobytes nested 100 000 levels deep overflowed the parsers' stack.
|
||||
|
||||
@@ -47,7 +47,9 @@ fn s<'a>(j: &'a Json, k: &str) -> Option<&'a str> {
|
||||
|
||||
fn hex_color(sc: &str) -> [f32; 3] {
|
||||
let h = sc.trim_start_matches('#');
|
||||
let c = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).unwrap_or(0) as f32 / 255.0;
|
||||
let c = |i: usize| {
|
||||
h.get(i..i + 2).filter(|x| x.bytes().all(|b| b.is_ascii_hexdigit())).and_then(|x| u8::from_str_radix(x, 16).ok()).unwrap_or(0) as f32 / 255.0
|
||||
};
|
||||
[c(0), c(2), c(4)]
|
||||
}
|
||||
|
||||
@@ -141,7 +143,9 @@ pub fn import(
|
||||
let Some(id) = s(a, "id") else { continue };
|
||||
if a.get("layers").is_some() {
|
||||
let folder = cx.folder(project);
|
||||
let (aw, ah) = (f(a, "w").map(|v| v as u32).unwrap_or(w), f(a, "h").map(|v| v as u32).unwrap_or(h));
|
||||
let layer = precomp_layer_size(&doc, &assets, id);
|
||||
let aw = f(a, "w").map(|v| v as u32).or(layer.map(|l| l.0)).unwrap_or(w);
|
||||
let ah = f(a, "h").map(|v| v as u32).or(layer.map(|l| l.1)).unwrap_or(h);
|
||||
let arate = f(a, "fr").map(FrameRate::from_f64).unwrap_or(rate);
|
||||
let c = Comp::new(aw, ah, arate, duration);
|
||||
let nm = s(a, "nm").unwrap_or(id).to_string();
|
||||
@@ -240,6 +244,19 @@ pub fn import(
|
||||
Ok(ImportResult { comp: id, items: cx.items, warnings: cx.im.warnings })
|
||||
}
|
||||
|
||||
/// The `w`/`h` of the first precomp layer, in the main comp or in any asset, that shows asset `id`.
|
||||
/// lottie-spec gives a precomposition asset no size: the layer's `w`/`h` are its clipping rect.
|
||||
fn precomp_layer_size(doc: &Json, assets: &[Json], id: &str) -> Option<(u32, u32)> {
|
||||
let lists = doc.get("layers").into_iter().chain(assets.iter().filter_map(|a| a.get("layers")));
|
||||
lists.filter_map(Json::as_array).flatten().find_map(|l| {
|
||||
if l.get("ty").and_then(Json::as_u64) != Some(0) || s(l, "refId") != Some(id) {
|
||||
return None;
|
||||
}
|
||||
let (lw, lh) = f(l, "w").zip(f(l, "h"))?;
|
||||
Some((lw.round().max(1.0) as u32, lh.round().max(1.0) as u32))
|
||||
})
|
||||
}
|
||||
|
||||
fn transform(im: &Im, ks: &Json, tr: &mut PropGroup, next: &mut u64) {
|
||||
let set = |tr: &mut PropGroup, m: &str, j: Option<&Json>, conv: &dyn Fn(&Json) -> Option<Value>| {
|
||||
if let (Some(j), Some(p)) = (j, tr.get_mut(m)) {
|
||||
@@ -399,7 +416,14 @@ fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[
|
||||
l.in_point = tb.tick(ip);
|
||||
l.out_point = tb.tick(op.max(ip));
|
||||
l.start_time = tb.tick(f(j, "st").unwrap_or(0.0));
|
||||
l.stretch = f(j, "sr").unwrap_or(1.0) * 100.0;
|
||||
// Below 1 % (or 0) the layer time divides by ~0, and layer.timeStretch refuses it too.
|
||||
let sr = f(j, "sr").unwrap_or(1.0);
|
||||
l.stretch = if sr.is_finite() && sr.abs() >= 0.01 {
|
||||
sr * 100.0
|
||||
} else {
|
||||
cx.im.warn(format!("{name}: a time stretch of {} % is not supported (100 % used)", sr * 100.0));
|
||||
100.0
|
||||
};
|
||||
if j.get("ddd").and_then(Json::as_u64) == Some(1) {
|
||||
l.switches.three_d = true;
|
||||
}
|
||||
@@ -485,3 +509,15 @@ fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hex_color_tests {
|
||||
use super::hex_color;
|
||||
|
||||
#[test]
|
||||
fn hex_color_rejects_plus_sign() {
|
||||
assert_eq!(hex_color("#+f0000"), [0.0, 0.0, 0.0]);
|
||||
assert_eq!(hex_color("#zz0000"), [0.0, 0.0, 0.0]);
|
||||
assert_eq!(hex_color("#ff0000"), [1.0, 0.0, 0.0]);
|
||||
}
|
||||
}
|
||||
|
||||
+27
-12
@@ -3,8 +3,8 @@
|
||||
|
||||
use effectcraft_color::BlendMode;
|
||||
use effectcraft_keyframe::{Gradient, ShapePath, Value};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{Node, PropGroup};
|
||||
use effectcraft_project::build::{self, EXTRA_DASHES, Ids};
|
||||
use effectcraft_project::{Node, ParamUi, PropGroup};
|
||||
use serde_json::{Map, Value as Json, json};
|
||||
|
||||
use crate::anim::{self, Ex, Im, export_prop, import_prop};
|
||||
@@ -356,7 +356,7 @@ fn import_blend(j: &Json, g: &mut PropGroup) {
|
||||
}
|
||||
}
|
||||
|
||||
fn import_stroke(im: &Im, j: &Json, g: &mut PropGroup) {
|
||||
fn import_stroke(im: &Im, ids: &mut Ids, j: &Json, g: &mut PropGroup) {
|
||||
set(im, g, "width", j.get("w"), &anim::to_scalar);
|
||||
if let Some(lc) = int(j, "lc") {
|
||||
set_enum(g, "cap", lc.saturating_sub(1).clamp(0, 2) as u32);
|
||||
@@ -374,17 +374,32 @@ fn import_stroke(im: &Im, j: &Json, g: &mut PropGroup) {
|
||||
if let Some(list) = j.get("d").and_then(Json::as_array)
|
||||
&& let Some(d) = g.sub_mut("dashes")
|
||||
{
|
||||
let mut seen_dash = false;
|
||||
// The n-th `d` and `g` are the n-th Dash/Gap pair; the extra pairs are created as the Dashes "+" does.
|
||||
let (mut dashes, mut gaps) = (0, 0);
|
||||
for e in list {
|
||||
let m = match e.get("n").and_then(Json::as_str) {
|
||||
Some("d") if !seen_dash => {
|
||||
seen_dash = true;
|
||||
"dash"
|
||||
let (n, pair) = match e.get("n").and_then(Json::as_str) {
|
||||
Some("d") => (&mut dashes, 0),
|
||||
Some("g") => (&mut gaps, 1),
|
||||
Some("o") => {
|
||||
set(im, d, "offset", e.get("v"), &anim::to_scalar);
|
||||
continue;
|
||||
}
|
||||
Some("g") => "gap",
|
||||
Some("o") => "offset",
|
||||
_ => continue,
|
||||
};
|
||||
let k = *n;
|
||||
*n += 1;
|
||||
let m = match k {
|
||||
0 => ["dash", "gap"][pair],
|
||||
_ => {
|
||||
let Some(&(dm, gm)) = EXTRA_DASHES.get(k - 1) else { continue };
|
||||
if d.get(dm).is_none() {
|
||||
let at = d.children.iter().position(|c| c.match_id() == "offset").unwrap_or(d.children.len());
|
||||
d.children.insert(at, ids.prop(gm, &format!("Gap {}", k + 1), Value::Scalar(0.0)).with_ui(ParamUi::Pixels).into());
|
||||
d.children.insert(at, ids.prop(dm, &format!("Dash {}", k + 1), Value::Scalar(0.0)).with_ui(ParamUi::Pixels).into());
|
||||
}
|
||||
[dm, gm][pair]
|
||||
}
|
||||
};
|
||||
set(im, d, m, e.get("v"), &anim::to_scalar);
|
||||
}
|
||||
}
|
||||
@@ -474,7 +489,7 @@ pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec<Pr
|
||||
let mut g = build::shape_stroke(ids, [1.0, 1.0, 1.0, 1.0], 2.0);
|
||||
set(im, &mut g, "color", j.get("c"), &anim::to_color);
|
||||
set(im, &mut g, "opacity", j.get("o"), &anim::to_scalar);
|
||||
import_stroke(im, j, &mut g);
|
||||
import_stroke(im, ids, j, &mut g);
|
||||
import_blend(j, &mut g);
|
||||
g
|
||||
}
|
||||
@@ -501,7 +516,7 @@ pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec<Pr
|
||||
set_enum(&mut g, "rule", 1);
|
||||
}
|
||||
} else {
|
||||
import_stroke(im, j, &mut g);
|
||||
import_stroke(im, ids, j, &mut g);
|
||||
}
|
||||
import_blend(j, &mut g);
|
||||
g
|
||||
|
||||
@@ -407,6 +407,30 @@ fn offset_paths_miter_uses_animatable_lottie_value() {
|
||||
assert_eq!(second.get("miter").unwrap().value, Value::Scalar(6.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_dash_gap_pairs_survive_a_roundtrip() {
|
||||
let (mut p, cid) = setup();
|
||||
let l = shape_layer(&mut p, cid, |ids| {
|
||||
let mut stroke = build::shape_stroke(ids, [0.0, 0.0, 1.0, 1.0], 3.0);
|
||||
let d = stroke.sub_mut("dashes").unwrap();
|
||||
d.get_mut("dash").unwrap().value = Value::Scalar(10.0);
|
||||
d.get_mut("gap").unwrap().value = Value::Scalar(5.0);
|
||||
let at = d.children.iter().position(|n| n.match_id() == "offset").unwrap();
|
||||
d.children.insert(at, ids.prop("gap2", "Gap 2", Value::Scalar(3.0)).into());
|
||||
d.children.insert(at, ids.prop("dash2", "Dash 2", Value::Scalar(20.0)).into());
|
||||
let rect = build::shape_rect(ids, [60.0, 40.0], [0.0, 0.0], 0.0);
|
||||
vec![build::shape_group(ids, "G", vec![rect, stroke])]
|
||||
});
|
||||
push(&mut p, cid, l);
|
||||
let (q, nc, _, _) = roundtrip(&p, cid, &ExportOptions::default());
|
||||
let c = q.comp(nc).unwrap();
|
||||
let g = c.layers[0].props.sub("contents").unwrap().groups().next().unwrap();
|
||||
let stroke = g.sub("contents").unwrap().groups().find(|g| g.match_id == "stroke").unwrap();
|
||||
let d = stroke.sub("dashes").unwrap();
|
||||
let v = |m: &str| d.get(m).map(|p| p.value.as_f64());
|
||||
assert_eq!((v("dash"), v("gap"), v("dash2"), v("gap2")), (Some(10.0), Some(5.0), Some(20.0), Some(3.0)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- layers
|
||||
|
||||
#[test]
|
||||
@@ -484,6 +508,18 @@ fn layers_parenting_timing_mattes_masks_and_blend_modes() {
|
||||
compare_renders(&p, cid, &q, nc, 2.0 / 255.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_time_stretch_is_not_imported() {
|
||||
let doc = json!({"w": 20, "h": 20, "fr": 30, "ip": 0, "op": 30, "assets": [],
|
||||
"layers": [{"ty": 1, "ind": 1, "sc": "#ff0000", "sw": 20, "sh": 20, "sr": 0, "ip": 0, "op": 30, "st": 0, "ks": {}}]});
|
||||
let mut q = Project::default();
|
||||
let imp = import(&mut q, doc.to_string().as_bytes(), "Imported", "", &mut |_| None).unwrap();
|
||||
let l = &q.comp(imp.comp).unwrap().layers[0];
|
||||
// layer.timeStretch refuses anything below 1 %, and keyframe commands refuse a layer with 0 %.
|
||||
assert_eq!(l.stretch, 100.0);
|
||||
assert!(imp.warnings.iter().any(|w| w.contains("time stretch")), "{:?}", imp.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precomp_with_time_remap() {
|
||||
let (mut p, cid) = setup();
|
||||
@@ -515,6 +551,19 @@ fn precomp_with_time_remap() {
|
||||
compare_renders(&p, cid, &q, nc, 2.0 / 255.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precomp_size_comes_from_the_layer_when_the_asset_has_none() {
|
||||
// lottie-spec: a precomposition asset has no `w`/`h`; the precomp layer carries them.
|
||||
let doc = json!({"w": 200, "h": 120, "fr": 30, "ip": 0, "op": 30,
|
||||
"assets": [{"id": "c", "layers": []}],
|
||||
"layers": [{"ty": 0, "ind": 1, "refId": "c", "w": 50, "h": 40, "ip": 0, "op": 30, "st": 0, "ks": {}}]});
|
||||
let mut q = Project::default();
|
||||
let nc = import(&mut q, doc.to_string().as_bytes(), "Imported", "", &mut |_| None).unwrap().comp;
|
||||
let LayerSource::Comp { item } = q.comp(nc).unwrap().layers[0].source else { panic!("not a precomp") };
|
||||
let pc = q.comp(item).unwrap();
|
||||
assert_eq!((pc.width, pc.height), (50, 40));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_layers_roundtrip_as_text_and_as_shapes() {
|
||||
let (mut p, cid) = setup();
|
||||
|
||||
@@ -87,6 +87,8 @@ pub fn uri_decode(s: &str) -> String {
|
||||
while i < b.len() {
|
||||
if b[i] == b'%'
|
||||
&& i + 2 < b.len()
|
||||
&& b[i + 1].is_ascii_hexdigit()
|
||||
&& b[i + 2].is_ascii_hexdigit()
|
||||
&& let Ok(v) = u8::from_str_radix(std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or("zz"), 16)
|
||||
{
|
||||
out.push(v);
|
||||
@@ -612,3 +614,15 @@ fn primitive(d: &Doc, p: &Value, materials: &[Material]) -> Result<Option<Primit
|
||||
}
|
||||
Ok(Some(prim))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::uri_decode;
|
||||
|
||||
#[test]
|
||||
fn uri_decode_rejects_signed_hex_pair() {
|
||||
assert_eq!(uri_decode("a%+1b"), "a%+1b");
|
||||
assert_eq!(uri_decode("a%-1b"), "a%-1b");
|
||||
assert_eq!(uri_decode("a%41b"), "aAb");
|
||||
}
|
||||
}
|
||||
|
||||
+80
-10
@@ -24,6 +24,10 @@ impl Cpx {
|
||||
fn add(self, o: Cpx) -> Cpx {
|
||||
Cpx { re: self.re + o.re, im: self.im + o.im }
|
||||
}
|
||||
#[inline]
|
||||
fn sub(self, o: Cpx) -> Cpx {
|
||||
Cpx { re: self.re - o.re, im: self.im - o.im }
|
||||
}
|
||||
}
|
||||
|
||||
/// Mixed-radix (2, 3, 4, 5) forward complex FFT, unscaled (decimation in time, recursive).
|
||||
@@ -71,18 +75,64 @@ impl Fft {
|
||||
self.rec(&mut out[q * m..(q + 1) * m], &inp[q * in_stride..], in_stride * p, m, fi + 1, tw_stride * p);
|
||||
}
|
||||
}
|
||||
let big = self.n;
|
||||
let mut t = [Cpx::default(); 5];
|
||||
for k in 0..m {
|
||||
for (q, tq) in t.iter_mut().enumerate().take(p) {
|
||||
*tq = out[q * m + k].mul(self.twiddle[(q * k * tw_stride) % big]);
|
||||
// The twiddle index q * k * tw_stride is always below n, so no modulo is needed.
|
||||
let tw = &self.twiddle;
|
||||
match p {
|
||||
2 => {
|
||||
for k in 0..m {
|
||||
let a0 = out[k];
|
||||
let a1 = out[m + k].mul(tw[k * tw_stride]);
|
||||
out[k] = a0.add(a1);
|
||||
out[m + k] = a0.sub(a1);
|
||||
}
|
||||
}
|
||||
for q2 in 0..p {
|
||||
let mut acc = Cpx::default();
|
||||
for (q, tq) in t.iter().enumerate().take(p) {
|
||||
acc = acc.add(tq.mul(self.twiddle[((q * q2) % p) * (big / p)]));
|
||||
4 => {
|
||||
for k in 0..m {
|
||||
let t0 = out[k];
|
||||
let t1 = out[m + k].mul(tw[k * tw_stride]);
|
||||
let t2 = out[2 * m + k].mul(tw[2 * k * tw_stride]);
|
||||
let t3 = out[3 * m + k].mul(tw[3 * k * tw_stride]);
|
||||
let (s0, d0) = (t0.add(t2), t0.sub(t2));
|
||||
let (s1, d1) = (t1.add(t3), t1.sub(t3));
|
||||
out[k] = s0.add(s1);
|
||||
out[2 * m + k] = s0.sub(s1);
|
||||
// The odd outputs combine d0 with -j * d1 = (d1.im, -d1.re) and its negative.
|
||||
out[m + k] = Cpx { re: d0.re + d1.im, im: d0.im - d1.re };
|
||||
out[3 * m + k] = Cpx { re: d0.re - d1.im, im: d0.im + d1.re };
|
||||
}
|
||||
}
|
||||
3 => {
|
||||
// Imaginary part of exp(-2 pi i / 3) = -sqrt(3) / 2.
|
||||
let h = tw[self.n / 3].im;
|
||||
for k in 0..m {
|
||||
let t0 = out[k];
|
||||
let t1 = out[m + k].mul(tw[k * tw_stride]);
|
||||
let t2 = out[2 * m + k].mul(tw[2 * k * tw_stride]);
|
||||
let (s, d) = (t1.add(t2), t1.sub(t2));
|
||||
let a = Cpx { re: t0.re - 0.5 * s.re, im: t0.im - 0.5 * s.im };
|
||||
out[k] = t0.add(s);
|
||||
// j * h * d = (-h * d.im, h * d.re)
|
||||
out[m + k] = Cpx { re: a.re - h * d.im, im: a.im + h * d.re };
|
||||
out[2 * m + k] = Cpx { re: a.re + h * d.im, im: a.im - h * d.re };
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Generic DFT butterfly (radix 5, once per transform).
|
||||
let roots: [Cpx; 5] = std::array::from_fn(|q| if q < p { tw[q * (self.n / p)] } else { Cpx::default() });
|
||||
let mut t = [Cpx::default(); 5];
|
||||
for k in 0..m {
|
||||
t[0] = out[k];
|
||||
for q in 1..p {
|
||||
t[q] = out[q * m + k].mul(tw[q * k * tw_stride]);
|
||||
}
|
||||
for q2 in 0..p {
|
||||
let mut acc = t[0];
|
||||
for q in 1..p {
|
||||
acc = acc.add(t[q].mul(roots[(q * q2) % p]));
|
||||
}
|
||||
out[q2 * m + k] = acc;
|
||||
}
|
||||
}
|
||||
out[q2 * m + k] = acc;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,6 +248,26 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fft_matches_dft() {
|
||||
// Every radix combination, including the 480-point size of the 20 ms frame.
|
||||
for &n in &[2usize, 3, 4, 5, 6, 8, 12, 15, 16, 20, 24, 30, 48, 60, 120, 240, 480] {
|
||||
let fft = Fft::new(n);
|
||||
let x: Vec<Cpx> = (0..n).map(|i| Cpx { re: ((i * 37 % 101) as f32 - 50.0) / 7.0, im: ((i * 53 % 89) as f32 - 44.0) / 9.0 }).collect();
|
||||
let mut y = x.clone();
|
||||
fft.process(&mut y, &mut Vec::new());
|
||||
for k in 0..n {
|
||||
let (mut re, mut im) = (0f64, 0f64);
|
||||
for (j, v) in x.iter().enumerate() {
|
||||
let ph = -2.0 * std::f64::consts::PI * ((j * k) % n) as f64 / n as f64;
|
||||
re += v.re as f64 * ph.cos() - v.im as f64 * ph.sin();
|
||||
im += v.re as f64 * ph.sin() + v.im as f64 * ph.cos();
|
||||
}
|
||||
assert!((y[k].re as f64 - re).abs() < 2e-3 && (y[k].im as f64 - im).abs() < 2e-3, "n={n} k={k}: ({}, {}) vs ({re}, {im})", y[k].re, y[k].im);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_direct_formula() {
|
||||
for &n in &[240usize, 960] {
|
||||
|
||||
@@ -45,6 +45,25 @@ fn design(fc: f64, beta: f64, delay: usize) -> Vec<f32> {
|
||||
h.iter().map(|&v| v as f32).collect()
|
||||
}
|
||||
|
||||
/// Dot product with eight independent partial sums, which the compiler can vectorise (a single
|
||||
/// running sum is bound by the latency of the float addition). The sums are combined pairwise.
|
||||
#[inline]
|
||||
fn dot8(a: &[f32], b: &[f32]) -> f32 {
|
||||
let mut s = [0f32; 8];
|
||||
let (ca, ra) = a.as_chunks::<8>();
|
||||
let (cb, rb) = b.as_chunks::<8>();
|
||||
for (x, y) in ca.iter().zip(cb) {
|
||||
for l in 0..8 {
|
||||
s[l] += x[l] * y[l];
|
||||
}
|
||||
}
|
||||
let mut acc = ((s[0] + s[4]) + (s[1] + s[5])) + ((s[2] + s[6]) + (s[3] + s[7]));
|
||||
for (x, y) in ra.iter().zip(rb) {
|
||||
acc += x * y;
|
||||
}
|
||||
acc
|
||||
}
|
||||
|
||||
/// Decimates one channel of 48 kHz audio by 3, 4 or 6.
|
||||
#[derive(Clone)]
|
||||
pub struct Downsampler {
|
||||
@@ -77,7 +96,7 @@ impl Downsampler {
|
||||
let t = taps - 1 + self.factor * j;
|
||||
let win = &self.buf[t + 1 - taps..=t];
|
||||
// h is symmetric, so the window can be taken in forward order.
|
||||
out.push(win.iter().zip(&self.h).map(|(a, b)| a * b).sum());
|
||||
out.push(dot8(win, &self.h));
|
||||
}
|
||||
let n = self.buf.len();
|
||||
self.buf.copy_within(n - (taps - 1).., 0);
|
||||
@@ -97,6 +116,16 @@ mod tests {
|
||||
(x.iter().map(|&v| (v as f64).powi(2)).sum::<f64>() / x.len() as f64).sqrt()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot8_matches_sequential_sum() {
|
||||
for len in [0usize, 1, 7, 8, 9, 63, 167, 251] {
|
||||
let a: Vec<f32> = (0..len).map(|i| ((i * 37 % 101) as f32 - 50.0) / 50.0).collect();
|
||||
let b: Vec<f32> = (0..len).map(|i| ((i * 53 % 89) as f32 - 44.0) / 44.0).collect();
|
||||
let exact: f64 = a.iter().zip(&b).map(|(&x, &y)| x as f64 * y as f64).sum();
|
||||
assert!((dot8(&a, &b) as f64 - exact).abs() < 1e-4, "len {len}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passes_band_and_rejects_aliases() {
|
||||
for &(fs, pass, stop) in &[(16_000u32, 6_000.0, 8_600.0), (12_000, 4_500.0, 6_500.0), (8_000, 3_000.0, 4_500.0)] {
|
||||
|
||||
+35
-10
@@ -533,12 +533,15 @@ pub struct WiggleParams {
|
||||
pub correlation: f64,
|
||||
/// Temporal phase in degrees (360° = one wiggle period).
|
||||
pub phase_deg: f64,
|
||||
/// Spatial phase in degrees: slides each subpath's pattern of displacements along its points
|
||||
/// (360° = once around the subpath).
|
||||
pub spatial_phase_deg: f64,
|
||||
pub seed: f64,
|
||||
}
|
||||
|
||||
impl Default for WiggleParams {
|
||||
fn default() -> Self {
|
||||
WiggleParams { size: 10.0, detail: 10.0, smooth: true, speed: 2.0, correlation: 50.0, phase_deg: 0.0, seed: 0.0 }
|
||||
WiggleParams { size: 10.0, detail: 10.0, smooth: true, speed: 2.0, correlation: 50.0, phase_deg: 0.0, spatial_phase_deg: 0.0, seed: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -568,9 +571,16 @@ fn noise(seed: u64, idx: u64, t: f64) -> f64 {
|
||||
v.clamp(-1.0, 1.0)
|
||||
}
|
||||
|
||||
/// Catmull-Rom interpolation between `p1` (f = 0) and `p2` (f = 1).
|
||||
fn catmull_rom(p0: f64, p1: f64, p2: f64, p3: f64, f: f64) -> f64 {
|
||||
0.5 * (2.0 * p1 + (-p0 + p2) * f + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * f * f + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * f * f * f)
|
||||
}
|
||||
|
||||
/// Wiggle Paths: subdivides the paths (`detail` points per segment) and displaces every point by
|
||||
/// smooth random noise of up to `size` pixels that evolves over `time_s` seconds at `speed`
|
||||
/// wiggles per second. Deterministic for a given seed and time.
|
||||
/// wiggles per second. The spatial phase moves each point's own share of the noise along the
|
||||
/// subpath, and a fraction of a point is interpolated between its neighbors. Deterministic for a
|
||||
/// given seed and time.
|
||||
pub fn wiggle(paths: &[BezPath], w: &WiggleParams, time_s: f64) -> Vec<BezPath> {
|
||||
if w.size == 0.0 {
|
||||
return paths.to_vec();
|
||||
@@ -580,6 +590,7 @@ pub fn wiggle(paths: &[BezPath], w: &WiggleParams, time_s: f64) -> Vec<BezPath>
|
||||
let t = time_s * w.speed + w.phase_deg / 360.0;
|
||||
let corr = (w.correlation / 100.0).clamp(0.0, 1.0);
|
||||
let shared = Vec2::new(noise(seed, u64::MAX, t), noise(seed, u64::MAX - 1, t));
|
||||
let turns = if w.spatial_phase_deg.is_finite() { (w.spatial_phase_deg / 360.0).rem_euclid(1.0) } else { 0.0 };
|
||||
let mut idx: u64 = 0;
|
||||
paths
|
||||
.iter()
|
||||
@@ -587,14 +598,28 @@ pub fn wiggle(paths: &[BezPath], w: &WiggleParams, time_s: f64) -> Vec<BezPath>
|
||||
let mut out = BezPath::new();
|
||||
for sub in subs(p) {
|
||||
let s = resample(&sub, m);
|
||||
let disp: Vec<Vec2> = s
|
||||
.iter()
|
||||
.map(|_| {
|
||||
let own = Vec2::new(noise(seed, idx * 2, t + idx as f64 * 0.37), noise(seed, idx * 2 + 1, t + idx as f64 * 0.61));
|
||||
idx += 1;
|
||||
(shared * corr + own * (1.0 - corr)) * w.size
|
||||
})
|
||||
.collect();
|
||||
let n = s.len();
|
||||
let first = idx;
|
||||
let own = |j: usize| {
|
||||
let i = first + j as u64;
|
||||
Vec2::new(noise(seed, i * 2, t + i as f64 * 0.37), noise(seed, i * 2 + 1, t + i as f64 * 0.61))
|
||||
};
|
||||
let along = |j: usize| {
|
||||
if turns == 0.0 {
|
||||
return own(j);
|
||||
}
|
||||
let u = (j as f64 + turns * n as f64).rem_euclid(n as f64);
|
||||
let k = u.floor();
|
||||
let f = u - k;
|
||||
let k = k as usize % n;
|
||||
if f == 0.0 {
|
||||
return own(k);
|
||||
}
|
||||
let (p0, p1, p2, p3) = (own((k + n - 1) % n), own(k), own((k + 1) % n), own((k + 2) % n));
|
||||
Vec2::new(catmull_rom(p0.x, p1.x, p2.x, p3.x, f).clamp(-1.0, 1.0), catmull_rom(p0.y, p1.y, p2.y, p3.y, f).clamp(-1.0, 1.0))
|
||||
};
|
||||
let disp: Vec<Vec2> = (0..n).map(|j| (shared * corr + along(j) * (1.0 - corr)) * w.size).collect();
|
||||
idx += n as u64;
|
||||
emit(&s, &disp, sub.closed, w.smooth, 1.0 / 3.0, &mut out);
|
||||
}
|
||||
out
|
||||
|
||||
@@ -317,6 +317,38 @@ fn wiggle_full_correlation_translates() {
|
||||
assert_eq!(zero[0], s[0]);
|
||||
}
|
||||
|
||||
/// Spatial Phase moves the pattern of displacements along the path: 360° is once around, and a
|
||||
/// whole number of points' worth gives each point the displacement of the point that far ahead.
|
||||
#[test]
|
||||
fn wiggle_spatial_phase_moves_the_pattern_along_the_path() {
|
||||
let s = [sq(0.0, 0.0, 100.0)];
|
||||
let w = WiggleParams { detail: 3.0, correlation: 0.0, smooth: false, ..Default::default() };
|
||||
// Displacements are proportional to Size: twice the size less once the size.
|
||||
let disp = |w: WiggleParams| -> Vec<Vec2> {
|
||||
let a = on_curve(&wiggle(&s, &w, 0.4)[0]);
|
||||
let b = on_curve(&wiggle(&s, &WiggleParams { size: w.size * 2.0, ..w }, 0.4)[0]);
|
||||
a.iter().zip(&b).map(|(a, b)| *b - *a).take(16).collect()
|
||||
};
|
||||
let d0 = disp(w);
|
||||
let n = d0.len();
|
||||
assert_eq!(n, 16);
|
||||
for k in [1, 5] {
|
||||
let dk = disp(WiggleParams { spatial_phase_deg: 360.0 * k as f64 / n as f64, ..w });
|
||||
for j in 0..n {
|
||||
let e = d0[(j + k) % n];
|
||||
assert!(close(dk[j].x, e.x, 1e-9) && close(dk[j].y, e.y, 1e-9), "{k} ahead, point {j}: {:?} vs {e:?}", dk[j]);
|
||||
}
|
||||
}
|
||||
// Whole turns either way leave the path as it is.
|
||||
let base = wiggle(&s, &w, 0.4);
|
||||
assert_eq!(wiggle(&s, &WiggleParams { spatial_phase_deg: 360.0, ..w }, 0.4), base);
|
||||
assert_eq!(wiggle(&s, &WiggleParams { spatial_phase_deg: -720.0, ..w }, 0.4), base);
|
||||
// Half a point ahead, the displacements change and stay within Size.
|
||||
let half = disp(WiggleParams { spatial_phase_deg: 180.0 / n as f64, ..w });
|
||||
assert!(half.iter().zip(&d0).any(|(h, d)| (*h - *d).hypot() > 1e-3));
|
||||
assert!(half.iter().all(|h| h.x.abs() <= 10.0 + 1e-9 && h.y.abs() <= 10.0 + 1e-9));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- merge / booleans
|
||||
|
||||
fn inclusion_exclusion(a: BezPath, b: BezPath) {
|
||||
|
||||
@@ -4,7 +4,7 @@ use effectcraft_color::{BlendMode, Label};
|
||||
use effectcraft_keyframe::{Gradient, ShapePath, TextDoc, Value};
|
||||
use effectcraft_time::Tick;
|
||||
|
||||
use crate::props::{GroupKind, MaskMode, ParamUi, PropGroup, Property};
|
||||
use crate::props::{GroupKind, MaskMode, Node, ParamUi, PropGroup, Property};
|
||||
use crate::{Comp, Layer, LayerId, LayerSource, LightKind, Project, Switches};
|
||||
|
||||
/// Id allocator borrowing the project's counter.
|
||||
@@ -594,6 +594,7 @@ pub fn shape_simple_op(ids: &mut Ids, kind: &str) -> Option<PropGroup> {
|
||||
.with(ids.prop("speed", "Wiggles/Second", Value::Scalar(2.0)))
|
||||
.with(ids.prop("correlation", "Correlation", Value::Scalar(50.0)).with_ui(ParamUi::Percent))
|
||||
.with(ids.prop("phase", "Temporal Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle))
|
||||
.with(wiggle_spatial_phase(ids))
|
||||
.with(ids.prop("seed", "Random Seed", Value::Scalar(0.0))),
|
||||
"merge" => indexed(ids.group("merge", "Merge Paths 1")).with(ids.prop("mode", "Mode", Value::Enum(0)).with_ui(popup(&[
|
||||
"Merge",
|
||||
@@ -607,6 +608,32 @@ pub fn shape_simple_op(ids: &mut Ids, kind: &str) -> Option<PropGroup> {
|
||||
Some(g)
|
||||
}
|
||||
|
||||
/// Wiggle Paths' Spatial Phase.
|
||||
fn wiggle_spatial_phase(ids: &mut Ids) -> Property {
|
||||
ids.prop("spatialPhase", "Spatial Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle)
|
||||
}
|
||||
|
||||
/// Adds the properties that shape path operations have gained since a project was saved (Wiggle
|
||||
/// Paths' Spatial Phase), at their defaults, to the operations in `contents` and its nested
|
||||
/// groups. Returns whether anything was added. It walks the nested groups with a work list and
|
||||
/// does not recurse.
|
||||
pub fn upgrade_shape_contents(ids: &mut Ids, contents: &mut PropGroup) -> bool {
|
||||
let mut changed = false;
|
||||
let mut todo = vec![contents];
|
||||
while let Some(group) = todo.pop() {
|
||||
for n in &mut group.children {
|
||||
let Node::Group(g) = n else { continue };
|
||||
if g.match_id == "wiggle" && !g.children.iter().any(|c| c.match_id() == "spatialPhase") {
|
||||
let at = g.children.iter().position(|c| c.match_id() == "phase").map_or(g.children.len(), |i| i + 1);
|
||||
g.children.insert(at, wiggle_spatial_phase(ids).into());
|
||||
changed = true;
|
||||
}
|
||||
todo.push(g);
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- camera / light / 3D
|
||||
|
||||
pub fn camera_options(ids: &mut Ids, zoom: f64) -> PropGroup {
|
||||
|
||||
@@ -290,3 +290,35 @@ fn comp_contains_ends_on_cycles_long_chains_and_shared_precomps() {
|
||||
assert!(p.comp_contains(c[0], c[n - 1]));
|
||||
assert!(!p.comp_contains(c[n - 1], c[0]));
|
||||
}
|
||||
|
||||
/// A Wiggle Paths 100 000 groups deep gets Spatial Phase on a 2 MiB stack.
|
||||
#[test]
|
||||
fn upgrading_deeply_nested_shape_groups_fits_a_small_stack() {
|
||||
let n = 100_000;
|
||||
let mut next = 1;
|
||||
let mut contents = build::shape_simple_op(&mut Ids(&mut next), "wiggle").unwrap();
|
||||
contents.children.retain(|c| c.match_id() != "spatialPhase");
|
||||
for _ in 0..n {
|
||||
let uid = Ids(&mut next).alloc();
|
||||
contents = crate::PropGroup::new(uid, "group", "Group 1").with(contents);
|
||||
}
|
||||
let h = std::thread::Builder::new()
|
||||
.stack_size(2 << 20)
|
||||
.spawn(move || {
|
||||
let added = build::upgrade_shape_contents(&mut Ids(&mut next), &mut contents);
|
||||
(added, contents)
|
||||
})
|
||||
.unwrap();
|
||||
let (added, mut g) = h.join().unwrap();
|
||||
assert!(added);
|
||||
// One level at a time: dropping the whole tree would recurse as deep as it is nested.
|
||||
let mut depth = 0;
|
||||
while g.match_id == "group" {
|
||||
let Some(crate::Node::Group(child)) = g.children.pop() else { panic!("a nested group") };
|
||||
g = child;
|
||||
depth += 1;
|
||||
}
|
||||
assert_eq!(depth, n);
|
||||
let ids: Vec<&str> = g.children.iter().map(|c| c.match_id()).collect();
|
||||
assert_eq!(ids, ["size", "detail", "points", "speed", "correlation", "phase", "spatialPhase", "seed"]);
|
||||
}
|
||||
|
||||
@@ -247,7 +247,9 @@ pub fn radial_blur(img: &Image, center: (f64, f64), amount: f64, zoom: bool) ->
|
||||
let (s, c) = (t * amount).to_radians().sin_cos();
|
||||
(center.0 + vx * c - vy * s, center.1 + vx * s + vy * c)
|
||||
};
|
||||
let p = img.sample_bilinear(sx, sy);
|
||||
// Samples outside the image repeat its edge: After Effects' Radial Blur keeps a
|
||||
// layer's edges (cases 713, 829) instead of fading them toward transparency.
|
||||
let p = img.sample_bilinear(sx.clamp(0.5, (img.width as f64 - 0.5).max(0.5)), sy.clamp(0.5, (img.height as f64 - 0.5).max(0.5)));
|
||||
for ch in 0..4 {
|
||||
acc[ch] += p[ch];
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod flow;
|
||||
pub mod inpaint;
|
||||
pub mod scopes;
|
||||
pub mod warp;
|
||||
pub mod yuv;
|
||||
|
||||
use effectcraft_color::{BlendMode, blend_pixel};
|
||||
use rayon::prelude::*;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//! 8-bit BT.709 limited-range 4:2:0, the H.264 export's input. Shared by the export and by the
|
||||
//! GPU compositor's tests, which hold its kernel to this function.
|
||||
|
||||
use rayon::prelude::*;
|
||||
|
||||
/// `v / 255` for every 8-bit `v` (the same values as dividing).
|
||||
pub const UNIT8: [f32; 256] = {
|
||||
let mut t = [0.0; 256];
|
||||
let mut i = 0;
|
||||
while i < 256 {
|
||||
t[i] = i as f32 / 255.0;
|
||||
i += 1;
|
||||
}
|
||||
t
|
||||
};
|
||||
|
||||
/// `v.round()` for `0 <= v < 2^23`, the range the colour conversions round in: truncation and
|
||||
/// the fraction are exact there, so this is `f32::round` without its libm call.
|
||||
#[inline(always)]
|
||||
pub fn round_pos(v: f32) -> f32 {
|
||||
let t = v as u32 as f32;
|
||||
if v - t >= 0.5 { t + 1.0 } else { t }
|
||||
}
|
||||
|
||||
/// BT.709 limited-range 8-bit 4:2:0 from RGBA8 (2×2 chroma average; alpha ignored).
|
||||
pub fn rgba_to_yuv420(rgba: &[u8], w: usize, h: usize, y: &mut Vec<u8>, u: &mut Vec<u8>, v: &mut Vec<u8>) {
|
||||
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
|
||||
y.resize(w * h, 0);
|
||||
u.resize(cw * ch, 0);
|
||||
v.resize(cw * ch, 0);
|
||||
y.par_chunks_mut(w * 2).zip(u.par_chunks_mut(cw).zip(v.par_chunks_mut(cw))).enumerate().for_each(|(cy, (yr, (ur, vr)))| {
|
||||
let mut acc = vec![(0f32, 0f32, 0f32); cw];
|
||||
for (dy, yrow) in yr.chunks_exact_mut(w).enumerate() {
|
||||
let src = &rgba[(cy * 2 + dy) * w * 4..][..w * 4];
|
||||
for (x, (s, yo)) in src.as_chunks::<4>().0.iter().zip(yrow.iter_mut()).enumerate() {
|
||||
let (r, g, b) = (UNIT8[s[0] as usize], UNIT8[s[1] as usize], UNIT8[s[2] as usize]);
|
||||
let l = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
*yo = round_pos(16.0 + 219.0 * l).clamp(1.0, 254.0) as u8;
|
||||
let a = &mut acc[x / 2];
|
||||
a.0 += (b - l) / 1.8556;
|
||||
a.1 += (r - l) / 1.5748;
|
||||
a.2 += 1.0;
|
||||
}
|
||||
}
|
||||
for (cx, a) in acc.iter().enumerate() {
|
||||
ur[cx] = round_pos(128.0 + 224.0 * a.0 / a.2).clamp(1.0, 254.0) as u8;
|
||||
vr[cx] = round_pos(128.0 + 224.0 * a.1 / a.2).clamp(1.0, 254.0) as u8;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The H.264 export's planes of `img` over `bg` (opaque): Y, then U and V, as
|
||||
/// [`crate::Image::to_rgba8_over`] + [`rgba_to_yuv420`] produce them.
|
||||
pub fn yuv420_over(img: &crate::Image, bg: [f32; 3]) -> Vec<u8> {
|
||||
let (mut y, mut u, mut v) = (vec![], vec![], vec![]);
|
||||
rgba_to_yuv420(&img.to_rgba8_over(bg), img.width as usize, img.height as usize, &mut y, &mut u, &mut v);
|
||||
y.extend_from_slice(&u);
|
||||
y.extend_from_slice(&v);
|
||||
y
|
||||
}
|
||||
@@ -46,6 +46,14 @@ pub trait Accelerator: Send + Sync {
|
||||
/// Render a top-level comp frame (what [`Renderer::comp_frame`] returns). `None` = not
|
||||
/// handled (the CPU renders it).
|
||||
fn comp_frame(&self, r: &Renderer, comp: ItemId, t: Tick) -> Option<Image>;
|
||||
/// Render a top-level comp frame like [`Self::comp_frame`], composite it over `bg` and
|
||||
/// convert it to the H.264 export's 8-bit BT.709 limited-range 4:2:0 (Y plane, then U and V
|
||||
/// at half size; see `effectcraft_raster::yuv`). Every byte is within one level of the CPU
|
||||
/// conversion of the same frame (a driver's arithmetic may move a value near a rounding
|
||||
/// boundary). `None` = not handled (the caller renders and converts on the CPU).
|
||||
fn comp_frame_yuv420(&self, _r: &Renderer, _comp: ItemId, _t: Tick, _bg: [f32; 3]) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
/// The accelerator implements effect `id` with the CPU effect's semantics.
|
||||
fn supports_effect(&self, id: &str) -> bool;
|
||||
/// Run a chain of supported effects on `buf`, clamping and quantising to `levels` after
|
||||
|
||||
+183
-21
@@ -12,9 +12,11 @@
|
||||
//! `FrameSeg::to_bytes` (keyed by their content-keyed chain key and scale, so they are
|
||||
//! reused whenever the same frames, settings and prompt come back, also after a restart). Every file ends in a checksum; damaged files read as misses and
|
||||
//! are deleted.
|
||||
//! * Size limit with least-recently-used eviction. The in-memory index is rebuilt from the
|
||||
//! folder when the cache opens (file modification times order it), so the cache survives
|
||||
//! restarts; reads refresh a file's time.
|
||||
//! * Size limit with least-recently-used eviction, down to 90% of the limit so the eviction scan
|
||||
//! runs once per tenth of the limit written, not on every write. The limit shrinks to keep
|
||||
//! free space on the cache's disk ([`DiskCache::set_disk_space`]). The in-memory index is
|
||||
//! rebuilt from the folder when the cache opens (file modification times order it), so the
|
||||
//! cache survives restarts; reads refresh a file's time.
|
||||
//! * Safe concurrent access: writes go to a unique temporary file that is renamed into place
|
||||
//! (atomic on every desktop OS), so readers in this or another process never see a partial
|
||||
//! entry; a missing or damaged file is just a miss. Writes run on a background thread with a
|
||||
@@ -22,7 +24,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
|
||||
use effectcraft_effects::Buf;
|
||||
@@ -75,11 +77,15 @@ pub struct DiskStats {
|
||||
pub entries: usize,
|
||||
pub bytes: u64,
|
||||
pub max_bytes: u64,
|
||||
/// The limit in effect: `max_bytes`, or less when the disk is short of free space.
|
||||
pub limit_bytes: u64,
|
||||
pub hits: u64,
|
||||
pub misses: u64,
|
||||
pub writes: u64,
|
||||
pub evictions: u64,
|
||||
pub dropped: u64,
|
||||
/// Writes that failed (a full disk, permissions).
|
||||
pub failed: u64,
|
||||
}
|
||||
|
||||
struct Meta {
|
||||
@@ -157,15 +163,17 @@ impl DiskIndex {
|
||||
v.into_iter().map(|e| e.1).collect()
|
||||
}
|
||||
|
||||
/// Remove least recently used entries until the total fits `max` bytes; returns them (the
|
||||
/// caller deletes their files).
|
||||
/// When the total exceeds `max` bytes, remove least recently used entries down to 90% of
|
||||
/// it (so the sort runs once per tenth of `max` written, not on every write); returns them
|
||||
/// (the caller deletes their files).
|
||||
pub fn evict(&mut self, max: u64) -> Vec<(Kind, u128)> {
|
||||
if self.total <= max {
|
||||
return vec![];
|
||||
}
|
||||
let target = max - max / 10;
|
||||
let mut out = vec![];
|
||||
for k in self.lru_order() {
|
||||
if self.total <= max {
|
||||
if self.total <= target {
|
||||
break;
|
||||
}
|
||||
self.remove(k.0, k.1);
|
||||
@@ -225,11 +233,16 @@ pub struct DiskCache {
|
||||
dir: PathBuf,
|
||||
index: Mutex<DiskIndex>,
|
||||
max_bytes: AtomicU64,
|
||||
/// The size free disk space allows (`u64::MAX` = unknown), see [`DiskCache::set_disk_space`].
|
||||
disk_cap: AtomicU64,
|
||||
/// Free space is limiting the cache (warned once per episode).
|
||||
space_limited: AtomicBool,
|
||||
hits: AtomicU64,
|
||||
misses: AtomicU64,
|
||||
writes: AtomicU64,
|
||||
evictions: AtomicU64,
|
||||
dropped: AtomicU64,
|
||||
failed: AtomicU64,
|
||||
tmp_counter: AtomicU64,
|
||||
queue: Arc<(Mutex<Queue>, Condvar)>,
|
||||
/// Layer buffers that took less than this to render are not written (ms).
|
||||
@@ -298,16 +311,21 @@ impl DiskCache {
|
||||
dir,
|
||||
index: Mutex::new(DiskIndex::default()),
|
||||
max_bytes: AtomicU64::new(max_bytes),
|
||||
disk_cap: AtomicU64::new(u64::MAX),
|
||||
space_limited: AtomicBool::new(false),
|
||||
hits: AtomicU64::new(0),
|
||||
misses: AtomicU64::new(0),
|
||||
writes: AtomicU64::new(0),
|
||||
evictions: AtomicU64::new(0),
|
||||
dropped: AtomicU64::new(0),
|
||||
failed: AtomicU64::new(0),
|
||||
tmp_counter: AtomicU64::new(0),
|
||||
queue: Arc::new((Mutex::new(Queue { jobs: Default::default(), busy: false }), Condvar::new())),
|
||||
queue: Arc::new((Mutex::new(Queue { jobs: Default::default(), busy: cfg!(not(target_arch = "wasm32")) }), Condvar::new())),
|
||||
min_layer_ms: AtomicU64::new(30),
|
||||
});
|
||||
dc.rescan();
|
||||
// Shrinking a large cache deletes many files: the writer thread does it.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
dc.evict();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
@@ -371,6 +389,32 @@ impl DiskCache {
|
||||
self.evict();
|
||||
}
|
||||
|
||||
/// The limit in effect: the setting, or less when the disk is short of free space.
|
||||
pub fn limit_bytes(&self) -> u64 {
|
||||
self.max_bytes().min(self.disk_cap.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// The cache's disk: `(total, available)` bytes, `None` = unknown. The cache leaves at least
|
||||
/// 2 GB or 5% of the disk free, whichever is more (evicting when it must).
|
||||
pub fn set_disk_space(&self, space: Option<(u64, u64)>) {
|
||||
let cap = match space {
|
||||
// (a disk reported as 0 bytes, such as an automount map, is unknown)
|
||||
Some((total, available)) if total > 0 => {
|
||||
let reserve = (2u64 << 30).max(total / 20);
|
||||
let used = self.index.lock().map(|g| g.total()).unwrap_or_default();
|
||||
// The cache's own files count as free: below the reserve it gives space back.
|
||||
used.saturating_add(available).saturating_sub(reserve)
|
||||
}
|
||||
_ => u64::MAX,
|
||||
};
|
||||
self.disk_cap.store(cap, Ordering::Relaxed);
|
||||
let limited = cap < self.max_bytes();
|
||||
if self.space_limited.swap(limited, Ordering::Relaxed) != limited && limited {
|
||||
log::warn!("disk cache at {}: little free disk space, limiting the cache to {} MB", self.folder().display(), cap >> 20);
|
||||
}
|
||||
self.evict();
|
||||
}
|
||||
|
||||
/// Layer buffers faster to render than this are not worth a disk write.
|
||||
pub fn set_min_layer_ms(&self, ms: u64) {
|
||||
self.min_layer_ms.store(ms, Ordering::Relaxed);
|
||||
@@ -395,11 +439,13 @@ impl DiskCache {
|
||||
entries,
|
||||
bytes,
|
||||
max_bytes: self.max_bytes(),
|
||||
limit_bytes: self.limit_bytes(),
|
||||
hits: self.hits.load(Ordering::Relaxed),
|
||||
misses: self.misses.load(Ordering::Relaxed),
|
||||
writes: self.writes.load(Ordering::Relaxed),
|
||||
evictions: self.evictions.load(Ordering::Relaxed),
|
||||
dropped: self.dropped.load(Ordering::Relaxed),
|
||||
failed: self.failed.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,30 +540,44 @@ impl DiskCache {
|
||||
}
|
||||
let path = self.path(kind, key);
|
||||
let Some(parent) = path.parent() else { return };
|
||||
if std::fs::create_dir_all(parent).is_err() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
self.write_failed(&e);
|
||||
return;
|
||||
}
|
||||
let data = seal(kind, payload);
|
||||
let n = self.tmp_counter.fetch_add(1, Ordering::Relaxed);
|
||||
let tmp = parent.join(format!("{key:032x}.tmp.{}.{n}", std::process::id()));
|
||||
if std::fs::write(&tmp, &data).is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
// An entry that cannot fit would be evicted straight away.
|
||||
if data.len() as u64 > self.limit_bytes() {
|
||||
return;
|
||||
}
|
||||
if std::fs::rename(&tmp, &path).is_err() {
|
||||
// Make room first, so the cache never exceeds its limit (or the free-space reserve).
|
||||
self.evict_to(self.limit_bytes().saturating_sub(data.len() as u64));
|
||||
let n = self.tmp_counter.fetch_add(1, Ordering::Relaxed);
|
||||
let tmp = parent.join(format!("{key:032x}.tmp.{}.{n}", std::process::id()));
|
||||
if let Err(e) = std::fs::write(&tmp, &data).and_then(|()| std::fs::rename(&tmp, &path)) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
self.write_failed(&e);
|
||||
return;
|
||||
}
|
||||
self.writes.fetch_add(1, Ordering::Relaxed);
|
||||
if let Ok(mut g) = self.index.lock() {
|
||||
g.insert(kind, key, data.len() as u64);
|
||||
}
|
||||
self.evict();
|
||||
}
|
||||
|
||||
/// Count a failed write; the first one is logged (a full disk shouldn't go unnoticed).
|
||||
fn write_failed(&self, e: &std::io::Error) {
|
||||
if self.failed.fetch_add(1, Ordering::Relaxed) == 0 {
|
||||
log::warn!("disk cache at {}: a write failed: {e}", self.folder().display());
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove least recently used entries until the cache fits its limit.
|
||||
fn evict(&self) {
|
||||
let max = self.max_bytes();
|
||||
self.evict_to(self.limit_bytes());
|
||||
}
|
||||
|
||||
/// Remove least recently used entries until the cache fits `max` bytes.
|
||||
fn evict_to(&self, max: u64) {
|
||||
let victims = match self.index.lock() {
|
||||
Ok(mut g) => g.evict(max),
|
||||
Err(_) => return,
|
||||
@@ -586,6 +646,15 @@ impl DiskCache {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn writer(dc: std::sync::Weak<DiskCache>, q: Arc<(Mutex<Queue>, Condvar)>) {
|
||||
let (m, cv) = &*q;
|
||||
// The eviction at open (a lower limit than the folder holds), off the opening thread;
|
||||
// `busy` starts set, so `flush` waits for it.
|
||||
if let Some(cache) = dc.upgrade() {
|
||||
cache.evict();
|
||||
}
|
||||
if let Ok(mut g) = m.lock() {
|
||||
g.busy = false;
|
||||
}
|
||||
cv.notify_all();
|
||||
loop {
|
||||
let job = {
|
||||
let Ok(mut g) = m.lock() else { return };
|
||||
@@ -772,7 +841,7 @@ fn decode_layer(p: &[u8]) -> Option<Buf> {
|
||||
// ---------------------------------------------------------------- content keys
|
||||
|
||||
/// Bump when rendering changes in a way that makes old disk entries wrong.
|
||||
const RENDER_VERSION: &str = "effectcraft-render-2";
|
||||
const RENDER_VERSION: &str = "effectcraft-render-3";
|
||||
|
||||
fn hash_debug(h: &mut Hash128, v: &impl std::fmt::Debug) {
|
||||
h.write(format!("{v:?}").as_bytes());
|
||||
@@ -841,9 +910,18 @@ pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraf
|
||||
|
||||
/// [`comp_content_key`] with [`VirtualFileId`] for paths `std::fs::metadata` cannot describe.
|
||||
pub fn comp_content_key_with(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId, virtual_id: Option<VirtualFileId<'_>>) -> u128 {
|
||||
comp_content_key_for_renderer(project, comp, virtual_id, RENDER_VERSION)
|
||||
}
|
||||
|
||||
fn comp_content_key_for_renderer(
|
||||
project: &effectcraft_project::Project,
|
||||
comp: effectcraft_project::ItemId,
|
||||
virtual_id: Option<VirtualFileId<'_>>,
|
||||
renderer: &str,
|
||||
) -> u128 {
|
||||
use effectcraft_project::{ItemKind, LayerSource};
|
||||
let mut h = Hash128::default();
|
||||
h.write(RENDER_VERSION.as_bytes());
|
||||
h.write(renderer.as_bytes());
|
||||
hash_debug(&mut h, &project.settings);
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut stack = vec![comp];
|
||||
@@ -1001,6 +1079,28 @@ mod tests {
|
||||
assert!(dc.get_frame(42).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previous_renderer_frames_are_not_reused() {
|
||||
use effectcraft_project::{Comp, ItemKind, Project};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
let mut project = Project::default();
|
||||
let comp = Comp::new(2, 2, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
||||
let id = project.add_item("Cached composition", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into()));
|
||||
let key = frame_key(comp_content_key(&project, id), 0, 1000, 0);
|
||||
let previous_key = frame_key(comp_content_key_for_renderer(&project, id, None, "effectcraft-render-2"), 0, 1000, 0);
|
||||
let dir = tmpdir("previous-renderer");
|
||||
{
|
||||
let cache = DiskCache::open(&dir, 1 << 30).unwrap();
|
||||
cache.put_frame_now(previous_key, 2, 2, &[9; 16]);
|
||||
}
|
||||
let cache = DiskCache::open(&dir, 1 << 30).unwrap();
|
||||
assert_eq!(cache.get_frame(previous_key).unwrap().rgba, vec![9; 16]);
|
||||
assert!(cache.get_frame(key).is_none());
|
||||
cache.put_frame_now(key, 2, 2, &[17; 16]);
|
||||
assert_eq!(cache.get_frame(key).unwrap().rgba, vec![17; 16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persists_across_restarts_and_clears() {
|
||||
let dir = tmpdir("persist");
|
||||
@@ -1030,9 +1130,9 @@ mod tests {
|
||||
let per = dc.stats().bytes / 6;
|
||||
// Touch 0 so it is the most recently used.
|
||||
assert!(dc.get_layer(0).is_some());
|
||||
dc.set_max_bytes(per * 3 + per / 2);
|
||||
dc.set_max_bytes(per * 3 + per * 3 / 4);
|
||||
let st = dc.stats();
|
||||
assert!(st.bytes <= per * 3 + per / 2, "{st:?}");
|
||||
assert!(st.bytes <= per * 3 + per * 3 / 4, "{st:?}");
|
||||
assert_eq!(st.entries, 3);
|
||||
assert!(dc.contains(Kind::Layer, 0), "recently used survives");
|
||||
assert!(!dc.contains(Kind::Layer, 1) && !dc.contains(Kind::Layer, 2) && !dc.contains(Kind::Layer, 3));
|
||||
@@ -1042,7 +1142,69 @@ mod tests {
|
||||
assert_eq!(files, 3);
|
||||
// New writes keep evicting.
|
||||
dc.put_layer_now(99, &buf(9, 64, 64));
|
||||
assert!(dc.stats().bytes <= per * 3 + per / 2);
|
||||
assert!(dc.stats().bytes <= per * 3 + per * 3 / 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eviction_goes_down_to_ninety_percent_in_lru_order() {
|
||||
let mut ix = DiskIndex::default();
|
||||
for k in 0..10u128 {
|
||||
ix.insert(Kind::Layer, k, 100);
|
||||
}
|
||||
assert!(ix.touch(Kind::Layer, 0));
|
||||
// Over 950: down to 855, least recently used first.
|
||||
ix.insert(Kind::Layer, 10, 100);
|
||||
assert_eq!(ix.evict(950), vec![(Kind::Layer, 1), (Kind::Layer, 2), (Kind::Layer, 3)]);
|
||||
assert_eq!(ix.total(), 800);
|
||||
// The slack absorbs the next write without another scan.
|
||||
ix.insert(Kind::Layer, 11, 100);
|
||||
assert!(ix.evict(950).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_disk_space_caps_the_limit() {
|
||||
let dir = tmpdir("space");
|
||||
let dc = DiskCache::open(&dir, 1 << 30).unwrap();
|
||||
for k in 0..4u128 {
|
||||
dc.put_layer_now(k, &buf(k as u32, 64, 64));
|
||||
}
|
||||
let used = dc.stats().bytes;
|
||||
// Unknown space: the setting alone.
|
||||
dc.set_disk_space(None);
|
||||
assert_eq!(dc.stats().limit_bytes, 1 << 30);
|
||||
// A disk reported as 0 bytes is unknown.
|
||||
dc.set_disk_space(Some((0, 0)));
|
||||
assert_eq!(dc.stats().limit_bytes, 1 << 30);
|
||||
// 100 GB disk, 5 GB reserved, exactly 5 GB free: the cache may not grow.
|
||||
dc.set_disk_space(Some((100 << 30, 5 << 30)));
|
||||
let st = dc.stats();
|
||||
assert_eq!((st.max_bytes, st.limit_bytes), (1 << 30, used));
|
||||
dc.put_layer_now(9, &buf(9, 64, 64));
|
||||
let st = dc.stats();
|
||||
assert!(st.bytes <= st.limit_bytes && st.bytes <= used - used / 10 && st.evictions > 0, "{st:?}");
|
||||
assert!(dc.contains(Kind::Layer, 9), "the new entry is the most recently used");
|
||||
// Below the reserve the cache gives its space back, and skips writes that can't fit.
|
||||
dc.set_disk_space(Some((100 << 30, 4 << 30)));
|
||||
dc.put_layer_now(10, &buf(10, 64, 64));
|
||||
let st = dc.stats();
|
||||
assert_eq!((st.limit_bytes, st.entries, st.bytes), (0, 0, 0), "{st:?}");
|
||||
// Plenty of space again: back to the setting.
|
||||
dc.set_disk_space(Some((100 << 30, 50 << 30)));
|
||||
assert_eq!(dc.stats().limit_bytes, 1 << 30);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_writes_are_counted() {
|
||||
let dir = tmpdir("failed");
|
||||
let dc = DiskCache::open(&dir, 1 << 30).unwrap();
|
||||
// A file where the layers folder should be: every layer write fails.
|
||||
std::fs::write(dir.join(VERSION).join(Kind::Layer.dir()), b"x").unwrap();
|
||||
dc.put_layer_now(1, &buf(1, 8, 8));
|
||||
dc.put_layer_now(2, &buf(2, 8, 8));
|
||||
let st = dc.stats();
|
||||
assert_eq!((st.failed, st.writes, st.entries), (2, 0, 0));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn walk(d: &Path) -> usize {
|
||||
|
||||
@@ -594,8 +594,10 @@ impl<'a> Renderer<'a> {
|
||||
if self.project.comp_contains(*item, ctx.comp_id) {
|
||||
return None;
|
||||
}
|
||||
// A composition proxy (a rendered still or movie) stands in for the comp.
|
||||
// Nothing before the nested comp starts or after it ends.
|
||||
// A composition proxy (a rendered still or movie) stands in for the comp. It is
|
||||
// decoded like footage: its Separate Fields, Invert Alpha and Interpret As Linear
|
||||
// Light settings and the layer's Frame Blending apply. Nothing before the nested
|
||||
// comp starts or after it ends.
|
||||
let lt = ctx.nested_time(layer);
|
||||
if !self.project.comp(*item).is_some_and(|nc| nc.covers(lt)) {
|
||||
return None;
|
||||
@@ -603,7 +605,7 @@ impl<'a> Renderer<'a> {
|
||||
if let Some(pf) = self.proxy_for(*item)
|
||||
&& let Some(nc) = self.project.comp(*item)
|
||||
{
|
||||
let img = self.footage.frame_with(*item, pf, lt, None, self.pipe.float_encoding())?;
|
||||
let img = self.footage_frame(ctx, layer, *item, pf, lt, None)?;
|
||||
return Some(self.footage_buf(&img, pf, nc.width, nc.height));
|
||||
}
|
||||
// Essential Properties overrides render the nested comp with this instance's
|
||||
@@ -737,6 +739,10 @@ impl<'a> Renderer<'a> {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(0x00f8_a3e5_u64, item, i, (w as f32).to_bits(), mode as u8, f.frame_rate.num, f.frame_rate.den, &f.path, float).hash(&mut h);
|
||||
(f.alpha as u8).hash(&mut h);
|
||||
if f.alpha == effectcraft_project::AlphaMode::Premultiplied {
|
||||
f.premul_color.map(f32::to_bits).hash(&mut h);
|
||||
}
|
||||
h.finish()
|
||||
});
|
||||
if let (Some(c), Some(k)) = (self.cache, key)
|
||||
|
||||
@@ -324,6 +324,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
|
||||
speed: ctx.f(layer, g, "speed", 2.0),
|
||||
correlation: ctx.f(layer, g, "correlation", 50.0),
|
||||
phase_deg: ctx.f(layer, g, "phase", 0.0),
|
||||
spatial_phase_deg: ctx.f(layer, g, "spatialPhase", 0.0),
|
||||
seed: ctx.f(layer, g, "seed", 0.0),
|
||||
};
|
||||
let t = layer.layer_time(ctx.time).seconds();
|
||||
|
||||
@@ -258,6 +258,24 @@ fn round_corners_clip_corner_pixels() {
|
||||
assert!((coverage(&img) - std::f32::consts::PI * 400.0).abs() < 15.0, "{}", coverage(&img));
|
||||
}
|
||||
|
||||
/// Wiggle Paths' Spatial Phase changes the drawn shape; a whole turn draws it as at 0°.
|
||||
#[test]
|
||||
fn wiggle_paths_spatial_phase_renders() {
|
||||
let render = |phase: f64| {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
vec![
|
||||
build::shape_ellipse(ids, [60.0, 60.0], [0.0, 0.0]),
|
||||
op(ids, "wiggle", &[("size", Value::Scalar(8.0)), ("correlation", Value::Scalar(0.0)), ("spatialPhase", Value::Scalar(phase))]),
|
||||
build::shape_fill(ids, RED),
|
||||
]
|
||||
});
|
||||
render_frame(&p, cid, Tick::from_seconds_f64(0.5), 1.0).data
|
||||
};
|
||||
let zero = render(0.0);
|
||||
assert!(render(90.0) != zero, "Spatial Phase 90° draws the shape of 0°");
|
||||
assert!(render(360.0) == zero, "Spatial Phase 360° draws another shape than 0°");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiggle_paths_vary_over_time_deterministically() {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
|
||||
@@ -42,7 +42,7 @@ pub struct CharXf {
|
||||
pub stroke_hsb: [f64; 3],
|
||||
pub stroke_opacity: f64,
|
||||
pub stroke_width: f64,
|
||||
/// Tracking (1/1000 em) added before / after the character.
|
||||
/// Tracking (pixels) added before / after the character.
|
||||
pub track_before: f64,
|
||||
pub track_after: f64,
|
||||
/// Line Anchor (percent) and the selection weight it was set with.
|
||||
@@ -694,10 +694,9 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
|
||||
let (mut la, mut la_k) = (50.0 * f64::from(align_index(justify)), 0.0);
|
||||
for gi in li_start..li_end {
|
||||
let x = &xfs[gi];
|
||||
let em = lay.styles.get(lay.glyphs[gi].run).map_or(doc.size, |s| s.size) / 1000.0;
|
||||
pen += x.track_before * em;
|
||||
pen += x.track_before;
|
||||
shift[gi] = pen;
|
||||
pen += x.track_after * em + vadv[gi];
|
||||
pen += x.track_after + vadv[gi];
|
||||
if x.line_anchor_k > la_k {
|
||||
la = x.line_anchor;
|
||||
la_k = x.line_anchor_k;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user