Merge main into feat/script-shortcuts

Resolves the docs/scripting-parity.md conflict by keeping the script shortcuts row and main's new Recent Script Files row.
This commit is contained in:
dotCooCoo
2026-10-28 22:45:48 -07:00
296 changed files with 14150 additions and 5853 deletions
+24
View File
@@ -22,3 +22,27 @@ jobs:
lint:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
windows-install-policy:
timeout-minutes: 10
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install the pinned packaging toolchain
shell: pwsh
run: |
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
dotnet tool install --tool-path $tools wix --version 5.0.2
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
$tools >> $env:GITHUB_PATH
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
foreach ($extension in @($policy.extensions)) {
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
}
- name: Build and check non-executable installer fixtures
shell: pwsh
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
+4 -2
View File
@@ -28,8 +28,10 @@ permissions:
jobs:
windows-arm64:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
# Immutable supporting workflow revision until the new install contract lands upstream.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
with:
app-name: effectcraft
app-dir: EffectCraft
install-relative-path: Programs/ArtCraft/EffectCraft
install-scope: perUser
+1
View File
@@ -22,6 +22,7 @@ EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual e
- **Everything is agent-drivable:** every interactive widget registers an automation id; UI state is serde.
- **Community links** (Help menu, About, header Discord button): https://discord.gg/artcraft, https://getartcraft.com, https://getartcraft.com/apps/effectcraft, https://github.com/storytold/effectcraft.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm).
- **New workspace crates** must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them.
- **Commits:** one task id per commit (`M6.3: trim paths`). Only green states.
## Running and looking at the app
Generated
+48 -36
View File
@@ -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",
+5 -2
View File
@@ -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" }
@@ -135,9 +136,11 @@ sys-locale = "0.3"
unsafe_code = "forbid"
[workspace.lints.clippy]
cognitive_complexity = "deny"
too_many_arguments = "deny"
wildcard_imports = "deny"
dbg_macro = "warn"
todo = "warn"
too_many_arguments = "allow"
needless_range_loop = "allow"
type_complexity = "allow"
+6 -2
View File
@@ -275,9 +275,13 @@ send us right now.
| arm64 (Snapdragon and other ARM PCs) | `effectcraft-<ver>-windows-arm64.msi` | `effectcraft-<ver>-windows-arm64-portable.zip` |
| x86 (32-bit) | `effectcraft-<ver>-windows-x86.msi` | `effectcraft-<ver>-windows-x86-portable.zip` |
Installers and executables are code-signed. The installer asks where to install EffectCraft
(Program Files by default), and later versions install into the same folder. To install silently
Installers and executables are code-signed. The installer asks who to install EffectCraft for and where. By default it installs for your
account only, into `%LOCALAPPDATA%\Programs\ArtCraft\EffectCraft`, without an administrator prompt;
"Everyone on this computer" installs into `C:\Program Files\ArtCraft\EffectCraft` (`Program Files (x86)` for the 32-bit build). Later versions
install into the same folder. To install silently
into a folder of your choice: `msiexec /i effectcraft-<ver>-windows-x64.msi /qn INSTALLFOLDER="D:\Apps\EffectCraft"`.
A silent install is per-user by default. `INSTALLFOLDER` chooses the folder, not who it is installed for;
add `ALLUSERS=1`, from an elevated terminal or deployment tool, for an all-users install.
### macOS
+20 -9
View File
@@ -152,6 +152,13 @@ fn essential_graphics_template_via_script_and_commands() {
assert_eq!(image::open(&frames[0]).unwrap().to_rgba8(), edit);
assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), original_template);
invalid_template_values_fail_cleanly(&dir);
template_usage_errors(&dir);
rational_template_keeps_exact_rate(&dir, &original_template);
let _ = std::fs::remove_dir_all(&dir);
}
fn invalid_template_values_fail_cleanly(dir: &PathBuf) {
// Invalid values fail before the queue creates an output directory, or frame rendering
// overwrites an existing output. A JSON object and existing control names are required.
for (name, values, expected) in [
@@ -164,25 +171,27 @@ fn essential_graphics_template_via_script_and_commands() {
std::fs::write(dir.join(&values_file), values).unwrap();
let output_dir = dir.join(format!("failed-{name}"));
let out = output_dir.join("frame_[#####].png");
let (code, error) = cli(&dir, &["render", "--template", "lt.ectemplate", "--values", &values_file, "--out", out.to_str().unwrap()]);
let (code, error) = cli(dir, &["render", "--template", "lt.ectemplate", "--values", &values_file, "--out", out.to_str().unwrap()]);
assert_eq!(code, 1, "{name}: {error}");
assert!(error["error"].as_str().unwrap().contains(expected), "{name}: {error}");
assert!(!output_dir.exists(), "{name}: invalid input created render output");
std::fs::write(dir.join("untouched.png"), b"existing output").unwrap();
let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", &values_file, "--out", "untouched.png"]);
let (code, error) = cli(dir, &["render-frame", "--template", "lt.ectemplate", "--values", &values_file, "--out", "untouched.png"]);
assert_eq!(code, 1, "{name}: {error}");
assert_eq!(std::fs::read(dir.join("untouched.png")).unwrap(), b"existing output");
}
let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "missing-values.json", "--out", "missing.png"]);
let (code, error) = cli(dir, &["render-frame", "--template", "lt.ectemplate", "--values", "missing-values.json", "--out", "missing.png"]);
assert_eq!(code, 1, "{error}");
assert!(error["error"].as_str().unwrap().contains("cannot read values"), "{error}");
assert!(!dir.join("missing.png").exists());
std::fs::write(dir.join("large-values.json"), vec![b' '; 1024 * 1024 + 1]).unwrap();
let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "large-values.json", "--out", "large.png"]);
let (code, error) = cli(dir, &["render-frame", "--template", "lt.ectemplate", "--values", "large-values.json", "--out", "large.png"]);
assert_eq!(code, 1, "{error}");
assert!(error["error"].as_str().unwrap().contains("exceeds 1 MiB"), "{error}");
assert!(!dir.join("large.png").exists());
}
fn template_usage_errors(dir: &PathBuf) {
for args in [
vec!["render-frame", "--values", "values.json"],
vec!["info", "--template", "lt.ectemplate"],
@@ -191,10 +200,12 @@ fn essential_graphics_template_via_script_and_commands() {
vec!["render", "--template", "lt.ectemplate", "--queue"],
vec!["render-frame", "--template", "lt.ectemplate", "--bridge", "1"],
] {
let output = Command::new(crate::binary()).current_dir(&dir).args(&args).output().unwrap();
let output = Command::new(crate::binary()).current_dir(dir).args(&args).output().unwrap();
assert_eq!(output.status.code(), Some(2), "{args:?}: {}", String::from_utf8_lossy(&output.stderr));
}
}
fn rational_template_keeps_exact_rate(dir: &PathBuf, original_template: &[u8]) {
// File-loaded templates can use exact rational rates that comp.new's float parser rounds.
// Preserve that rate, Tick duration, work area, background and locked source layers.
let mut project = effectcraft_engine::project::Project::from_json(&std::fs::read_to_string(dir.join("t.ecproj")).unwrap()).unwrap();
@@ -207,13 +218,13 @@ fn essential_graphics_template_via_script_and_commands() {
comp.background = [0.08, 0.16, 0.24];
comp.layers.first_mut().unwrap().switches.locked = true;
std::fs::write(dir.join("rational.ecproj"), project.to_json()).unwrap();
ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"rational.ectemplate"}"#, "rational.ecproj"]);
ok(dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"rational.ectemplate"}"#, "rational.ecproj"]);
let rational_template = std::fs::read(dir.join("rational.ectemplate")).unwrap();
let rational = ok(&dir, &["render-frame", "--template", "rational.ectemplate", "--frame", "5", "--out", "rational.png"]);
let rational = ok(dir, &["render-frame", "--template", "rational.ectemplate", "--frame", "5", "--out", "rational.png"]);
assert!((rational["time"].as_f64().unwrap() - 5.0 / rate.as_f64()).abs() < 1e-9, "{rational}");
assert_eq!((rational["width"].as_u64().unwrap(), rational["height"].as_u64().unwrap()), (320, 90));
let output = dir.join("rational-work-area/frame_[#####].png");
ok(&dir, &["render", "--template", "rational.ectemplate", "--work-area", "--out", output.to_str().unwrap(), "--format", "png", "--resolution", "0.05"]);
ok(dir, &["render", "--template", "rational.ectemplate", "--work-area", "--out", output.to_str().unwrap(), "--format", "png", "--resolution", "0.05"]);
let count = std::fs::read_dir(dir.join("rational-work-area"))
.unwrap()
.filter_map(Result::ok)
@@ -222,8 +233,8 @@ fn essential_graphics_template_via_script_and_commands() {
assert_eq!(count, 4, "the template's exact work area contains frames 3 through 6");
assert_eq!(std::fs::read(dir.join("rational.ectemplate")).unwrap(), rational_template);
assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), original_template);
let _ = std::fs::remove_dir_all(&dir);
}
fn template_output_alias_fixture(case: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("ec-qa-template-output-{case}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
+4
View File
@@ -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).
+3 -2
View File
@@ -311,10 +311,11 @@ pub fn stats() -> Value {
})
}
type PendingFrameCheck = (js_sys::Function, js_sys::Function, (u32, u32, Vec<u8>), f64);
thread_local! {
/// `workerFrameCheck` promises waiting for their frame: id → (resolve, reject, local frame).
#[allow(clippy::type_complexity)]
static CHECKS: RefCell<std::collections::HashMap<u64, (js_sys::Function, js_sys::Function, (u32, u32, Vec<u8>), f64)>> = RefCell::new(Default::default());
static CHECKS: RefCell<std::collections::HashMap<u64, PendingFrameCheck>> = RefCell::new(Default::default());
}
/// `effectcraft.workerFrameCheck({time?, scale?, backend?})`: render the active comp's frame in
+11 -9
View File
@@ -9,8 +9,8 @@
//!
//! `--control <port>` (or `EFFECTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server;
//! see `effectcraft_ui_egui::control` for the methods.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
// Built everywhere so its tests run on every platform; only Linux AppImages use it.
mod appearance;
@@ -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
}
@@ -455,7 +452,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 +462,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 +483,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();
-2
View File
@@ -1,6 +1,4 @@
//! The desktop binary rejects a bad command line before it starts anything (#997).
// Integration tests: unwrapping and panicking on failure is fine here.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::PathBuf;
use std::process::{Command, Stdio};
+1
View File
@@ -5,3 +5,4 @@ allow-indexing-slicing-in-tests = true
disallowed-types = [
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
]
cognitive-complexity-threshold = 30
+39 -12
View File
@@ -7,6 +7,10 @@ use serde_json::{Value, json};
use crate::harness::{Img, Qa, coverage, luma, mean_diff};
fn lum(i: &Img, x: u32, y: u32) -> f64 {
luma(i.get_pixel(x, y))
}
fn px(img: &Img, x: u32, y: u32) -> [u8; 4] {
img.get_pixel(x, y).0
}
@@ -25,13 +29,12 @@ fn write_sequence(qa: &Qa) -> String {
qa.path("seq/ball_000.png")
}
#[test]
fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
let mut qa = Qa::new("compositing");
/// Import the sequence (reinterpreted at 12 fps) and a PSD written by the app; returns the
/// sequence item and the PSD's composition.
fn import_footage(qa: &mut Qa) -> (Value, Value) {
// Footage: the sequence, and a layered PSD written by the app itself (Save Frame As ▸
// Photoshop Layers) from a small comp.
let seq = write_sequence(&qa);
let seq = write_sequence(qa);
let imp = qa.exec("file.import", json!({"paths": [seq]}));
let seq_item = imp["items"][0].clone();
let project = qa.tool("get_project", json!({}));
@@ -55,10 +58,14 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
let pc = qa.tool("get_comp", json!({"comp": psd_comp.clone()}));
let names: Vec<&str> = pc["layers"].as_array().unwrap().iter().map(|l| l["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Bar", "Gray"], "the PSD's layers, top first: {pc}");
(seq_item, psd_comp)
}
/// The main comp: the sequence plays, then a Screen-blended PSD; returns the Normal-mode frame.
fn main_comp_and_blend(qa: &mut Qa, seq_item: &Value, psd_comp: &Value) -> Img {
// The main comp.
let main = qa.exec("comp.new", json!({"name": "Main", "width": 160, "height": 90, "frameRate": 12, "duration": 1}))["comp"].clone();
qa.exec("layer.addItem", json!({"item": seq_item}));
qa.exec("comp.new", json!({"name": "Main", "width": 160, "height": 90, "frameRate": 12, "duration": 1}));
qa.exec("layer.addItem", json!({"item": seq_item.clone()}));
let a = qa.frame(None, 0.0);
let b = qa.frame(None, 0.5);
assert!(mean_diff(&a, &b) > 1.0, "the sequence plays");
@@ -75,7 +82,10 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
for c in 0..3 {
assert!(s[c] > n[c] && s[c] > [32, 64, 96][c], "Screen brightens channel {c}: {s:?}");
}
normal
}
fn track_matte(qa: &mut Qa) {
// Track matte: a magenta solid seen only through text.
qa.exec("layer.newText", json!({"text": "MATTE", "name": "Matte", "size": 40, "fill": "#ffffff"}));
qa.exec("layer.newSolid", json!({"name": "Fill", "color": "#ff00ff", "width": 160, "height": 90}));
@@ -91,7 +101,9 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
let fill = info["layers"].as_array().unwrap().iter().find(|l| l["name"] == "Fill").unwrap().clone();
assert_eq!(fill["trackMatte"]["kind"], "Alpha Matte");
qa.exec("layer.setSwitch", json!({"layers": ["Fill", "Matte"], "switch": "video", "value": false}));
}
fn adjustment_layer(qa: &mut Qa, normal: &Img) {
// Adjustment layer: Lumetri exposure, inside a feathered, expanded elliptical mask; then Glow.
qa.exec("layer.newAdjustment", json!({"name": "Grade"}));
let fx = qa.exec("effect.apply", json!({"effect": "Lumetri Color", "layers": ["Grade"]}));
@@ -103,7 +115,6 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
let mk = qa.exec("layer.addMask", json!({"layer": "Grade", "shape": "ellipse", "rect": [40, 20, 80, 50]}));
assert!(mk["mask"].is_u64(), "{mk}");
let hard = qa.frame(None, 0.5);
let lum = |i: &Img, x, y| luma(i.get_pixel(x, y));
assert!(lum(&hard, 80, 45) > lum(&before, 80, 45) - 1.0 && lum(&hard, 5, 5) < lum(&before, 5, 5) - 10.0, "the mask limits the grade to the ellipse");
qa.exec("layer.mask.set", json!({"layer": "Grade", "field": "expansion", "value": 8}));
let grown = qa.frame(None, 0.5);
@@ -116,7 +127,7 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
}
n
};
assert!(bright(&grown, &normal) > bright(&hard, &normal) + 100, "expansion grows the graded area");
assert!(bright(&grown, normal) > bright(&hard, normal) + 100, "expansion grows the graded area");
qa.exec("layer.mask.set", json!({"layer": "Grade", "field": "feather", "value": 20}));
let soft = qa.frame(None, 0.5);
// At the ellipse's edge (x = 40 − 8): feathered is in between none and full.
@@ -125,7 +136,10 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
qa.exec("effect.apply", json!({"effect": "Glow", "layers": ["Grade"]}));
let glow = qa.frame(None, 0.5);
assert!(mean_diff(&soft, &glow) > 0.5, "Glow changes the frame");
}
/// The 3D stage with depth of field and a light; returns the stage comp.
fn stage_3d(qa: &mut Qa) -> Value {
// 3D stage: a near and a far card, a camera with depth of field, a light.
let stage = qa.exec("comp.new", json!({"name": "Stage", "width": 160, "height": 90, "frameRate": 12, "duration": 1}))["comp"].clone();
qa.exec("layer.newSolid", json!({"name": "Near", "color": "#ff4040", "width": 40, "height": 40}));
@@ -155,7 +169,10 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
let advanced = qa.frame(None, 0.0);
assert!(mean_diff(&classic, &advanced) > 0.2, "Advanced 3D renders the stage its own way");
assert!(lum(&advanced, 50, 45) > 60.0, "Advanced 3D still lights the card");
stage
}
fn alpha_renders(qa: &mut Qa, stage: &Value) {
// Renders with alpha: the stage has no background layer, so most of it is transparent.
let mov = qa.path("stage.mov");
let webm = qa.path("stage.webm");
@@ -165,7 +182,7 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
assert!(done["items"].as_array().unwrap().iter().all(|i| i["status"] == "Done"), "{done}");
let reference = qa.tool("render_frame", json!({"comp": stage.clone(), "time": 0, "transparent": true, "inline": false}));
assert_eq!(reference["width"], 160);
let refa = alpha_frame(&mut qa, stage.clone());
let refa = alpha_frame(qa, stage.clone());
assert!(px(&refa, 2, 2)[3] < 40 && px(&refa, 50, 45)[3] == 255, "render_frame transparent keeps alpha: {:?} {:?}", px(&refa, 2, 2), px(&refa, 50, 45));
// ProRes 4444 carries alpha in the frame; WebM VP9 as a second VP9 stream in BlockAdditions
// (AlphaMode 1), which FilmCraft's Matroska reader decodes into the frame's alpha plane.
@@ -174,14 +191,24 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
assert_eq!(r["errors"], json!([]));
assert_eq!(r["unlabeledAlpha"], r["items"], "{path}: the importer sees an alpha channel");
let c = qa.exec("file.newCompFromSelection", json!({}))["comps"][0].clone();
let f = alpha_frame(&mut qa, c.clone());
let f = alpha_frame(qa, c.clone());
// The opaque card decodes to the stage's colours (lossy codecs, so loosely).
let (got, want) = (px(&f, 50, 45), px(&refa, 50, 45));
assert!((0..3).all(|c| got[c].abs_diff(want[c]) < 12), "{path} decodes to the stage: {got:?} vs {want:?}");
assert!(mean_diff(&qa.frame(Some(c.clone()), 0.0), &qa.frame(Some(stage.clone()), 0.0)) < 8.0, "{path} composites like the stage");
assert!(px(&f, 2, 2)[3] < 40 && px(&f, 50, 45)[3] > 250, "{path}: transparent corners, opaque card: {:?} {:?}", px(&f, 2, 2), px(&f, 50, 45));
}
let _ = main;
}
#[test]
fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
let mut qa = Qa::new("compositing");
let (seq_item, psd_comp) = import_footage(&mut qa);
let normal = main_comp_and_blend(&mut qa, &seq_item, &psd_comp);
track_matte(&mut qa);
adjustment_layer(&mut qa, &normal);
let stage = stage_3d(&mut qa);
alpha_renders(&mut qa, &stage);
}
fn alpha_frame(qa: &mut Qa, comp: Value) -> Img {
+9 -1
View File
@@ -2,7 +2,15 @@
//! init_coeff_cdfs that its syntax uses). Layout as in filmcraft-av1: each innermost array
//! holds N cumulative values (the last is 32768) plus the adaptation counter.
use crate::tables::*;
use crate::tables::{
DEFAULT_ANGLE_DELTA_CDF, DEFAULT_COEFF_BASE_CDF, DEFAULT_COEFF_BASE_EOB_CDF, DEFAULT_COEFF_BR_CDF, DEFAULT_DC_SIGN_CDF, DEFAULT_DRL_MODE_CDF,
DEFAULT_EOB_EXTRA_CDF, DEFAULT_EOB_PT_16_CDF, DEFAULT_EOB_PT_32_CDF, DEFAULT_EOB_PT_64_CDF, DEFAULT_EOB_PT_128_CDF, DEFAULT_EOB_PT_256_CDF,
DEFAULT_EOB_PT_512_CDF, DEFAULT_EOB_PT_1024_CDF, DEFAULT_INTER_TX_TYPE_SET3_CDF, DEFAULT_INTRA_TX_TYPE_SET2_CDF, DEFAULT_IS_INTER_CDF, DEFAULT_MV_BIT_CDF,
DEFAULT_MV_CLASS_CDF, DEFAULT_MV_CLASS0_BIT_CDF, DEFAULT_MV_CLASS0_FR_CDF, DEFAULT_MV_FR_CDF, DEFAULT_MV_JOINT_CDF, DEFAULT_MV_SIGN_CDF,
DEFAULT_NEW_MV_CDF, DEFAULT_PARTITION_W8_CDF, DEFAULT_PARTITION_W16_CDF, DEFAULT_PARTITION_W32_CDF, DEFAULT_PARTITION_W64_CDF, DEFAULT_REF_MV_CDF,
DEFAULT_SINGLE_REF_CDF, DEFAULT_SKIP_CDF, DEFAULT_TXB_SKIP_CDF, DEFAULT_UV_MODE_CFL_ALLOWED_CDF, DEFAULT_UV_MODE_CFL_NOT_ALLOWED_CDF, DEFAULT_Y_MODE_CDF,
DEFAULT_ZERO_MV_CDF,
};
#[derive(Clone)]
pub(crate) struct Cdfs {
+119 -47
View File
@@ -3,8 +3,12 @@
//! processes, so the reconstruction built here is exactly what a decoder produces; the syntax
//! is written afterwards by `writer.rs`.
use crate::pred::*;
use crate::tables::*;
use crate::pred::{INTRA_MODES_USED, InterBlock, IntraEdge, Plane, edges, predict_inter, predict_intra, subpel_plane};
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,
TX_SET_INTRA_2, TX_SIZE_SQR_UP, TX_TYPE_IN_SET_INTER, TX_TYPE_IN_SET_INTRA,
};
use crate::transform::{forward_2d, inverse_2d};
/// Decisions for one coded block.
@@ -100,6 +104,17 @@ pub(crate) fn intra_uv_tx_type(uv_mode: usize, uv_tx: usize) -> usize {
if tx_in_set(tx_set(uv_tx, false), false, t) { t } else { DCT_DCT }
}
/// Geometry and coding mode of one square transform block.
#[derive(Clone, Copy)]
struct TransformBlock {
plane: usize,
x: usize,
y: usize,
log2: u32,
tx_type: usize,
intra: bool,
}
struct TxResult {
levels: Vec<i32>,
nonzero: bool,
@@ -107,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],
@@ -131,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;
@@ -166,6 +215,7 @@ impl<'a> Decider<'a> {
Some(rf) => [(8, 0), (0, 8), (8, 8)].map(|(fx, fy)| subpel_plane(&rf.planes[0], g.width, g.height, fx, fy, bd)),
None => Default::default(),
},
scratch: None,
}
}
@@ -324,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 {
@@ -343,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(0, c * 4, r * 4, log2, t, &pred[..n * n], true);
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 {
@@ -374,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)
@@ -422,7 +480,7 @@ impl<'a> Decider<'a> {
let uv_type = intra_uv_tx_type(uv_mode, uv_tx);
let mut code_uv = |i: usize| {
predict_intra(&ced[i], &ce[i], uv_mode, &mut pred);
let t = self.code_tx(1 + i, c * 2, r * 2, clog2, uv_type, &pred[..cn * cn], true);
let t = self.code_tx(TransformBlock { plane: 1 + i, x: c * 2, y: r * 2, log2: clog2, tx_type: uv_type, intra: true }, &pred[..cn * cn]);
self.mark_decoded(1 + i, r, c, n4);
t
};
@@ -450,13 +508,17 @@ impl<'a> Decider<'a> {
let (vw, vh) = self.g.visible(p);
let pn = n >> s;
let (x, y) = ((c * 4) >> s, (r * 4) >> s);
predict_inter(&refr.planes[p], vw as i32 - 1, vh as i32 - 1, x, y, pn, pn, mv, s, self.bd, &mut pred[..pn * pn]);
predict_inter(
&refr.planes[p],
&InterBlock { last_x: vw as i32 - 1, last_y: vh as i32 - 1, x, y, width: pn, height: pn, mv, subsampling: s, bit_depth: self.bd },
&mut pred[..pn * pn],
);
if bsize == BLOCK_64X64 {
// coded as a skip block: the prediction is the reconstruction
dist += self.sse(p, x, y, pn, &pred[..pn * pn]);
self.put(p, x, y, pn, &pred[..pn * pn]);
} else {
let t = self.code_tx(p, x, y, log2 - s as u32, DCT_DCT, &pred[..pn * pn], false);
let t = self.code_tx(TransformBlock { plane: p, x, y, log2: log2 - s as u32, tx_type: DCT_DCT, intra: false }, &pred[..pn * pn]);
dist += t.dist;
tx.push(t);
}
@@ -771,32 +833,38 @@ impl<'a> Decider<'a> {
/// Transform, quantise and reconstruct one square transform block of `plane` at (x, y)
/// over the prediction `pred`; the reconstruction is written to `rec`.
fn code_tx(&mut self, p: usize, x: usize, y: usize, log2: u32, tx_type: usize, pred: &[u16], intra: bool) -> TxResult {
let (res, recon) = self.try_tx(p, x, y, log2, tx_type, pred, intra);
fn code_tx(&mut self, block: TransformBlock, pred: &[u16]) -> TxResult {
let TransformBlock { plane: p, x, y, log2, .. } = block;
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, p: usize, x: usize, y: usize, log2: u32, tx_type: usize, pred: &[u16], intra: bool) -> (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 };
@@ -808,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;
@@ -834,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;
}
}
}
+3 -1
View File
@@ -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));
+17 -5
View File
@@ -3,7 +3,7 @@
//! is 0 in the frames this encoder writes.
use crate::pred::Plane;
use crate::tables::*;
use crate::tables::{MAX_LOOP_FILTER, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, SUBSAMPLED_SIZE, TX_HEIGHT, TX_WIDTH};
/// Mode info the loop filter reads (per 4x4 luma unit, row stride `mi_cols`), and the
/// transform size per 4x4 unit of each plane (`LoopfilterTxSizes`, row stride `mi_cols + 32`).
@@ -39,7 +39,7 @@ pub(crate) fn loop_filter(planes: &mut [Plane; 3], info: &LfInfo, level: [u32; 4
}
let mut col = 0;
while col < info.mi_cols {
edge(pl, info, level, bit_depth, plane, pass, row, col);
edge(pl, info, level, bit_depth, plane, pass, (row, col));
col += step;
}
row += step;
@@ -48,7 +48,8 @@ pub(crate) fn loop_filter(planes: &mut [Plane; 3], info: &LfInfo, level: [u32; 4
}
}
fn edge(pl: &mut Plane, info: &LfInfo, level: [u32; 4], bd: u32, plane: usize, pass: usize, row: usize, col: usize) {
fn edge(pl: &mut Plane, info: &LfInfo, level: [u32; 4], bd: u32, plane: usize, pass: usize, position: (usize, usize)) {
let (row, col) = position;
let s = (plane > 0) as usize;
let (dx, dy) = if pass == 0 { (1usize, 0usize) } else { (0, 1) };
let x = col * 4;
@@ -88,13 +89,24 @@ fn edge(pl: &mut Plane, info: &LfInfo, level: [u32; 4], bd: u32, plane: usize, p
let limit = lvl.max(1);
let blimit = 2 * (lvl + 2) + limit;
let thresh = lvl >> 4;
let strength = FilterStrength { limit, blimit, thresh };
for k in 0..4 {
sample_filter(pl, xp + dy * k, yp + dx * k, plane, limit, blimit, thresh, dx as isize, dy as isize, filter_size, bd);
sample_filter(pl, (xp + dy * k, yp + dx * k), plane, &strength, (dx as isize, dy as isize), filter_size, bd);
}
}
/// Thresholds derived from the loop-filter level (spec 7.14.4).
struct FilterStrength {
limit: i32,
blimit: i32,
thresh: i32,
}
#[inline]
fn sample_filter(pl: &mut Plane, x: usize, y: usize, plane: usize, limit: i32, blimit: i32, thresh: i32, dx: isize, dy: isize, filter_size: usize, bd: u32) {
fn sample_filter(pl: &mut Plane, position: (usize, usize), plane: usize, strength: &FilterStrength, direction: (isize, isize), filter_size: usize, bd: u32) {
let (x, y) = position;
let (dx, dy) = direction;
let FilterStrength { limit, blimit, thresh } = *strength;
let stride = pl.stride as isize;
let step = dy * stride + dx;
let base = y as isize * stride + x as isize;
+23 -15
View File
@@ -2,7 +2,10 @@
//! modes plus V_PRED / H_PRED without edge filtering, 7.11.3 block inter prediction without
//! reference scaling) so that the encoder predicts exactly like a decoder.
use crate::tables::*;
use crate::tables::{
DC_PRED, EIGHTTAP, H_PRED, PAETH_PRED, SM_WEIGHTS_TX_4X4, SM_WEIGHTS_TX_8X8, SM_WEIGHTS_TX_16X16, SM_WEIGHTS_TX_32X32, SMOOTH_H_PRED, SMOOTH_PRED,
SMOOTH_V_PRED, SUBPEL_FILTERS, V_PRED,
};
use crate::transform::round2;
/// A plane buffer (padded to whole superblocks).
@@ -184,19 +187,20 @@ pub(crate) fn predict_intra(ed: &Edges, e: &IntraEdge, mode: usize, pred: &mut [
/// final rounding is the identity) of the `w x h` block at plane position (x, y) displaced by
/// `mv` (1/8 luma sample units, [row, col]). `last_x` / `last_y` are the largest sample
/// coordinates of the reference plane; the result goes to `pred` (row stride `w`).
pub(crate) fn predict_inter(
refp: &Plane,
last_x: i32,
last_y: i32,
x: usize,
y: usize,
w: usize,
h: usize,
mv: [i32; 2],
ss: usize,
bit_depth: u32,
pred: &mut [u16],
) {
pub(crate) struct InterBlock {
pub last_x: i32,
pub last_y: i32,
pub x: usize,
pub y: usize,
pub width: usize,
pub height: usize,
pub mv: [i32; 2],
pub subsampling: usize,
pub bit_depth: u32,
}
pub(crate) fn predict_inter(refp: &Plane, block: &InterBlock, pred: &mut [u16]) {
let InterBlock { last_x, last_y, x, y, width: w, height: h, mv, subsampling: ss, bit_depth } = *block;
// positions in 1/16 sample units ((startX >> 6) & 15 is the filter phase)
let px = ((x as i32) << 4) + ((2 * mv[1]) >> ss);
let py = ((y as i32) << 4) + ((2 * mv[0]) >> ss);
@@ -340,7 +344,11 @@ mod tests {
for (bx, by) in [(0i32, 0i32), (8, 8), (24, 16), (-5, 20), (30, -3)] {
let mut pred = vec![0u16; 64];
let mv = [by * 8 + fy as i32 / 2, bx * 8 + fx as i32 / 2];
predict_inter(&p, w as i32 - 1, h as i32 - 1, 0, 0, 8, 8, mv, 0, 8, &mut pred);
predict_inter(
&p,
&InterBlock { last_x: w as i32 - 1, last_y: h as i32 - 1, x: 0, y: 0, width: 8, height: 8, mv, subsampling: 0, bit_depth: 8 },
&mut pred,
);
for r in 0..8 {
for c in 0..8 {
let (x, y) = ((bx + c).clamp(0, w as i32 - 1), (by + r).clamp(0, h as i32 - 1));
+12 -1
View File
@@ -4,7 +4,7 @@
use std::sync::OnceLock;
use crate::tables::*;
use crate::tables::{ADST_ADST, ADST_DCT, COS128_LOOKUP, DCT_ADST, DCT_DCT, TRANSFORM_ROW_SHIFT, TX_WIDTH_LOG2};
#[inline(always)]
fn round2_64(x: i64, n: u32) -> i32 {
@@ -71,6 +71,12 @@ fn inverse_dct(t: &mut [i32], n: u32, r: u32) {
for i in 0..len {
t[i] = copy[brev(n, i)];
}
inverse_dct_initial_stages(t, n, r);
inverse_dct_final_stages(t, n, r);
}
/// Rotate the high-frequency branches before the four-point core.
fn inverse_dct_initial_stages(t: &mut [i32], n: u32, r: u32) {
if n >= 5 {
for i in 0..8 {
bf(t, 16 + i, 31 - i, 6 + ((brev(3, 7 - i) as i32) << 3), false);
@@ -103,6 +109,10 @@ fn inverse_dct(t: &mut [i32], n: u32, r: u32) {
}
}
}
}
/// Evaluate the four-point core and merge progressively larger branches.
fn inverse_dct_final_stages(t: &mut [i32], n: u32, r: u32) {
for i in 0..2 {
bf(t, 2 * i, 2 * i + 1, 32 + 16 * i as i32, i == 0);
}
@@ -454,6 +464,7 @@ pub(crate) fn forward_2d(residual: &[i32], out: &mut [f32], n: usize, tx_type: u
#[cfg(test)]
mod tests {
use super::*;
use crate::tables::{TX_4X4, TX_8X8, TX_16X16, TX_32X32};
/// forward (float) then the normative inverse reproduces the residual: the forward basis
/// and scale match the decoder's inverse transforms for every supported size and type.
+41 -23
View File
@@ -8,7 +8,15 @@ use crate::cdf::Cdfs;
use crate::decide::{FrameGeom, Leaf, Node, intra_uv_tx_type, scan_for, tx_in_set, tx_set, tx_size_for};
use crate::loopfilter::LfInfo;
use crate::symbol::SymbolWriter;
use crate::tables::*;
use crate::tables::{
ADST_ADST, ADST_DCT, ALTREF_FRAME, ALTREF2_FRAME, BLOCK_8X8, BLOCK_64X64, BR_CDF_SIZE, BWDREF_FRAME, CLASS0_SIZE, COEFF_BASE_CTX_OFFSET, COEFF_BASE_RANGE,
D67_PRED, DC_PRED, DCT_ADST, DCT_DCT, DEFAULT_INTRA_FRAME_Y_MODE_CDF, GLOBALMV, GOLDEN_FRAME, INTRA_FRAME, INTRA_MODE_CONTEXT, LAST_FRAME, LAST2_FRAME,
LAST3_FRAME, MAG_REF_OFFSET_WITH_TX_CLASS, MAX_ANGLE_DELTA, MAX_REF_MV_STACK_SIZE, MI_HEIGHT_LOG2, MI_WIDTH_LOG2, MV_BORDER, MV_CLASS_0, NEARESTMV, NEARMV,
NEWMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, NUM_BASE_LEVELS, PARTITION_HORZ, PARTITION_HORZ_4, PARTITION_HORZ_A, PARTITION_HORZ_B, PARTITION_NONE,
PARTITION_SPLIT, PARTITION_SUBSIZE, PARTITION_VERT, PARTITION_VERT_4, PARTITION_VERT_A, PARTITION_VERT_B, REF_CAT_LEVEL, SIG_REF_DIFF_OFFSET, SIZE_GROUP,
SUBSAMPLED_SIZE, TX_32X32, TX_CLASS_2D, TX_HEIGHT, TX_HEIGHT_LOG2, TX_SIZE_SQR, TX_SIZE_SQR_UP, TX_TYPE_INTER_INV_SET3, TX_TYPE_INTRA_INV_SET2, TX_WIDTH,
TX_WIDTH_LOG2, V_PRED,
};
type MvP = [i32; 2];
@@ -62,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,
@@ -114,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,
@@ -181,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 {
@@ -455,23 +466,7 @@ impl<'a> TileWriter<'a> {
}
} else {
if plane == 0 {
// transform_type( )
let set = tx_set(tx_sz, b.is_inter);
let t = b.tx_type_y;
if set > 0 && self.qidx > 0 {
let sqr = TX_SIZE_SQR[tx_sz] as usize;
if b.is_inter {
// The decision only picks types of this set (DCT_DCT otherwise).
let s = TX_TYPE_INTER_INV_SET3.iter().position(|&v| v as usize == t).unwrap_or_default();
self.sw.symbol(s, &mut self.cdf.inter_tx_type_set3[sqr]);
} else {
let s = TX_TYPE_INTRA_INV_SET2.iter().position(|&v| v as usize == t).unwrap_or_default();
self.sw.symbol(s, &mut self.cdf.intra_tx_type_set2[sqr][b.y_mode]);
}
} else {
assert_eq!(t, DCT_DCT);
}
self.set_tx_types(x4, y4, tx_sz, t);
self.write_transform_type(x4, y4, tx_sz, b);
}
let plane_tx_type = self.compute_tx_type(plane, tx_sz, x4, y4, b);
debug_assert!(plane_tx_type == DCT_DCT || matches!(plane_tx_type, ADST_DCT | DCT_ADST | ADST_ADST));
@@ -501,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;
@@ -526,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];
@@ -571,6 +569,26 @@ impl<'a> TileWriter<'a> {
}
}
fn write_transform_type(&mut self, x4: usize, y4: usize, tx_sz: usize, b: &Leaf) {
// transform_type( )
let set = tx_set(tx_sz, b.is_inter);
let t = b.tx_type_y;
if set > 0 && self.qidx > 0 {
let sqr = TX_SIZE_SQR[tx_sz] as usize;
if b.is_inter {
// The decision only picks types of this set (DCT_DCT otherwise).
let s = TX_TYPE_INTER_INV_SET3.iter().position(|&v| v as usize == t).unwrap_or_default();
self.sw.symbol(s, &mut self.cdf.inter_tx_type_set3[sqr]);
} else {
let s = TX_TYPE_INTRA_INV_SET2.iter().position(|&v| v as usize == t).unwrap_or_default();
self.sw.symbol(s, &mut self.cdf.intra_tx_type_set2[sqr][b.y_mode]);
}
} else {
assert_eq!(t, DCT_DCT);
}
self.set_tx_types(x4, y4, tx_sz, t);
}
fn set_tx_types(&mut self, x4: usize, y4: usize, tx_sz: usize, t: usize) {
for i in 0..(TX_WIDTH[tx_sz] as usize >> 2) {
for j in 0..(TX_HEIGHT[tx_sz] as usize >> 2) {
+217 -34
View File
@@ -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);
+28 -13
View File
@@ -692,18 +692,33 @@ impl BoltCfg<'_> {
}
}
/// Grow a bolt from `a` to `z` by midpoint displacement (`depth` levels), forking at random.
/// Segments go to `out` with their intensity; `mains` marks the main core's segments.
#[allow(clippy::too_many_arguments)]
fn bolt(cfg: &BoltCfg, a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, main: bool, rng: &mut Rng, out: &mut Vec<Seg>, mains: &mut Vec<bool>) {
if out.len() > 20_000 {
/// One branch's geometry and recursive growth state.
struct BoltBranch {
start: (f64, f64),
end: (f64, f64),
depth: u32,
intensity: f32,
main: bool,
}
/// Segments and their matching main-core flags, kept in the same traversal order.
#[derive(Default)]
struct BoltOutput {
segments: Vec<Seg>,
mains: Vec<bool>,
}
/// Grow a branch by midpoint displacement, recursively appending its segments and forks.
fn bolt(cfg: &BoltCfg, branch: BoltBranch, rng: &mut Rng, output: &mut BoltOutput) {
let BoltBranch { start: a, end: z, depth, intensity: inten, main } = branch;
if output.segments.len() > 20_000 {
return;
}
let (dx, dy) = (z.0 - a.0, z.1 - a.1);
let len = (dx * dx + dy * dy).sqrt();
if depth == 0 || len < 1.0 {
out.push((a, z, inten));
mains.push(main);
output.segments.push((a, z, inten));
output.mains.push(main);
return;
}
let (nx, ny) = (-dy / len.max(1e-9), dx / len.max(1e-9));
@@ -725,8 +740,8 @@ fn bolt(cfg: &BoltCfg, a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, mai
if cfg.obstacle > 0.0 && !main && cfg.alpha_at(m) > 0.5 && rng.f() < cfg.obstacle / 10.0 {
return;
}
bolt(cfg, a, m, depth - 1, inten, main, rng, out, mains);
bolt(cfg, m, z, depth - 1, inten, main, rng, out, mains);
bolt(cfg, BoltBranch { start: a, end: m, depth: depth - 1, intensity: inten, main }, rng, output);
bolt(cfg, BoltBranch { start: m, end: z, depth: depth - 1, intensity: inten, main }, rng, output);
if rng.f() < cfg.fork && inten > cfg.term && len >= cfg.min_fork {
let ang = rng.s() * 0.7;
let (s, c) = ang.sin_cos();
@@ -734,7 +749,7 @@ fn bolt(cfg: &BoltCfg, a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, mai
let k = 1.2;
// Fork Strength 50 % keeps the decayed intensity; Fork Variation randomises it.
let fi = inten * (1.0 - cfg.decay) * cfg.fork_strength * 2.0 * if cfg.fork_var > 0.0 { 1.0 - cfg.fork_var * rng.f() as f32 } else { 1.0 };
bolt(cfg, m, (m.0 + fx * k, m.1 + fy * k), depth.saturating_sub(1), fi.min(1.0), false, rng, out, mains);
bolt(cfg, BoltBranch { start: m, end: (m.0 + fx * k, m.1 + fy * k), depth: depth.saturating_sub(1), intensity: fi.min(1.0), main: false }, rng, output);
}
}
@@ -772,10 +787,9 @@ pub fn lightning_segments(ctx: &EffectCtx, b: &Buf) -> Vec<Seg> {
fork_strength: (g("expertSettings/forkStrength", 50.0) / 100.0).clamp(0.0, 1.0) as f32,
fork_var: (g("expertSettings/forkVariation", 0.0) / 100.0).clamp(0.0, 1.0) as f32,
};
let mut segs = Vec::new();
let mut mains = Vec::new();
let mut output = BoltOutput::default();
let (h, w) = (b.img.height as f64, b.img.width as f64);
let mut go = |a, z, depth, cfg: &BoltCfg, rng: &mut Rng| bolt(cfg, a, z, depth, 1.0, true, rng, &mut segs, &mut mains);
let mut go = |a, z, depth, cfg: &BoltCfg, rng: &mut Rng| bolt(cfg, BoltBranch { start: a, end: z, depth, intensity: 1.0, main: true }, rng, &mut output);
// LIGHTNING_TYPES order.
match kind {
// Direction: travels toward the Direction point and beyond.
@@ -811,6 +825,7 @@ pub fn lightning_segments(ctx: &EffectCtx, b: &Buf) -> Vec<Seg> {
// Strike.
_ => go(o, d, depth, &cfg, &mut rng),
}
let BoltOutput { segments: mut segs, mains } = output;
let dist = |p: (f64, f64)| ((p.0 - o.0).powi(2) + (p.1 - o.1).powi(2)).sqrt();
let far = segs.iter().map(|(_, z, _)| dist(*z)).fold(1e-9, f64::max);
// Decay Main Core: the whole bolt fades with distance from the origin.
+1 -1
View File
@@ -1,5 +1,5 @@
use super::raster::{Seg, mask_px, over, poly_segs, raster_segs};
use super::*;
use super::{Buf, EffectCtx, IndexedParallelIterator, IntoParallelRefMutIterator, PI, ParallelIterator, poly_length, premul};
// ---------------------------------------------------------------- Audio Spectrum / Waveform
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{EffectSpec, ParamUi, Value, slider};
pub(super) fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
EffectSpec { id, name, category: "Generate", params, render, gpu: false, float: true }
+4 -1
View File
@@ -1,5 +1,8 @@
use super::raster::over;
use super::*;
use super::{
Buf, EffectCtx, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, PI, ParallelIterator, Plane, Px, gauss_plane, hash1,
hsl_to_rgb, morph_frac, premul, rgb_to_hsl, smoothstep, unpremul,
};
pub(super) fn glue_gun(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
+1 -1
View File
@@ -1,5 +1,5 @@
use super::raster::over;
use super::*;
use super::{Buf, EffectCtx, IndexedParallelIterator, ParallelIterator, ParallelSliceMut, Px, hsl_to_rgb};
// ---------------------------------------------------------------- Fractal
+4 -1
View File
@@ -1,5 +1,8 @@
use super::raster::{Seg, all_masks_px, chaikin, mask_px, over, poly_segs, raster_convex, raster_segs, scan_runs, stroke_outline, trim_poly};
use super::*;
use super::{
Buf, EffectCtx, Image, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, ParallelIterator, Params, Plane, gauss_plane, hash1,
layer_or_self, luminance, poly_length, smoothstep, unpremul,
};
/// How painted coverage meets the layer: 0 On Original Image, 1 On Transparent, 2 Reveal
/// Original Image.
+1 -2
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Buf, EffectCtx, IndexedParallelIterator, ParallelIterator, ParallelSliceMut, Plane, Px, smoothstep};
// ---------------------------------------------------------------- shared rasterisation
@@ -147,7 +147,6 @@ pub(super) fn chaikin(pts: &[[f64; 2]], iters: usize) -> Vec<[f64; 2]> {
// ---------------------------------------------------------------- Scribble
/// Scan runs (start, end along direction `d`) of the fill region on the line `n·p = s`.
#[allow(clippy::too_many_arguments)]
pub(super) fn scan_runs(polys: &[(Vec<[f64; 2]>, bool, bool)], fill: u32, ew: f64, d: [f64; 2], n: [f64; 2], s: f64, out: &mut Vec<(f64, f64)>) {
out.clear();
for (pts, _closed, inverted) in polys {
+1 -1
View File
@@ -4,7 +4,7 @@ use super::fill::{eyedropper_fill, glue_gun, paint_bucket, threads};
use super::fractal::{FRACTAL_OVERSAMPLE, FRACTAL_PALETTES, FRACTAL_SETS, fractal};
use super::paths::{scribble, stroke, vegas, write_on};
use super::raster::{END_CAPS, JOINS};
use super::*;
use super::{EffectSpec, ParamUi, Value, col, num, p, popup, slider};
// ---------------------------------------------------------------- registry
+27 -3
View File
@@ -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 {
+311 -20
View File
@@ -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).
+28 -3
View File
@@ -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))];
+25 -12
View File
@@ -358,20 +358,24 @@ pub enum FrameMatte {
Outside,
}
/// The matte of layer frame `f` on the layer grid of `img` (the effect's input on that frame at
/// `scale`), computing what is missing. `frame(g)`: the input on frame g on the same grid.
#[allow(clippy::too_many_arguments)]
/// The layer grid and timing used to compute one object matte.
pub struct MatteFrame<'a> {
pub layer_size: [f64; 2],
pub fps: f64,
pub frame: i64,
pub scale: f64,
pub img: &'a Image,
}
/// Compute the object's alpha for one layer frame, loading other frames on the same grid as needed.
pub fn frame_matte(
params: &Params,
layer_size: [f64; 2],
fps: f64,
f: i64,
scale: f64,
img: &Image,
input: &MatteFrame<'_>,
model: Option<Arc<dyn MaskModel>>,
frame: &mut dyn FnMut(i64) -> Option<Arc<Image>>,
cancel: &dyn Fn() -> bool,
) -> FrameMatte {
let MatteFrame { layer_size, fps, frame: f, scale, img } = *input;
let Some(chain) = chain(params, layer_size, fps, f, scale, model) else { return FrameMatte::NoPrompt };
if !chain.data.in_span(f) {
return FrameMatte::Outside;
@@ -408,7 +412,7 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf {
if let Some(a) = frozen_alpha(ctx.params, layer_size, fps, f, w, h, model.as_deref()) {
return compose(&buf, Some(&a), w, h, b(ctx.params, "invert"), param(ctx.params, "view").map(Value::as_enum).unwrap_or(0) == 1);
}
let alpha = match frame_matte(ctx.params, layer_size, fps, f, sc, &img, model, &mut frame, &|| false) {
let alpha = match frame_matte(ctx.params, &MatteFrame { layer_size, fps, frame: f, scale: sc, img: &img }, model, &mut frame, &|| false) {
FrameMatte::Alpha(a) if a.len() == w * h => Some(a),
FrameMatte::NoPrompt => return buf,
_ => None,
@@ -608,7 +612,11 @@ mod tests {
assert_ne!(seed(&p, [100.0, 80.0], 25.0, Some(stub.as_ref())), seed(&p, [100.0, 80.0], 25.0, None));
let img = frame_at(0);
let mut frame = |g: i64| Some(Arc::new(frame_at(g)));
let FrameMatte::Alpha(a) = frame_matte(&p, [100.0, 80.0], 25.0, 0, 1.0, &img, Some(stub.clone()), &mut frame, &|| false) else { panic!("no matte") };
let FrameMatte::Alpha(a) =
frame_matte(&p, &MatteFrame { layer_size: [100.0, 80.0], fps: 25.0, frame: 0, scale: 1.0, img: &img }, Some(stub.clone()), &mut frame, &|| false)
else {
panic!("no matte")
};
assert!(a[40 * 100 + 40] > 0.5 && a[5 * 100 + 5] < 0.5);
assert_eq!(stub.calls.load(std::sync::atomic::Ordering::Relaxed), 1);
assert_eq!(*stub.bbox.lock().unwrap(), None);
@@ -634,7 +642,11 @@ mod tests {
let stub = Arc::new(Stub::default());
let img = frame_at(0);
let mut frame = |g: i64| Some(Arc::new(frame_at(g)));
let FrameMatte::Alpha(a) = frame_matte(&p, [100.0, 80.0], 25.0, 0, 1.0, &img, Some(stub.clone()), &mut frame, &|| false) else { panic!("no matte") };
let FrameMatte::Alpha(a) =
frame_matte(&p, &MatteFrame { layer_size: [100.0, 80.0], fps: 25.0, frame: 0, scale: 1.0, img: &img }, Some(stub.clone()), &mut frame, &|| false)
else {
panic!("no matte")
};
assert_eq!(*stub.bbox.lock().unwrap(), Some([74.0, 54.0, 100.0, 80.0]));
assert!(a[70 * 100 + 90] > 0.5 && a[40 * 100 + 40] < 0.5);
// At half resolution the box is scaled with the frame (another Input Key, so the
@@ -643,7 +655,8 @@ mod tests {
p.values.insert(INPUT_KEY.into(), Value::Str("half".into()));
let half = effectcraft_raster::resample(&img, 50, 40);
let mut frame = |g: i64| Some(Arc::new(effectcraft_raster::resample(&frame_at(g), 50, 40)));
let _ = frame_matte(&p, [100.0, 80.0], 25.0, 0, 0.5, &half, Some(stub.clone()), &mut frame, &|| false);
let _ =
frame_matte(&p, &MatteFrame { layer_size: [100.0, 80.0], fps: 25.0, frame: 0, scale: 0.5, img: &half }, Some(stub.clone()), &mut frame, &|| false);
assert_eq!(*stub.bbox.lock().unwrap(), Some([37.0, 27.0, 50.0, 40.0]));
}
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{ParamUi, slider};
// ---------------------------------------------------------------- ASC CDL
+1 -1
View File
@@ -1,7 +1,7 @@
use super::cdl::Cdl;
use super::lut::load_file;
use super::space::{ACES_CCT, ACES_CG, ACES2065_1, D65, IDENTITY, P3, REC709, REC2020, SPACES, Space, Tf, Xform, matrix, sp, space_names};
use super::*;
use super::{Buf, ColorSpace, EffectCtx, EffectSpec, IntoParallelRefMutIterator, ParallelIterator, Value, invert, mul, mul_vec, p, popup, unpremul};
// ---------------------------------------------------------------- displays, views and looks
+1 -1
View File
@@ -1,5 +1,5 @@
use super::cdl::{Cdl, parse_cdl_xml};
use super::*;
use super::{Arc, HashMap, Lut, Mutex, OnceLock, parse_cube};
// ---------------------------------------------------------------- LUT files
+1 -1
View File
@@ -2,7 +2,7 @@ use super::cdl::Cdl;
use super::effects::{DISPLAYS, cdl_from_params, custom_xf, looks, parse_looks, profile, space_at};
use super::lut::{FileXform, load_file};
use super::space::{ACES_CCT, D65, IDENTITY, Space, Tf, Xform, matrix};
use super::*;
use super::{Arc, EffectCtx, Lut, invert, mul};
// ---- Colour programs (the GPU compositor's colour-management kernels)
+1 -1
View File
@@ -4,7 +4,7 @@ use super::effects::{
space_param, spec,
};
use super::space::SPACES;
use super::*;
use super::{EffectSpec, ParamUi, Value, num, p, popup, slider};
pub fn specs() -> Vec<EffectSpec> {
let cc = "Color Correction";
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{invert, mul, mul_vec, rgb_to_xyz};
pub(super) type M3 = [[f64; 3]; 3];
+1 -1
View File
@@ -634,7 +634,7 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf {
let (w, h) = (img.width as usize, img.height as usize);
let mp = matte_params(ctx.params);
let mut rgb = rb::rgb_of(&img);
let map = refine::decontaminate(&mut rgb, &alpha, &band, w, h, &mp, rr * sc, sc);
let map = refine::decontaminate(&mut rgb, &alpha, &band, (w, h), &mp, rr * sc, sc);
let view_map = b(ctx.params, "refineEdgeMatte/decontamination/viewDecontaminationMap");
let (ox, oy) = (buf.offset[0].round() as i64, buf.offset[1].round() as i64);
for y in 0..h {
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{EffectSpec, ParamUi, Value, hash1, slider};
pub(crate) fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
EffectSpec { id, name, category: "Simulation", params, render, gpu: false, float: true }
+3 -2
View File
@@ -1,7 +1,7 @@
use super::common::{h, hs};
use super::particle_fx::{PS2_CACHE, PW_CACHE, ps2_phys, pw_phys};
use super::sprites::{Shape, Sprite};
use super::*;
use super::{Buf, EffectCtx, Image, IntoParallelRefMutIterator, ParallelIterator, SimCache, params_key_except, value_noise};
// ------------------------------------------------------------------ particle engine
@@ -263,7 +263,8 @@ pub(super) fn particle_look(q: &Particle, birth: [f32; 4], death: [f32; 4], bsiz
(c, size.max(0.0))
}
pub(super) fn type_sprite(kind: u32, x: f32, y: f32, r: f32, c: [f32; 4], vx: f32, vy: f32, rot: f32) -> Sprite {
pub(super) fn type_sprite(kind: u32, x: f32, y: f32, r: f32, c: [f32; 4], velocity: [f32; 2], rot: f32) -> Sprite {
let [vx, vy] = velocity;
let vl = (vx * vx + vy * vy).sqrt().max(1e-6);
let shape = match kind {
0 => Shape::Line { dx: -vx / vl * r * 2.0, dy: -vy / vl * r * 2.0 },
+3 -3
View File
@@ -1,6 +1,6 @@
use super::engine::{PState, PW_EFFECT_CAMERA, Phys, accelerated, norm3, particle_look, sim_key, simulate, type_sprite};
use super::sprites::{Acc, Post, Sprite, SpritePlan, raster, splat};
use super::*;
use super::{Buf, EffectCtx, Image, IntoParallelRefIterator, ParallelIterator, SimCache, Value, params_key, unpremul};
// ------------------------------------------------------------------ CC Particle World
@@ -169,7 +169,7 @@ pub(super) fn particle_world_plan(ctx: &EffectCtx, b: &Buf) -> Option<SpritePlan
let PwSeen { depth, x, y, k, v } = cam.see(q.p, q.v, lw, lh, b)?;
let (c, size) = particle_look(q, birth, death, bs, ds, svar, max_op);
let r = size * 0.5 * lw * k * s * 0.25;
let sp = type_sprite(kind, x, y, r, c, v[0] * lw * k * s, v[1] * lw * k * s, (q.id % 628) as f32 * 0.01 + q.age * 2.0);
let sp = type_sprite(kind, x, y, r, c, [v[0] * lw * k * s, v[1] * lw * k * s], (q.id % 628) as f32 * 0.01 + q.age * 2.0);
Some((depth, sp))
})
.collect();
@@ -242,7 +242,7 @@ pub(super) fn particle_systems2_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan {
let (bx, by) = b.to_px([(q.p[0] * unit) as f64, (q.p[1] * unit) as f64]);
let (c, size) = particle_look(q, birth, death, bs, ds, svar, max_op);
let r = size * unit * 0.02 * s;
type_sprite(kind, bx as f32, by as f32, r, c, q.v[0] * unit * s, q.v[1] * unit * s, (q.id % 628) as f32 * 0.01 + q.age * 2.0)
type_sprite(kind, bx as f32, by as f32, r, c, [q.v[0] * unit * s, q.v[1] * unit * s], (q.id % 628) as f32 * 0.01 + q.age * 2.0)
})
.collect();
particle_sprites(sprites, pr.e("particle/transferMode"))
+1 -1
View File
@@ -3,7 +3,7 @@ use super::engine::{PARTICLE_TYPES, PW_ANIMS};
use super::particle_fx::{PW_FOV, mr_mercury, particle_systems2, particle_systems2_plan, particle_world, particle_world_plan};
use super::sprites::SpritePlan;
use super::weather::{bubbles, drizzle, rainfall, rainfall_plan, snowfall, snowfall_plan, star_burst, star_burst_plan};
use super::*;
use super::{Buf, EffectCtx, EffectSpec, ParamUi, Value, col, num, p, popup, slider};
/// The sprite plan of a sprite-drawing simulation effect for buffer geometry `b` (its pixels
/// are not read), `None` for other effects or settings drawn otherwise (the GPU compositor's
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Buf, Image, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, ParallelIterator, ParallelSliceMut, Px, unpremul};
// ------------------------------------------------------------------ band rasteriser
+1 -1
View File
@@ -1,6 +1,6 @@
use super::common::{h, hs};
use super::sprites::{Acc, Post, Shape, Sprite, SpritePlan, Tint, aa, raster};
use super::*;
use super::{Buf, EffectCtx, Image, IntoParallelIterator, ParallelIterator, unpremul};
// ------------------------------------------------------------------ CC Rainfall / CC Snowfall
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Acc, Buf, EffectCtx, Image, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, ParallelIterator, Plane, Px, Shape, SimCache,
Sprite, Value, hs, params_key, splat, unpremul, value_noise,
};
// ------------------------------------------------------------------ Foam
+1 -1
View File
@@ -1,5 +1,5 @@
use super::shatter::{ShatterPlan, card_dance_plan, shatter_impl};
use super::*;
use super::{Acc, Buf, EffectCtx, Image, Lighting, Proj, raster, unpremul};
// ------------------------------------------------------------------ 3D pieces
+1 -1
View File
@@ -1,6 +1,6 @@
use super::pieces::{Cam, Piece, PiecePlan, PieceTex, rot_axis, sort_far_first};
use super::shatter::{ShatterPlan, shatter_impl};
use super::*;
use super::{Acc, Buf, EffectCtx, IntoParallelIterator, ParallelIterator, Post, Shape, Sprite, SpritePlan, h, hs, unpremul};
// ------------------------------------------------------------------ CC Ball Action
+1 -1
View File
@@ -2,7 +2,7 @@ use super::foam::{FOAM_TEXTURES, foam};
use super::pixels::{ball_action, pixel_polly, scatterize};
use super::shatter::{CD_PROPS, CD_SOURCES, SHATTER_PATTERNS, SHATTER_TEXTURE_MODES, card_dance, shatter};
use super::surfaces::{caustics, wave_world};
use super::*;
use super::{EffectSpec, ParamUi, Value, col, num, p, pct, popup, pt, slider, spec};
// ------------------------------------------------------------------ specs
+4 -1
View File
@@ -1,5 +1,8 @@
use super::pieces::{M3, Piece, PiecePlan, PieceTex, inv3, rot_axis, rot_xyz, sort_far_first};
use super::*;
use super::{
Acc, Buf, EffectCtx, Image, IndexedParallelIterator, IntoParallelIterator, IntoParallelRefIterator, Lighting, ParallelIterator, Post, Shape, Sprite,
SpritePlan, Value, h, hs, layer_or_self, unpremul,
};
// ------------------------------------------------------------------ Card Dance
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Acc, Buf, EffectCtx, Image, IntoParallelIterator, ParallelIterator, Plane, Px, Shape, SimCache, Sprite, Value, gauss_plane, params_key, unpremul};
// ------------------------------------------------------------------ Caustics
+3 -2
View File
@@ -575,7 +575,8 @@ fn mapper_from(ctx: &EffectCtx, group: &str, ephemeral: bool) -> Option<Mapper>
/// Draw a map layer frame as a particle: centred on (x, y) buffer px, rotated `angle` degrees
/// and scaled, over `out`.
fn blit_layer(out: &mut Image, lp: &crate::LayerPixels, x: f64, y: f64, angle: f64, scale: [f64; 2], buf_scale: f64, alpha: f32) {
fn blit_layer(out: &mut Image, lp: &crate::LayerPixels, position: [f64; 2], angle: f64, scale: [f64; 2], buf_scale: f64, alpha: f32) {
let [x, y] = position;
let (w, h) = (lp.size[0] * scale[0].abs() * buf_scale, lp.size[1] * scale[1].abs() * buf_scale);
let r = (w * w + h * h).sqrt() * 0.5 + 1.0;
let (x0, x1) = ((x - r).floor().max(0.0) as i64, (x + r).ceil().min(out.width as f64) as i64);
@@ -925,7 +926,7 @@ impl PgPlan {
pub fn finish(&self, mut b: Buf) -> Buf {
let mut fx = splat(b.img.width, b.img.height, &self.sprites.sprites, self.sprites.acc);
for q in &self.blits {
blit_layer(&mut fx, &self.frames[q.frame], q.x, q.y, q.angle, q.scale, b.scale, q.alpha);
blit_layer(&mut fx, &self.frames[q.frame], [q.x, q.y], q.angle, q.scale, b.scale, q.alpha);
}
// Particles replace the layer's own pixels.
b.img = combine(&b.img, &fx, 5);
+11 -14
View File
@@ -453,7 +453,7 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf {
});
let need = need_m.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner);
if synth && need.iter().any(|w| *w > 0.0) {
synthesize(ctx, &a, k, &src_of, &crop, sc, &mut out, &need);
synthesize(ctx, &a, k, &SynthesisGrid { src_of: &src_of, crop: &crop, scale: sc }, &mut out, &need);
}
if b(ctx.params, "advanced/showTrackPoints") {
let r = (2.5 * f(ctx.params, "advanced/trackPointSize") / 100.0 * sc).max(0.5);
@@ -465,19 +465,16 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf {
Buf { img: out, offset: [0.0; 2], scale: sc }
}
/// Fill the pixels flagged in `need` (weight = how much synthesized content they take) from
/// neighbouring frames, nearest first.
#[allow(clippy::too_many_arguments)]
fn synthesize(
ctx: &EffectCtx,
a: &WarpAnalysis,
k: usize,
src_of: &(dyn Fn([f64; 2]) -> [f64; 2] + Sync),
crop: &[f64; 4],
sc: f64,
out: &mut Image,
need: &[f32],
) {
/// Maps an output sample into the uncropped source grid shared by neighbouring frames.
struct SynthesisGrid<'a> {
src_of: &'a (dyn Fn([f64; 2]) -> [f64; 2] + Sync),
crop: &'a [f64; 4],
scale: f64,
}
/// Fill the pixels flagged in `need` from neighbouring frames, nearest first.
fn synthesize(ctx: &EffectCtx, a: &WarpAnalysis, k: usize, grid: &SynthesisGrid<'_>, out: &mut Image, need: &[f32]) {
let SynthesisGrid { src_of, crop, scale: sc } = *grid;
let Some(host) = ctx.env.host else { return };
let n = a.frames.len();
let range = ((f(ctx.params, "advanced/synthesizeInputRange").max(0.0) / a.frame_duration.max(1e-6)).round() as usize).min(60);
+12 -1
View File
@@ -450,7 +450,18 @@ async fn run_work(w: Work, shared: &RotoShared) {
(RotoTask::Propagate, true) => om::ensure(&w.params, w.size, w.fps, *f, model.clone(), &mut get, &cancel).is_some(),
(RotoTask::Freeze, true) => {
let img = get(*f);
match img.map(|img| (om::frame_matte(&w.params, w.size, w.fps, *f, 1.0, &img, model.clone(), &mut get, &cancel), img)) {
match img.map(|img| {
(
om::frame_matte(
&w.params,
&om::MatteFrame { layer_size: w.size, fps: w.fps, frame: *f, scale: 1.0, img: &img },
model.clone(),
&mut get,
&cancel,
),
img,
)
}) {
Some((om::FrameMatte::Alpha(a), img)) => {
frozen.size = [img.width as usize, img.height as usize];
frozen.frames.insert(*f, fx::freeze_frame(&a, &[]));
+3 -1
View File
@@ -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;
@@ -156,7 +158,7 @@ mod enabled;
mod params;
mod schema;
use super::*;
use super::commands;
pub use enabled::always;
pub(crate) use enabled::{has_comp, has_keys, has_layers, has_project_selection, not_yet, not_yet_run};
pub use params::match_path_of;
+8 -2
View 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();
+2
View File
@@ -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
View File
@@ -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),
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{EngineError, Result, Session, Value};
// ---------- enablement ----------
+33 -2
View File
@@ -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
),
+4 -1
View File
@@ -1,5 +1,8 @@
use super::create::{apply_text_params, text_range_p};
use super::*;
use super::{
EngineError, GroupKind, Ids, ItemId, KV, LayerId, MaskMode, Node, Project, PropGroup, Result, Session, TextDoc, Value, b_p, bad, build, json, layer_mut,
layer_p, merge_p, str_p,
};
pub(super) fn add_mask(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "layer.addMask")?;
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
EngineError, Ids, ItemId, ItemKind, Justify, KV, Label, Layer, LayerId, LayerSource, Project, Result, Session, Solid, TextDoc, Tick, Value, b_p, bad,
build, comp_id, f_p, json, limits, str_p,
};
pub(crate) fn color_p(p: &Value, k: &str) -> Option<[f32; 3]> {
match p.get(k)? {
+2 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Comp, EngineError, ItemId, KV, Layer, LayerId, PropGroup, Result, Session, Tick, Value, b_p, bad, layers_p, resolve_layer};
/// Re-express a layer in its new parent space without moving its anchor.
pub(crate) struct ParentFix {
@@ -336,6 +336,7 @@ pub(super) fn set_parent(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)]
mod parent_chain_tests {
use super::super::json;
use super::*;
use std::sync::Arc;
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
EngineError, FootageKind, Ids, ItemId, ItemKind, Layer, LayerSource, Project, Result, Session, Value, bad, build, has_comp, json, layer_mut, layers_p,
unlocked,
};
/// A layer whose source is an item (footage, precomposition or solid), as in After Effects'
/// Replace Source. Text, shape, null, camera, light and 3D model layers have no AVItem source to
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{EngineError, LayerId, Result, Session, Value, b_p, comp_id, json, resolve_layer};
pub(super) fn select(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
BlendMode, EngineError, FrameBlend, ItemId, KV, Layer, LayerId, MatteKind, Quality, Result, Session, TrackMatte, Value, b_p, bad, json, layer_mut, layer_p,
layers_p, merge_p, resolve_layer, str_p,
};
pub(super) fn set_switch(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
EngineError, ItemKind, Label, Layer, LayerId, LayerSource, Result, Session, Tick, Value, b_p, bad, build, f_p, json, layer_mut, layers_p, merge_p,
resolve_layer, str_p, unlocked, with,
};
pub(super) fn timing(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
+4 -1
View File
@@ -1,5 +1,8 @@
use super::create::{color_p, pixel_aspect_p};
use super::*;
use super::{
EngineError, ItemKind, KV, LayerId, LayerSource, PropGroup, Result, Session, Tick, Value, bad, has_layers, json, layer_mut, layer_p, layers_p, limits,
str_p, unlocked,
};
/// Move Position by `(dx, dy)`, including every key. Separated dimensions move X and Y as well
/// as the combined property the channels were split from.
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Comp, EngineError, ItemId, Layer, LayerId, Result, Session, Tick, Value};
/// Hand a frontend-only command to the UI ([`crate::Event::Frontend`]).
pub(crate) fn frontend(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
+10 -10
View File
@@ -121,16 +121,16 @@ fn parametric_path(g: &PropGroup, lt: effectcraft_time::Tick) -> Option<ShapePat
let path = match g.match_id.as_str() {
"rect" => effectcraft_path::rect(v2("size", [100.0; 2]), v2("position", [0.0; 2]), f("roundness", 0.0)),
"ellipse" => effectcraft_path::ellipse(v2("size", [100.0; 2]), v2("position", [0.0; 2])),
"star" => effectcraft_path::polystar(
g.prop("type").map_or(0, |pr| pr.value_at(lt).as_enum()) == 0,
f("points", 5.0),
v2("position", [0.0; 2]),
f("rotation", 0.0),
f("innerRadius", 50.0),
f("outerRadius", 100.0),
f("innerRoundness", 0.0),
f("outerRoundness", 0.0),
),
"star" => effectcraft_path::polystar(effectcraft_path::Polystar {
star: g.prop("type").map_or(0, |pr| pr.value_at(lt).as_enum()) == 0,
points: f("points", 5.0),
pos: v2("position", [0.0; 2]),
rotation: f("rotation", 0.0),
inner: f("innerRadius", 50.0),
outer: f("outerRadius", 100.0),
inner_round: f("innerRoundness", 0.0),
outer_round: f("outerRoundness", 0.0),
}),
_ => return None,
};
// A closed parametric shape is one subpath; anything else would not be this shape.
+50 -32
View File
@@ -205,23 +205,8 @@ fn apply_om_template(om: &mut OutputModule, t: &OutputModule) {
/// The `format` parameter's values.
const FORMATS: &str = "format: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff";
/// Apply Output Module parameters (`template` first). `roi`: the viewer's region of interest
/// (captured by `crop.useRoi`).
fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<[f64; 4]>, p: &Value, cmd: &str) -> Result<bool> {
let mut any = false;
if let Some(t) = str_p(p, "template") {
let t = templates.output_module(t).ok_or_else(|| bad(cmd, format!("no Output Module template “{t}”")))?;
apply_om_template(om, &t);
any = true;
}
let before = om.clone();
if let Some(f) = str_p(p, "format") {
let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, FORMATS))?;
om.set_format(f);
}
// Before Channels: the WebM codec decides whether alpha can be written, so an explicit
// `channels: rgba` with `webmCodec: av1` is refused rather than dropped (#166).
any |= apply_codec_options(om, p, cmd)?;
/// `channels` (refusing RGBA where the format has no alpha) and `color`/`alphaMode`.
fn apply_channels_and_alpha(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<()> {
if let Some(c) = str_p(p, "channels") {
om.channels = match c.to_ascii_lowercase().replace([' ', '+'], "").as_str() {
"rgb" => Channels::Rgb,
@@ -244,21 +229,11 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
_ => return Err(bad(cmd, "color: straight|premultiplied")),
};
}
if let Some(q) = f_p(p, "quality") {
om.quality = q.clamp(1.0, 100.0) as u8;
}
if let Some(b) = f_p(p, "bitrate") {
om.bitrate_kbps = b.clamp(50.0, 500_000.0) as u32;
}
if let Some(b) = b_p(p, "webmBitrate") {
om.webm_bitrate = b;
}
if let Some(k) = p.get("keyframeInterval").and_then(Value::as_u64) {
om.keyframe_interval = k.min(100_000) as u32;
}
if let Some(pp) = str_p(p, "proresProfile") {
om.prores_profile = ProResProfile::from_name(pp).ok_or_else(|| bad(cmd, "proresProfile: proxy|lt|standard|hq|4444|4444xq"))?;
}
Ok(())
}
/// `crop` plus the flat dialog keys (`cropTop`… and `resizeWidth`/`resizeHeight`).
fn apply_crop(om: &mut OutputModule, roi: Option<[f64; 4]>, p: &Value, cmd: &str) -> Result<()> {
match p.get("crop") {
Some(Value::Bool(b)) => om.crop.enabled = *b,
Some(Value::Object(c)) => {
@@ -298,6 +273,11 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
om.resize.enabled = true;
}
}
Ok(())
}
/// `resize` (bool or object).
fn apply_resize(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<()> {
match p.get("resize") {
Some(Value::Bool(b)) => om.resize.enabled = *b,
Some(Value::Object(r)) => {
@@ -328,6 +308,44 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
Some(Value::Null) | None => {}
Some(_) => return Err(bad(cmd, "resize: bool or {enabled?, preset?, width?, height?, lockAspect?, quality?: low|high}")),
}
Ok(())
}
/// Apply Output Module parameters (`template` first). `roi`: the viewer's region of interest
/// (captured by `crop.useRoi`).
fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<[f64; 4]>, p: &Value, cmd: &str) -> Result<bool> {
let mut any = false;
if let Some(t) = str_p(p, "template") {
let t = templates.output_module(t).ok_or_else(|| bad(cmd, format!("no Output Module template “{t}”")))?;
apply_om_template(om, &t);
any = true;
}
let before = om.clone();
if let Some(f) = str_p(p, "format") {
let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, FORMATS))?;
om.set_format(f);
}
// Before Channels: the WebM codec decides whether alpha can be written, so an explicit
// `channels: rgba` with `webmCodec: av1` is refused rather than dropped (#166).
any |= apply_codec_options(om, p, cmd)?;
apply_channels_and_alpha(om, p, cmd)?;
if let Some(q) = f_p(p, "quality") {
om.quality = q.clamp(1.0, 100.0) as u8;
}
if let Some(b) = f_p(p, "bitrate") {
om.bitrate_kbps = b.clamp(50.0, 500_000.0) as u32;
}
if let Some(b) = b_p(p, "webmBitrate") {
om.webm_bitrate = b;
}
if let Some(k) = p.get("keyframeInterval").and_then(Value::as_u64) {
om.keyframe_interval = k.min(100_000) as u32;
}
if let Some(pp) = str_p(p, "proresProfile") {
om.prores_profile = ProResProfile::from_name(pp).ok_or_else(|| bad(cmd, "proresProfile: proxy|lt|standard|hq|4444|4444xq"))?;
}
apply_crop(om, roi, p, cmd)?;
apply_resize(om, p, cmd)?;
if let Some(a) = p.get("audio") {
om.audio = match a {
Value::Bool(true) => AudioOutput::On,
+1 -1
View File
@@ -1,5 +1,5 @@
use super::params::bad;
use super::*;
use super::{CommandSpec, Result, Value};
// ---------- parameter validation (agents) ----------
+176 -3
View File
@@ -237,13 +237,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.
@@ -555,6 +559,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).
+10 -1
View File
@@ -221,7 +221,16 @@ pub fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option<ShapePa
"rounded" | "roundedRect" => effectcraft_path::rect(size, centre, roundness(size)),
k => {
let (star, points, outer, inner) = polystar_of(k, size)?;
effectcraft_path::polystar(star, points, centre, 0.0, inner, outer, 0.0, 0.0)
effectcraft_path::polystar(effectcraft_path::Polystar {
star,
points,
pos: centre,
rotation: 0.0,
inner,
outer,
inner_round: 0.0,
outer_round: 0.0,
})
}
};
effectcraft_path::from_kurbo(&bez).into_iter().next()
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Deserialize, ItemId, Layer, LayerId, Serialize, Tick, Uid, commands, mask_track, media_browser, roto, viewer};
/// A keyframe reference: layer, property uid, key time (layer time).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{ItemId, Serialize, Value};
/// Events for frontends (drained each frame).
#[derive(Clone, Debug, PartialEq, Serialize)]
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::prefs;
pub fn text_families() -> Vec<String> {
effectcraft_text::families().into_iter().map(|(f, _)| f).collect()
+2
View File
@@ -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
}
}
+17 -5
View File
@@ -130,8 +130,20 @@ fn solid_sized(p: &mut Project, comp: &Comp, folder: ItemId, name: &str, color:
build::layer(p, comp, name, LayerSource::Solid { item: sid }, (w, h), None)
}
/// What `card` draws: name, gradient end colors (hex), size, position and Y rotation.
#[derive(Clone, Copy)]
struct CardSpec<'a> {
name: &'a str,
a: &'a str,
b: &'a str,
size: (u32, u32),
pos: [f64; 3],
rot_y: f64,
}
/// A 3D card: a solid with a diagonal gradient and a thin border grid.
fn card(p: &mut Project, comp: &Comp, folder: ItemId, name: &str, a: &str, b: &str, size: (u32, u32), pos: [f64; 3], rot_y: f64) -> Layer {
fn card(p: &mut Project, comp: &Comp, folder: ItemId, spec: &CardSpec) -> Layer {
let CardSpec { name, a, b, size, pos, rot_y } = *spec;
let mut l = solid_sized(p, comp, folder, name, [1.0, 1.0, 1.0], size.0, size.1);
let (w, h) = (size.0 as f64, size.1 as f64);
effect(
@@ -192,11 +204,11 @@ fn showcase_3d(p: &mut Project, solids: ItemId) -> Comp {
set(&mut floor, "materialOptions/specularIntensity", Value::Scalar(10.0));
// Cards at different depths and angles; the centre pair intersects.
let left = card(p, &c, solids, "Card Blue", "#2E7BF0", "#7A4DFF", (520, 340), [520.0, 560.0, 120.0], 28.0);
let right = card(p, &c, solids, "Card Coral", "#FF6A5C", "#FFB347", (520, 340), [1420.0, 560.0, 260.0], -32.0);
let mut x1 = card(p, &c, solids, "Cross A", "#1FD1A5", "#2E7BF0", (460, 460), [960.0, 470.0, 520.0], 40.0);
let left = card(p, &c, solids, &CardSpec { name: "Card Blue", a: "#2E7BF0", b: "#7A4DFF", size: (520, 340), pos: [520.0, 560.0, 120.0], rot_y: 28.0 });
let right = card(p, &c, solids, &CardSpec { name: "Card Coral", a: "#FF6A5C", b: "#FFB347", size: (520, 340), pos: [1420.0, 560.0, 260.0], rot_y: -32.0 });
let mut x1 = card(p, &c, solids, &CardSpec { name: "Cross A", a: "#1FD1A5", b: "#2E7BF0", size: (460, 460), pos: [960.0, 470.0, 520.0], rot_y: 40.0 });
set(&mut x1, "materialOptions/lightTransmission", Value::Scalar(30.0));
let x2 = card(p, &c, solids, "Cross B", "#F0E14A", "#FF6A5C", (460, 460), [960.0, 470.0, 520.0], -40.0);
let x2 = card(p, &c, solids, &CardSpec { name: "Cross B", a: "#F0E14A", b: "#FF6A5C", size: (460, 460), pos: [960.0, 470.0, 520.0], rot_y: -40.0 });
// Hero title in 3D space, gently turning.
let mut title = text_layer(
p,
+8
View File
@@ -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![],
+25 -9
View File
@@ -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"}
+8 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::config;
/// Host services (file access) injected by the frontend.
pub trait Services: Send + Sync {
@@ -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.
+3 -1
View File
@@ -1,7 +1,9 @@
use super::doc_state::{CompSelection, EditorState};
use super::error::{EngineError, Result};
use super::event::Event;
use super::*;
use super::{
Arc, Comp, History, Image, ItemId, LayerId, Project, RenderOpts, Renderer, Session, ThumbnailJob, Tick, Value, camera_track, commands, guard, roto, warp,
};
mod comps;
mod dispatch;
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Comp, CompSelection, Event, ItemId, LayerId, Result, Session, Tick, Value, commands};
/// `t` as a current time of `comp`: a frame within its duration.
fn comp_frame_at(comp: &Comp, t: Tick) -> Tick {
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{EngineError, Event, Result, Session, Value, commands, guard};
impl Session {
pub fn new() -> Session {
+15 -4
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Arc, EditorState, EngineError, Event, History, ItemId, Project, Result, Session, camera_track, commands, roto, warp};
impl Session {
/// Apply an undoable edit. `merge`: consecutive edits with the same key fold into one step.
@@ -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 };
+44 -3
View File
@@ -1,4 +1,4 @@
use super::*;
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();
}
}
+5 -1
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
use super::choices::{APPEARANCE_MODES, DARK_THEMES, LANGUAGES, LIGHT_THEMES, WINDOW_GRAPHICS};
use super::*;
use super::{Value, json};
// ---------------------------------------------------------------- schema (Settings dialog)
+12 -2
View File
@@ -1,12 +1,14 @@
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::*;
use super::{Appearance, General, Map, PREFS_VERSION, Prefs, Startup, Value, json};
/// Bring an older settings document up to [`PREFS_VERSION`].
///
/// - 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 -1
View File
@@ -1,5 +1,5 @@
use super::sections::parse_hex;
use super::*;
use super::{Label, Prefs, Serialize, Tick};
impl Prefs {
/// Display name of a label (Labels settings).
+31 -1
View File
@@ -1,4 +1,6 @@
use super::*;
use serde::{Deserialize, Serialize};
use super::Prefs;
impl Prefs {
/// Remember a project in File ▸ Open Recent.
@@ -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).
+2 -2
View File
@@ -1,5 +1,5 @@
use super::choices::theme_family;
use super::*;
use super::{BTreeMap, Deserialize, Label, Serialize, Value};
/// Unknown keys inside a page, kept so a newer version's settings survive a round trip.
pub(super) type Extra = BTreeMap<String, Value>;
@@ -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(),
+25 -1
View File
@@ -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
+15
View File
@@ -106,6 +106,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;
+126 -22
View File
@@ -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();
+71 -45
View File
@@ -3,7 +3,7 @@
use effectcraft_effects::camera_tracker as ct;
use effectcraft_geom::vec3;
use effectcraft_project::LayerId;
use effectcraft_project::{Comp, Layer, LayerId, PropGroup};
use effectcraft_raster::Image;
use effectcraft_render::EvalCtx;
use effectcraft_render::three_d::camera::active_camera;
@@ -11,6 +11,7 @@ use effectcraft_track::camtrack::linalg::{self, M3, V3};
use serde_json::{Value, json};
use crate::Session;
use crate::camera_track::Placed;
use crate::tests::track::{H, W, frame_time, setup, texture};
const F: f64 = 260.0;
@@ -77,10 +78,9 @@ fn status(s: &mut Session) -> Value {
s.execute("camera.solveStatus", json!({})).unwrap()
}
#[test]
fn track_camera_solves_and_creates_layers_on_the_plane() {
let (mut s, clip) = tracked(json!({}));
let st = status(&mut s);
/// Solved with a sane error and focal length; returns the Camera Tracker effect group.
fn assert_solved(s: &mut Session, clip: LayerId) -> PropGroup {
let st = status(s);
assert_eq!(st["solved"], true, "{st}");
let err = st["averageError"].as_f64().unwrap();
assert!(err < 0.5, "average error {err}");
@@ -90,9 +90,12 @@ fn track_camera_solves_and_creates_layers_on_the_plane() {
// Method Used / Average Error are reported in the effect.
let g = s.active_comp().unwrap().layer(clip).unwrap().effects().unwrap().groups().next().unwrap().clone();
assert!(matches!(g.prop(ct::METHOD_USED).map(|p| &p.value), Some(effectcraft_keyframe::Value::Str(m)) if !m.is_empty()));
g
}
// Pick floor points on frame 12 (their 3D positions lie on y = 1.5 in the true scene: the
// ones seen below the horizon at the bottom of the frame).
/// Pick floor points on frame 12 (their 3D positions lie on y = 1.5 in the true scene: the
/// ones seen below the horizon at the bottom of the frame).
fn select_floor_points(s: &mut Session) -> Vec<u64> {
s.execute("time.set", json!({"time": frame_time(12).seconds()})).unwrap();
let pts = s.execute("camera.points", json!({})).unwrap();
let list = pts["points"].as_array().unwrap();
@@ -101,32 +104,19 @@ fn track_camera_solves_and_creates_layers_on_the_plane() {
assert!(floor.len() >= 3, "{floor:?}");
s.execute("camera.selectPoints", json!({"points": floor})).unwrap();
assert_eq!(s.state.camera_points.len(), floor.len());
floor
}
// Create Solid and Camera: one undo step, camera + solid.
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "solid"})).unwrap();
let cam = LayerId(r["camera"].as_u64().unwrap());
let solid = LayerId(r["layers"][0].as_u64().unwrap());
let comp = s.active_comp().unwrap().clone();
assert!(comp.layer(cam).unwrap().is_camera());
assert_eq!(comp.layer(cam).unwrap().name, "3D Tracker Camera");
assert_eq!(comp.layers[0].id, cam);
let sl = comp.layer(solid).unwrap();
assert!(sl.switches.three_d);
assert!(sl.name.starts_with("Track Solid"));
// The camera is keyed on every frame.
assert_eq!(comp.layer(cam).unwrap().props.prop("transform/position").unwrap().keys.len(), 25);
// Every solved point projects through the created camera exactly where the solve puts it,
// and onto its tracked position (the footage fills the comp, so layer pixels are comp
// pixels): within 1 px for 95 % of the observations (the rest are the solve's outliers:
// features on occlusion edges), 0.5 px on average.
/// Every solved point projects through the created camera exactly where the solve puts it,
/// and onto its tracked position (the footage fills the comp, so layer pixels are comp
/// pixels): within 1 px for 95 % of the observations (the rest are the solve's outliers:
/// features on occlusion edges), 0.5 px on average.
fn assert_reprojection(s: &Session, comp: &Comp, pl: &Placed, g: &PropGroup) {
let cid = s.active_comp_id().unwrap();
let uid = g.uid;
let pl = crate::camera_track::placed(&s.project, cid, clip, uid).unwrap();
let tracks = ct::tracks(&crate::camera_track::static_params(&g)).unwrap();
let tracks = ct::tracks(&crate::camera_track::static_params(g)).unwrap();
let mut errs = vec![];
for k in [0u32, 6, 12, 18, 24] {
let ctx = EvalCtx::new(&s.project, cid, &comp, frame_time(k));
let ctx = EvalCtx::new(&s.project, cid, comp, frame_time(k));
let camst = active_camera(&ctx);
for sp in pl.solve.visible(k as usize) {
let Some(obs) = tracks.track(sp.id).and_then(|t| t.at(k)) else { continue };
@@ -142,34 +132,31 @@ fn track_camera_solves_and_creates_layers_on_the_plane() {
let p95 = errs[errs.len() * 95 / 100];
let mean = errs.iter().sum::<f64>() / errs.len() as f64;
assert!(p95 < 1.0 && mean < 0.5, "p95 {p95} px, mean {mean} px");
}
// The solid lies on the selected points' plane.
/// The solid lies on the selected points' plane and faces the camera.
fn assert_solid_on_plane(s: &Session, comp: &Comp, sl: &Layer, pl: &Placed, size: f64) {
let cid = s.active_comp_id().unwrap();
let tr = sl.props.sub("transform").unwrap();
let pos = tr.get("position").unwrap().value.as_vec3();
let ori = tr.get("orientation").unwrap().value.as_vec3();
let normal = linalg::mv(&linalg::from_euler_xyz(ori), [0.0, 0.0, -1.0]);
let size = r["target"]["size"].as_f64().unwrap();
for id in &s.state.camera_points {
let w = pl.to_world(pl.solve.point(*id).unwrap().pos);
let d = linalg::dot(linalg::sub(w, pos), normal).abs();
assert!(d < 0.05 * size.max(1.0) + 1.0, "point {id} is {d} px off the plane");
}
// The solid faces the camera.
let ctx = EvalCtx::new(&s.project, cid, &comp, frame_time(12));
let ctx = EvalCtx::new(&s.project, cid, comp, frame_time(12));
let eye = active_camera(&ctx).eye;
assert!(linalg::dot(normal, linalg::sub([eye.x, eye.y, eye.z], pos)) > 0.0);
}
// Undo removes both layers; redo brings them back.
let before = comp.layers.len();
assert!(s.undo());
assert_eq!(s.active_comp().unwrap().layers.len(), before - 2);
assert!(s.redo());
assert_eq!(s.active_comp().unwrap().layers.len(), before);
// A second create reuses the camera: Create Multiple Nulls and Camera.
/// A second create reuses the camera (Create Multiple Nulls and Camera), then Text and
/// Shadow Catcher (+ light).
fn assert_create_kinds(s: &mut Session, cam: LayerId, n_floor: usize) {
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "null", "multiple": true})).unwrap();
assert_eq!(r["camera"].as_u64().unwrap(), cam.0);
assert_eq!(r["layers"].as_array().unwrap().len(), floor.len());
assert_eq!(r["layers"].as_array().unwrap().len(), n_floor);
// Text and Shadow Catcher (+ light).
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "text"})).unwrap();
assert_eq!(r["layers"].as_array().unwrap().len(), 1);
@@ -180,11 +167,12 @@ fn track_camera_solves_and_creates_layers_on_the_plane() {
assert_eq!(comp.layer(ids[0]).unwrap().props.prop("materialOptions/acceptsShadows").unwrap().value.as_enum(), 2);
assert!(comp.layer(ids[1]).unwrap().is_light());
assert!(s.execute_checked("camera.createFromSolve", json!({"kind": "teapot"})).is_err());
}
// Set Ground Plane and Origin: the target becomes the origin; a null created there sits at
// (0, 0, 0) lying on the X-Z plane (its front facing up, −y).
/// Set Ground Plane and Origin, then an explicit target.
fn assert_ground_and_target(s: &mut Session) {
s.execute_checked("camera.setGroundPlane", json!({})).unwrap();
assert_eq!(status(&mut s)["groundPlane"], true);
assert_eq!(status(s)["groundPlane"], true);
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "null"})).unwrap();
let null = LayerId(r["layers"][0].as_u64().unwrap());
let tr = s.active_comp().unwrap().layer(null).unwrap().props.sub("transform").unwrap().clone();
@@ -203,6 +191,44 @@ fn track_camera_solves_and_creates_layers_on_the_plane() {
assert!(linalg::norm(linalg::sub(pos, [10.0, 0.0, 20.0])) < 1e-6, "{pos:?}");
let front = linalg::mv(&linalg::from_euler_xyz(tr.get("orientation").unwrap().value.as_vec3()), [0.0, 0.0, -1.0]);
assert!(linalg::norm(linalg::sub(front, [0.0, 0.0, -1.0])) < 1e-6, "{front:?}");
}
#[test]
fn track_camera_solves_and_creates_layers_on_the_plane() {
let (mut s, clip) = tracked(json!({}));
let g = assert_solved(&mut s, clip);
let floor = select_floor_points(&mut s);
// Create Solid and Camera: one undo step, camera + solid.
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "solid"})).unwrap();
let cam = LayerId(r["camera"].as_u64().unwrap());
let solid = LayerId(r["layers"][0].as_u64().unwrap());
let comp = s.active_comp().unwrap().clone();
assert!(comp.layer(cam).unwrap().is_camera());
assert_eq!(comp.layer(cam).unwrap().name, "3D Tracker Camera");
assert_eq!(comp.layers[0].id, cam);
let sl = comp.layer(solid).unwrap();
assert!(sl.switches.three_d);
assert!(sl.name.starts_with("Track Solid"));
// The camera is keyed on every frame.
assert_eq!(comp.layer(cam).unwrap().props.prop("transform/position").unwrap().keys.len(), 25);
let cid = s.active_comp_id().unwrap();
let uid = g.uid;
let pl = crate::camera_track::placed(&s.project, cid, clip, uid).unwrap();
assert_reprojection(&s, &comp, &pl, &g);
assert_solid_on_plane(&s, &comp, sl, &pl, r["target"]["size"].as_f64().unwrap());
// Undo removes both layers; redo brings them back.
let before = comp.layers.len();
assert!(s.undo());
assert_eq!(s.active_comp().unwrap().layers.len(), before - 2);
assert!(s.redo());
assert_eq!(s.active_comp().unwrap().layers.len(), before);
assert_create_kinds(&mut s, cam, floor.len());
assert_ground_and_target(&mut s);
// Serde keeps the analysis.
let back = effectcraft_project::Project::from_json(&s.project.to_json()).unwrap();
+451
View File
@@ -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");
}
+1 -1
View File
@@ -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());

Some files were not shown because too many files have changed in this diff Show More