diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml
index 1353830f8..b903f82ea 100644
--- a/.github/workflows/packaging-lint.yml
+++ b/.github/workflows/packaging-lint.yml
@@ -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')
diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml
index a074e6dbe..2a8144a5b 100644
--- a/.github/workflows/windows-arm64.yml
+++ b/.github/workflows/windows-arm64.yml
@@ -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@fa5ef18780cf5e7d5d3b8de068de56cffc83cac5
with:
app-name: effectcraft
app-dir: EffectCraft
+ install-relative-path: Programs/ArtCraft/EffectCraft
+ install-scope: perUser
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 77a9cc432..c97d32ed9 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -89,6 +89,8 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall
`crates/engine/presets/transitions.json` (sidecar `.attribution`).
- Backgrounds animation presets (23: drifting gradients, fog, cloud and smoke, plasma and magma, cells and crystals, scrolling grids
and checks, pulse rings, bokeh, light sweep): `crates/engine/presets/backgrounds.json` (sidecar `.attribution`).
+- Behaviors animation presets (22: wiggle, shake, float, orbit, bounce, drift, pendulum, spin, pulse, heartbeat, blink, strobe, flicker):
+ `crates/engine/presets/behaviors.json` (sidecar `.attribution`).
- Sample scripts (File ▸ Scripts) and ScriptUI panel: `crates/engine/scripts/Rename Layers.jsx`,
`crates/engine/scripts/Sort Layers by In Point.jsx`, `crates/engine/scripts/Create Null at Selected Layers.jsx`,
`crates/engine/scripts/Render Queue Batch.jsx`, `crates/engine/scripts/ScriptUI Panels/Layer Tools.jsx`
diff --git a/CLAUDE.md b/CLAUDE.md
index e52386942..21f2df36b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index dfa28c9f0..74d946f01 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -7,12 +7,36 @@ work apply to everyone, people and AI agents alike, and they are not negotiable.
You need [Rust](https://rustup.rs/) 1.95 or newer.
+On Linux the desktop app plays preview sound through `cpal`, so the build needs a usable ALSA
+pkg-config package: an `alsa.pc` that pkg-config can find, naming the linkable ALSA runtime
+library (usually `libasound.so.2`). `alsa-sys` runs pkg-config at build time; it does not compile
+ALSA's headers. Without a usable package the build fails at link time with an unrelated-looking
+error; `cargo xtask ci` probes pkg-config up front and names the package:
+
+```sh
+sudo dnf install alsa-lib-devel # Fedora
+sudo apt install libasound2-dev # Debian / Ubuntu
+sudo pacman -S alsa-lib # Arch
+```
+
+If you cannot install the package, point `PKG_CONFIG_PATH` at a directory holding an `alsa.pc`
+whose `Libs` line names your system's `libasound` (usually `libasound.so.2`); every CI run needs
+that variable set.
+
```sh
cargo run -p effectcraft -- --demo # the desktop app, with the demo project
cargo test --workspace
cargo xtask ci # what every commit must pass
```
+The default `dev` profile keeps debug info to line tables: builds are fast and panics/backtraces
+still name the file and line, but a debugger cannot show local variables. For an interactive
+debugger session use the `dev-full` profile, which is the same build with full debug info:
+
+```sh
+cargo run --profile dev-full -p effectcraft -- --demo
+```
+
## Ground rules
- **Clean room.** Work from After Effects' public documentation and its observable behaviour only.
diff --git a/Cargo.lock b/Cargo.lock
index 4a4043140..629fc65e9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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,9 +1620,10 @@ dependencies = [
[[package]]
name = "effectcraft-media"
-version = "0.17.0"
+version = "0.18.0"
dependencies = [
"craft-docpath",
+ "effectcraft-color",
"effectcraft-model",
"effectcraft-pdf",
"effectcraft-project",
@@ -1621,27 +1633,30 @@ dependencies = [
"effectcraft-svg",
"effectcraft-time",
"exr",
+ "filmcraft-aac",
"filmcraft-codecs",
"filmcraft-color",
"filmcraft-frame",
"filmcraft-isobmff",
+ "filmcraft-matroska",
"filmcraft-media",
+ "filmcraft-mpegts",
"filmcraft-time",
"gif",
"image",
"log",
"rayon",
"serde_json",
- "symphonia",
"thiserror 2.0.21",
]
[[package]]
name = "effectcraft-model"
-version = "0.17.0"
+version = "0.18.0"
dependencies = [
"effectcraft-geom",
"image",
+ "log",
"miniz_oxide 0.9.1",
"serde",
"serde_json",
@@ -1650,14 +1665,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 +1684,7 @@ dependencies = [
[[package]]
name = "effectcraft-pdf"
-version = "0.17.0"
+version = "0.18.0"
dependencies = [
"craft-pdf",
"effectcraft-raster",
@@ -1685,7 +1700,7 @@ dependencies = [
[[package]]
name = "effectcraft-plugin"
-version = "0.17.0"
+version = "0.18.0"
dependencies = [
"effectcraft-effects",
"effectcraft-keyframe",
@@ -1697,7 +1712,7 @@ dependencies = [
[[package]]
name = "effectcraft-project"
-version = "0.17.0"
+version = "0.18.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1710,14 +1725,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 +1741,7 @@ dependencies = [
[[package]]
name = "effectcraft-render"
-version = "0.17.0"
+version = "0.18.0"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@@ -1748,7 +1763,7 @@ dependencies = [
[[package]]
name = "effectcraft-script"
-version = "0.17.0"
+version = "0.18.0"
dependencies = [
"boa_engine",
"effectcraft-engine",
@@ -1766,7 +1781,7 @@ dependencies = [
[[package]]
name = "effectcraft-segment"
-version = "0.17.0"
+version = "0.18.0"
dependencies = [
"miniz_oxide 0.9.1",
"ndarray",
@@ -1775,7 +1790,7 @@ dependencies = [
[[package]]
name = "effectcraft-svg"
-version = "0.17.0"
+version = "0.18.0"
dependencies = [
"effectcraft-geom",
"effectcraft-path",
@@ -1787,7 +1802,7 @@ dependencies = [
[[package]]
name = "effectcraft-text"
-version = "0.17.0"
+version = "0.18.0"
dependencies = [
"effectcraft-keyframe",
"harfrust",
@@ -1799,7 +1814,7 @@ dependencies = [
[[package]]
name = "effectcraft-time"
-version = "0.17.0"
+version = "0.18.0"
dependencies = [
"proptest",
"serde",
@@ -1807,7 +1822,7 @@ dependencies = [
[[package]]
name = "effectcraft-track"
-version = "0.17.0"
+version = "0.18.0"
dependencies = [
"effectcraft-raster",
"effectcraft-segment",
@@ -1819,9 +1834,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 +1869,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",
@@ -2232,8 +2248,8 @@ dependencies = [
[[package]]
name = "filmcraft-aac"
-version = "0.1.1"
-source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
+version = "0.8.0"
+source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
dependencies = [
"filmcraft-bitstream",
"thiserror 2.0.21",
@@ -2241,40 +2257,57 @@ dependencies = [
[[package]]
name = "filmcraft-ac3"
-version = "0.1.1"
-source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
+version = "0.8.0"
+source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
dependencies = [
"thiserror 2.0.21",
]
[[package]]
-name = "filmcraft-av1"
-version = "0.1.1"
-source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
+name = "filmcraft-apv"
+version = "0.8.0"
+source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
dependencies = [
"filmcraft-bitstream",
"rayon",
"thiserror 2.0.21",
]
+[[package]]
+name = "filmcraft-av1"
+version = "0.8.0"
+source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
+dependencies = [
+ "filmcraft-bitstream",
+ "rayon",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "filmcraft-avi"
+version = "0.8.0"
+source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
+
[[package]]
name = "filmcraft-bitstream"
-version = "0.1.1"
-source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
+version = "0.8.0"
+source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
[[package]]
name = "filmcraft-cfb"
-version = "0.1.1"
-source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
+version = "0.8.0"
+source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
[[package]]
name = "filmcraft-codecs"
-version = "0.1.1"
-source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
+version = "0.8.0"
+source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
dependencies = [
"filmcraft-aac",
"filmcraft-ac3",
+ "filmcraft-apv",
"filmcraft-av1",
+ "filmcraft-avi",
"filmcraft-bitstream",
"filmcraft-color",
"filmcraft-dnx",
@@ -2284,6 +2317,7 @@ dependencies = [
"filmcraft-isobmff",
"filmcraft-matroska",
"filmcraft-media",
+ "filmcraft-mpa",
"filmcraft-mpeg2v",
"filmcraft-mpegts",
"filmcraft-mxf",
@@ -2291,9 +2325,11 @@ dependencies = [
"filmcraft-opus",
"filmcraft-prores",
"filmcraft-time",
+ "filmcraft-vp8",
"filmcraft-vp9",
"image",
"log",
+ "rayon",
"symphonia",
"thiserror 2.0.21",
"web-time",
@@ -2301,16 +2337,25 @@ dependencies = [
[[package]]
name = "filmcraft-color"
-version = "0.1.1"
-source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
+version = "0.8.0"
+source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
dependencies = [
"serde",
]
+[[package]]
+name = "filmcraft-dng"
+version = "0.8.0"
+source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
+dependencies = [
+ "rayon",
+ "thiserror 2.0.21",
+]
+
[[package]]
name = "filmcraft-dnx"
-version = "0.1.1"
-source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
+version = "0.8.0"
+source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
dependencies = [
"filmcraft-bitstream",
"rayon",
@@ -2319,8 +2364,8 @@ dependencies = [
[[package]]
name = "filmcraft-frame"
-version = "0.1.1"
-source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
+version = "0.8.0"
+source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
dependencies = [
"filmcraft-color",
"filmcraft-time",
@@ -2330,16 +2375,16 @@ dependencies = [
[[package]]
name = "filmcraft-geom"
-version = "0.1.1"
-source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
+version = "0.8.0"
+source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
dependencies = [
"serde",
]
[[package]]
name = "filmcraft-h264"
-version = "0.1.1"
-source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
+version = "0.8.0"
+source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
dependencies = [
"filmcraft-bitstream",
"rayon",
@@ -2348,8 +2393,8 @@ dependencies = [
[[package]]
name = "filmcraft-h264enc"
-version = "0.1.1"
-source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
+version = "0.8.0"
+source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
dependencies = [
"filmcraft-bitstream",
"rayon",
@@ -2358,8 +2403,8 @@ dependencies = [
[[package]]
name = "filmcraft-hevc"
-version = "0.1.1"
-source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
+version = "0.8.0"
+source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
dependencies = [
"filmcraft-bitstream",
"rayon",
@@ -2368,8 +2413,8 @@ dependencies = [
[[package]]
name = "filmcraft-interchange"
-version = "0.1.1"
-source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
+version = "0.8.0"
+source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
dependencies = [
"filmcraft-cfb",
"filmcraft-color",
@@ -2385,8 +2430,8 @@ dependencies = [
[[package]]
name = "filmcraft-isobmff"
-version = "0.1.1"
-source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
+version = "0.8.0"
+source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
dependencies = [
"filmcraft-bitstream",
"thiserror 2.0.21",
@@ -2394,15 +2439,16 @@ dependencies = [
[[package]]
name = "filmcraft-matroska"
-version = "0.1.1"
-source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
+version = "0.8.0"
+source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
[[package]]
name = "filmcraft-media"
-version = "0.1.1"
-source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
+version = "0.8.0"
+source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
dependencies = [
"filmcraft-color",
+ "filmcraft-dng",
"filmcraft-frame",
"filmcraft-time",
"image",
@@ -2411,10 +2457,19 @@ dependencies = [
"thiserror 2.0.21",
]
+[[package]]
+name = "filmcraft-mpa"
+version = "0.8.0"
+source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
+dependencies = [
+ "filmcraft-bitstream",
+ "thiserror 2.0.21",
+]
+
[[package]]
name = "filmcraft-mpeg2v"
-version = "0.1.1"
-source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
+version = "0.8.0"
+source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
dependencies = [
"rayon",
"thiserror 2.0.21",
@@ -2422,31 +2477,31 @@ dependencies = [
[[package]]
name = "filmcraft-mpegts"
-version = "0.1.1"
-source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
+version = "0.8.0"
+source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "filmcraft-mxf"
-version = "0.1.1"
-source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
+version = "0.8.0"
+source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
[[package]]
name = "filmcraft-ogg"
-version = "0.1.1"
-source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
+version = "0.8.0"
+source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
[[package]]
name = "filmcraft-opus"
-version = "0.1.1"
-source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
+version = "0.8.0"
+source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
[[package]]
name = "filmcraft-project"
-version = "0.1.1"
-source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
+version = "0.8.0"
+source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
dependencies = [
"filmcraft-color",
"filmcraft-geom",
@@ -2459,8 +2514,8 @@ dependencies = [
[[package]]
name = "filmcraft-prores"
-version = "0.1.1"
-source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
+version = "0.8.0"
+source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
dependencies = [
"filmcraft-bitstream",
"rayon",
@@ -2469,16 +2524,24 @@ dependencies = [
[[package]]
name = "filmcraft-time"
-version = "0.1.1"
-source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
+version = "0.8.0"
+source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
dependencies = [
"serde",
]
+[[package]]
+name = "filmcraft-vp8"
+version = "0.8.0"
+source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
+dependencies = [
+ "thiserror 2.0.21",
+]
+
[[package]]
name = "filmcraft-vp9"
-version = "0.1.1"
-source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
+version = "0.8.0"
+source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
dependencies = [
"filmcraft-bitstream",
"rayon",
@@ -7183,9 +7246,10 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
-version = "0.17.0"
+version = "0.18.0"
dependencies = [
"chrono",
+ "pkg-config",
"serde_json",
"tempfile",
]
diff --git a/Cargo.toml b/Cargo.toml
index 79ec24655..c101c489c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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" }
@@ -76,29 +77,29 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg"]
# media codecs + containers: FilmCraft's pure-Rust crates, pinned to a revision (plan/adr/0001).
# Only `crates/media`, `crates/export` and `crates/interchange` may depend on these.
-filmcraft-codecs = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
-filmcraft-media = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
-filmcraft-frame = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
-filmcraft-color = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
-filmcraft-time = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
+filmcraft-codecs = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
+filmcraft-media = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
+filmcraft-frame = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
+filmcraft-color = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
+filmcraft-time = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
# encoders + muxer (crates/export)
-filmcraft-h264enc = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
-filmcraft-prores = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
-filmcraft-aac = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
-filmcraft-isobmff = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
+filmcraft-h264enc = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
+filmcraft-prores = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
+filmcraft-aac = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
+filmcraft-isobmff = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
+# container headers read for AAC channel layouts (crates/media)
+filmcraft-matroska = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
+filmcraft-mpegts = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
# decoder used as the round-trip oracle of effectcraft-opusenc (crates/export dev-dependency)
-filmcraft-opus = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
-filmcraft-vp9 = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
+filmcraft-opus = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
+filmcraft-vp9 = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
# decoders used as the round-trip oracles of effectcraft-hevcenc / effectcraft-av1enc (dev-dependencies only)
-filmcraft-hevc = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
-filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
+filmcraft-hevc = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
+filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
# timeline interchange (EDL, FCP7 XML, FCPXML, OTIO) for Premiere Pro interop (crates/interchange only)
-filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
-filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
-filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
-# The symphonia filmcraft-codecs already builds, named only to turn on its AIFF reader: the pinned
-# rev enables wav alone (FilmCraft main enables aiff too). Drop it when the pin moves past that.
-symphonia = { version = "0.5.5", default-features = false, features = ["aiff"] }
+filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
+filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
+filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
# gpu (the same wgpu eframe / egui-wgpu use)
wgpu = { version = "30.0.1", features = ["fragile-send-sync-non-atomic-wasm"] }
@@ -130,14 +131,18 @@ sys-locale = "0.3"
# filmcraft-prores = { path = "../filmcraft/crates/prores" }
# filmcraft-aac = { path = "../filmcraft/crates/aac" }
# filmcraft-isobmff = { path = "../filmcraft/crates/isobmff" }
+# filmcraft-matroska = { path = "../filmcraft/crates/matroska" }
+# filmcraft-mpegts = { path = "../filmcraft/crates/mpegts" }
[workspace.lints.rust]
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"
@@ -150,6 +155,13 @@ opt-level = 1
# Panics and backtraces keep file and line; full debug info only made the many test binaries large.
debug = "line-tables-only"
+# Same settings as `dev`, but with full debug info for interactive debugger sessions: local
+# variables, parameters and column breakpoints work. Use `--profile dev-full` when debugging;
+# everything else stays on the fast default. See CONTRIBUTING.md, "Setup".
+[profile.dev-full]
+inherits = "dev"
+debug = true
+
# Pixel hot loops: keep debug builds interactive.
[profile.dev.package.effectcraft-raster]
opt-level = 3
diff --git a/README.md b/README.md
index 5fa28f0bb..12970b8ba 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@
A free, open-source compositor: compositions, layers,
- keyframes, 306 effects, layer styles, expressions, 3D cameras and lights, and a render queue, native on macOS,
+ keyframes, 308 effects, layer styles, expressions, 3D cameras and lights, and a render queue, native on macOS,
Windows and Linux, and in the browser. Young, moving fast, and already usable.
@@ -84,9 +84,10 @@ a few places where it matters to us:
without a plugin.
- **A project file you can read.** Projects are versioned JSON (`.ecproj`), so they diff cleanly
in version control.
-- **Everything is scriptable.** Every menu item, timeline drag and property edit goes through one
- command registry, so the same actions are reachable from a command line, a JSON control
- channel and an MCP server for agents.
+- **Everything is scriptable.** Menu actions, timeline edits and property changes are exposed as
+ commands. Engine commands are available through the CLI, JSON control channel and MCP;
+ UI-only commands use the frontend dispatcher and can be invoked through the control channel
+ or an attached MCP bridge.
- **No FFmpeg.** Video and audio decoding and encoding are pure Rust: FilmCraft's codecs and
EffectCraft's own VP9, AV1, HEVC and Opus encoders.
@@ -126,7 +127,7 @@ or into the Timeline, between layers and at the time you point to.
## Effects
-306 built-in effects across its categories, each with its parameter names, order and
+308 built-in effects across its categories, each with its parameter names, order and
defaults: blur and sharpen, channel, color correction (Curves, Levels, Hue/Saturation, a full
color-grading panel…), distort (Warp, Bulge, Turbulent Displace, CC Power Pin…), generate (Fractal Noise,
Gradient Ramp, Stroke, Write-on, Audio Spectrum…), keying, matte, noise and grain, perspective,
@@ -155,7 +156,7 @@ tools, and Front, Top, Left and Custom views.
A dedicated Render Queue, with Render Settings and Output Modules: **H.264** MP4 and
**ProRes** MOV (Proxy to 4444 XQ with alpha) with audio, **HEVC** (Main / Main 10) and **AV1**
-MP4, **WebM** (VP9 with inter frames and alpha, or AV1; Opus audio), PNG, JPEG, TIFF and 32-bit EXR sequences, animated GIF and WAV/AIFF. Field
+MP4, **WebM** (VP9 with inter frames and alpha, or AV1; Opus audio), PNG, JPEG, TIFF, Targa and 32-bit EXR sequences, animated GIF and WAV/AIFF. Field
rendering with 3:2 pulldown, effect/solo/guide/depth overrides, crop, region of interest and
resize, Render Settings and Output Module templates with defaults, post-render actions, storage
overflow and render logs. The same queue runs from the command line. Every encoder is pure
@@ -173,8 +174,10 @@ cannot express; File ▸ Import ▸ Lottie… opens one as a new composition.
## Built for agents
-Everything you can do from a menu is a command with an id, and agents can reach every one of
-them:
+Engine commands are available through the CLI, JSON control channel and MCP. UI-only commands
+need the running app's frontend dispatcher: complete menu access requires the app's control
+channel or an attached MCP bridge. Headless MCP sessions expose supported engine commands,
+but not UI-only commands or bridge-only `ui_*` tools.
- **MCP server:** `effectcraft-cli mcp` speaks the Model Context Protocol over stdio, headless
or bridged to the running app (`--bridge 9877`). Tools cover commands, the project and property
@@ -228,7 +231,7 @@ tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING
## Where it stands
EffectCraft is young: its first commit was on 1 October 2026. A comprehensive feature set
-exists, including 306 effects, but breadth is not the same as full maturity across every edge
+exists, including 308 effects, but breadth is not the same as full maturity across every edge
case. Today it's a good place to try things and to tell us what breaks. It isn't yet recommended
for production client work. In particular:
@@ -275,9 +278,13 @@ send us right now.
| arm64 (Snapdragon and other ARM PCs) | `effectcraft--windows-arm64.msi` | `effectcraft--windows-arm64-portable.zip` |
| x86 (32-bit) | `effectcraft--windows-x86.msi` | `effectcraft--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--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
diff --git a/ROADMAP.md b/ROADMAP.md
index f4c99d61f..740ec6010 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~30 points of "ready for real work" and ~350–620 h away
-> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (open-issue count, crash list and localization share re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 (26.5.0.89)
+> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-30 · **Change:** minor (features of 30 October in the progress log: text Line Join, Timeline drag-reorder and multi-select of effects, viewer edge pan, ScriptUI drag capture and dock size; previously: localization recount after #1774) · **Target:** Adobe After Effects 2026 (26.5.0.89)
EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written
from scratch in Rust. This page is the one-page summary; the evidence is in
@@ -51,7 +51,7 @@ Linux/Windows reliability (30–50 h) and depth in animation, masks, effects and
| UI/UX fidelity | 65% | 60–120 | [ui-parity.md](docs/ui-parity.md) |
| File formats | 50% | 130–230 | [file-format-parity.md](docs/file-format-parity.md) |
| Hardware | 40% | 60–120 | [hardware-parity.md](docs/hardware-parity.md) |
-| Localization | 18% | 60–110 + review | [localization-parity.md](docs/localization-parity.md) |
+| Localization | 24% | 60–110 + review | [localization-parity.md](docs/localization-parity.md) |
| Performance | 35% | 50–100 | [gaps.md](docs/gaps.md#g5-performance-at-real-world-scale) |
| Stability | 45% | 40–80 | [gaps.md](docs/gaps.md#g3-stability-and-a-green-main) |
| Platforms | 65% | 30–60 | [gaps.md](docs/gaps.md#g2-real-user-reliability-on-every-platform) |
@@ -80,24 +80,24 @@ Weights and evidence per row: [target-app-parity.md](docs/target-app-parity.md#b
## Languages
-UI strings translated out of ≈ 3,900 (detail: [localization-parity.md](docs/localization-parity.md)).
+UI strings translated out of ≈ 4,200 (detail: [localization-parity.md](docs/localization-parity.md)).
| Language | Status | % |
|---|---|---:|
| English | full | 100% |
-| Simplified Chinese | partial (menus, part of the panels) | 29% |
-| Spanish | menus only | 16% |
+| Simplified Chinese | partial (menus, most of the panels) | 46% |
+| Spanish | menus only | 29% |
| Hindi | none | 0% |
| Arabic | none | 0% |
| French | menus only | 16% |
-| Portuguese (Brazil) | partial (menus, part of the panels) | 26% |
+| Portuguese (Brazil) | partial (menus, part of the panels) | 34% |
| Indonesian | none | 0% |
-| Japanese | partial (menus, part of the panels) | 27% |
+| Japanese | partial (menus, most of the panels) | 46% |
| German | menus only | 16% |
| Korean | none | 0% |
| Vietnamese | none | 0% |
-Also shipped: Traditional Chinese (partial, 29%), Ukrainian (partial, 29%), Czech (partial, 29%), Georgian (partial, 38%; native-speaker reviewed) and Russian (menus only, 16%). No other language has had
+Also shipped: Traditional Chinese (partial, 38%), Ukrainian (partial, 29%), Czech (partial, 29%), Polish (partial, 34%), Georgian (partial, 39%; native-speaker reviewed) and Russian (menus only, 15%). No other language has had
native-speaker review.
## Upcoming
@@ -117,10 +117,33 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
## Progress log
+- 2026-10-30: Text strokes take a Line Join (Miter, Round or Bevel) in the Character panel,
+ Miter for new text as in After Effects (#2077). In the Timeline, effects, masks and shape items
+ drag to a new place in their stack (#2210) and Shift-click adds them to the selection, so one
+ animation preset can hold several (#2552). The Composition panel pans while a mask vertex is
+ held at its edge (#999). Effect buffers that pad a small layer stay within a memory budget
+ (#2261); an MP4 lasts as long as its edit list shows (#2592); the Render Queue's times tick
+ between frames (#2593). ScriptUI drags keep their control past the window edge with
+ `MouseEvent.buttons` (#2584), and docked panels get their dock's size and `onResize` (#2585).
+- 2026-10-28: A shape layer's Rectangle and Ellipse Size get the Constrain Proportions chain
+ that Scale has, so a circle stays a circle while its size is typed, scrubbed or keyed; it
+ starts off, so existing shapes behave as before (#2489).
+- 2026-10-24: Shape layers have the Wiggle Transform path operation (Contents ▸ Add, or
+ `layer.addShapeItem {kind: "wiggler"}`): it wiggles the anchor point, position, scale and
+ rotation of the paths above it by random amounts that change over time, up to the Transform
+ values it holds. The copies of a Repeater above it wiggle independently as far as its
+ Correlation allows; with the Repeater below it, they wiggle together.
+- 2026-10-22: Localization: the panel/dialog catalogs gain 303 rows in Japanese, Simplified
+ Chinese and Traditional Chinese — panel tabs, form and dialog labels, panel context menus,
+ tracker/graph/text-panel controls, File-panel footers and credits. Recounted after #1774:
+ Japanese is 1,923 of ≈ 4,200 interface strings (46%, with 297 effect names); the Chinese
+ panel catalogs hold 998 (Simplified) and 951 (Traditional) rows, and Simplified Chinese is
+ 46% with its 307 effect names; the overall share is ≈ 24%.
- 2026-10-16: Convert To Bezier Path turns a shape layer's Rectangle, Ellipse or Polystar
path into an editable Path 1 with the same geometry, as After Effects does; it used to
apply to mask RotoBezier only, so a parametric shape path could not be converted at all
and its vertices could not be edited or animated (#932).
+- 2026-10-14: Georgian (`ka`): every menu row, every panel-catalog row the Chinese catalogs have plus 195 Georgian-only ones, and the effect names and categories; Match System for `ka` / `ka-GE` / `ka_GE.UTF-8` locales. Reviewed by a native speaker.
- 2026-10-12: Roto Brush span edits report strokes outside a trimmed layer without panicking;
strokes and undo history are preserved so restoring the timing allows the span edit again.
- 2026-10-11: Ukrainian panels caught up with the Chinese catalogs. The 95 rows the panel
@@ -135,7 +158,6 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
follow the shared standard: `docs/parity.md` became `docs/target-app-parity.md`; new
`docs/roadmap.md`, `localization-parity.md`, `file-format-parity.md`, `hardware-parity.md`,
`ui-parity.md`, `effects-parity.md` and `scripting-parity.md`.
-- 2026-10-14: Georgian (`ka`): every menu row, every panel-catalog row the Chinese catalogs have plus 195 Georgian-only ones, and the effect names and categories; Match System for `ka` / `ka-GE` / `ka_GE.UTF-8` locales. Reviewed by a native speaker.
- 2026-10-10: Spanish (#460) and Brazilian Portuguese (#217) menu translations.
- 2026-10-10: Japanese now translates the interface below the menu bar too: `crates/ui-egui/src/i18n/ui.rs` has a Japanese table with the same 396 rows as the Chinese ones (panels, dialogs, buttons and tooltips; the shape test keeps the three catalogs listing the same source strings). Menus were already Japanese.
- 2026-10-09: Ukrainian now covers all 396 current panel-catalog strings, including dialogs, buttons and tooltips, alongside its menu translation. The catalog keeps the same source keys and placeholder counts as the Chinese catalogs; missing or engine-generated text still falls back to English.
@@ -155,11 +177,13 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
| Date | Change | Summary |
|---|---|---|
+| 2026-10-30 | minor | Progress log: text Line Join, Timeline drag-reorder and Shift multi-select of effects, viewer edge pan, effect padding budget, MP4 edit-list duration, Render Queue clock, ScriptUI drag capture and docked-panel size |
+| 2026-10-22 | minor | Localization: 300 more panel/dialog rows in Japanese and the Chinese catalogs, plus the review's three missing rows (Basic, Advanced, Play/Stop) — panel tabs, form and dialog labels, context menus, tracker/graph/text-panel controls, File-panel footers, credits; language shares recounted after #1774 (Japanese 27% → 46% with 297 effect names, Simplified Chinese 29% → 46% with 307 effect names, Traditional Chinese 29% → 38%, overall 18% → 24%) |
| 2026-10-12 | minor | Open-issue count (134 to 216), crash list and localization share (12% to 18%) updated to match docs/gaps.md |
| 2026-10-12 | patch | Roto Brush span validation after trimming; command regressions for both layer edges |
+| 2026-10-12 | minor | Shared PDF object/xref parsing with bounded app-owned conversion and preserved stream budgets; drawing, EPS and image/font policy remain unchanged |
| 2026-10-10 | minor | Hours to ≈ 95% for each audience |
| 2026-10-10 | minor | Headline adds mainstream practitioner (≈ 47%) and essentials user (≈ 52%) |
-| 2026-10-12 | minor | Shared PDF object/xref parsing with bounded app-owned conversion and preserved stream budgets; drawing, EPS and image/font policy remain unchanged |
-| 2026-10-10 | minor | Stage checked against the new core-workflow gate (all six pass; still alpha) |
| 2026-10-10 | major | Rewritten to the shared progress-docs standard: stage, two numbers, dimensions, features, languages, upcoming, progress log; milestones moved to docs/roadmap.md |
+| 2026-10-10 | minor | Stage checked against the new core-workflow gate (all six pass; still alpha) |
| 2026-10-05 | major | Two numbers (breadth vs real use) and workstreams G1–G9 |
diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs
index 6e5b8d6cc..db6394b21 100644
--- a/apps/effectcraft-cli/src/main.rs
+++ b/apps/effectcraft-cli/src/main.rs
@@ -7,11 +7,12 @@
//! effectcraft-cli exec --list [--filter TEXT] [--schemas] list engine commands (as `commands`)
//! effectcraft-cli run [ …] run several commands in order
//! effectcraft-cli run --args-file F the same, one argument per line of F
+//! effectcraft-cli run --commands FILE|- steps {"command", "params"} from FILE or stdin
//! effectcraft-cli props [--flat] [--time S] a layer's property tree (with paths)
//! effectcraft-cli get [--time S] read a property
//! effectcraft-cli set [--time S] [--expression E]
//! effectcraft-cli render-frame [--comp C] [--time S|--frame N] [--max-side PX|--scale K] [--out F.png] [--transparent]
-//! effectcraft-cli render [--comp C] --out FILE [--format h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff] [--start S] [--end S]
+//! effectcraft-cli render [--comp C] --out FILE [--format h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff] [--start S] [--end S]
//! [--work-area] [--fps N] [--resolution full|half|third|quarter|K] [--quality best|draft] [--channels rgb|rgba]
//! [--jpeg-quality N] [--bitrate KBPS] [--prores proxy|lt|standard|hq|4444|4444xq] [--audio auto|on|off]
//! [--profile main|main10] [--level auto|4.1] [--rate-control bitrate|quality] [--video-quality 1-100]
@@ -98,6 +99,10 @@ const USAGE: &str = "usage: effectcraft-cli [--params JSON] run one engine command (exec --list: list them, as `commands`)
run [ ...] run several commands in order
run --args-file F the same, one argument per line of F (no command-line length limit)
+ run --commands FILE|- steps from FILE, or standard input for -: a JSON array of
+ {\"command\": ID, \"params\": {...}} (or \"id\" for \"command\"), or one
+ such object per line (JSON Lines); at most 64 MiB and 100000 steps.
+ Repeatable; run after the pairs and --args-file
props [--flat] [--time S] a layer's property tree with paths
get [--time S] read a property
set [--time S] [--expression E]
@@ -107,7 +112,7 @@ const USAGE: &str = "usage: effectcraft-cli ) -> Result raw,
Err(e) => fail_usage(&e),
@@ -334,6 +343,87 @@ impl From for Failure {
}
}
+/// Most bytes `run --commands` reads from a file or standard input: the steps are untrusted input.
+const MAX_COMMANDS_BYTES: u64 = 64 * 1024 * 1024;
+/// Most steps one `run --commands` takes.
+const MAX_COMMANDS_STEPS: usize = 100_000;
+
+/// The text of `--commands source`: the file `source`, or standard input for `-`, refused past
+/// [`MAX_COMMANDS_BYTES`]. This reader, [`parse_commands`] and [`command_step`] are ported from
+/// vectorcraft-cli's (storytold/vectorcraft#3630), with a tuple in place of its `Step` enum.
+fn read_commands(source: &str) -> Result {
+ use std::io::Read;
+ let mut bytes = vec![];
+ let read = if source == "-" {
+ std::io::stdin().lock().take(MAX_COMMANDS_BYTES + 1).read_to_end(&mut bytes)
+ } else {
+ std::fs::File::open(source).and_then(|f| f.take(MAX_COMMANDS_BYTES + 1).read_to_end(&mut bytes))
+ };
+ read.map_err(|e| format!("--commands {source}: {e}"))?;
+ if bytes.len() as u64 > MAX_COMMANDS_BYTES {
+ return Err(format!("--commands {source}: more than {} MiB of steps", MAX_COMMANDS_BYTES / (1024 * 1024)));
+ }
+ String::from_utf8(bytes).map_err(|_| format!("--commands {source}: not UTF-8 text"))
+}
+
+/// The steps of `--commands source` from its `text`: a JSON array of `{"command": ID, "params":
+/// {…}}` objects (`"id"` may stand for `"command"`), or one such object per line (JSON Lines, blank
+/// lines skipped). `first` is the run's index of the first of them, which an error names with the
+/// entry's place in the text.
+fn parse_commands(source: &str, text: &str, first: usize) -> Result, String> {
+ // Notepad on Windows starts UTF-8 files with a byte order mark.
+ let text = text.strip_prefix('\u{feff}').unwrap_or(text);
+ let too_many = || format!("--commands {source}: more than {MAX_COMMANDS_STEPS} steps");
+ // Each entry with where it is in the text: its line for JSON Lines.
+ let mut entries: Vec<(Option, Value)> = vec![];
+ if text.trim_start().starts_with('[') {
+ let v: Value = serde_json::from_str(text).map_err(|e| format!("--commands {source}: {e}"))?;
+ let Value::Array(items) = v else { return Err(format!("--commands {source}: not a JSON array")) };
+ if items.len() > MAX_COMMANDS_STEPS {
+ return Err(too_many());
+ }
+ entries.extend(items.into_iter().map(|v| (None, v)));
+ } else {
+ for (n, line) in text.lines().enumerate().filter(|(_, l)| !l.trim().is_empty()) {
+ if entries.len() == MAX_COMMANDS_STEPS {
+ return Err(too_many());
+ }
+ let v = serde_json::from_str(line).map_err(|e| format!("--commands {source}: step {} (line {}): {e}", first + entries.len(), n + 1))?;
+ entries.push((Some(n + 1), v));
+ }
+ }
+ entries
+ .into_iter()
+ .enumerate()
+ .map(|(k, (line, v))| {
+ command_step(v).map_err(|e| {
+ let place = line.map_or_else(|| format!("entry {}", k + 1), |l| format!("line {l}"));
+ format!("--commands {source}: step {} ({place}): {e}", first + k)
+ })
+ })
+ .collect()
+}
+
+/// The command id and params of one `--commands` entry, `{"command": ID, "params": {…}}`.
+fn command_step(v: Value) -> Result<(String, Value), String> {
+ let Value::Object(mut o) = v else { return Err("a step must be an object such as {\"command\": \"comp.new\", \"params\": {}}".into()) };
+ let id = match (o.remove("command"), o.remove("id")) {
+ (Some(Value::String(id)), None) | (None, Some(Value::String(id))) => id,
+ (Some(_), Some(_)) => return Err("give `command` or `id`, not both".into()),
+ (None, None) => return Err("no `command`".into()),
+ _ => return Err("`command` must be a string, a command id".into()),
+ };
+ let params = match o.remove("params") {
+ None | Some(Value::Null) => json!({}),
+ Some(p @ Value::Object(_)) => p,
+ Some(_) => return Err(format!("`params` of {id} must be a JSON object")),
+ };
+ if let Some(key) = o.keys().next() {
+ return Err(format!("unknown key `{key}`: a step has `command` (or `id`) and `params`"));
+ }
+ Ok((id, params))
+}
+
fn usage_err(m: &str) -> Result {
Err(Failure::Usage(m.to_string()))
}
@@ -524,14 +614,14 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
let text = std::fs::read_to_string(f).map_err(|e| Failure::Usage(format!("--args-file {f}: {e}")))?;
pairs.extend(text.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from));
}
- if pairs.is_empty() {
- return usage_err("run needs [] pairs");
+ let sources = args.opts("--commands");
+ if pairs.is_empty() && sources.is_empty() {
+ return usage_err("run needs [] pairs or --commands FILE|-");
}
- let mut b = backend(args, true)?;
- let mut results = vec![];
+ let mut steps = vec![];
let mut i = 0;
while i < pairs.len() {
- let id = &pairs[i];
+ let id = pairs[i].clone();
let params = match pairs.get(i + 1).filter(|p| p.trim_start().starts_with('{')) {
Some(p) => {
i += 1;
@@ -540,9 +630,26 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
None => json!({}),
};
i += 1;
- let r = b.exec(id, params).map_err(|e| Failure::Error(format!("{id}: {e}")))?;
- if let Some(e) = failed_render(id, &r) {
- return Err(Failure::Error(format!("{id}: {e}")));
+ steps.push((id, params));
+ }
+ if sources.iter().filter(|s| **s == "-").count() > 1 {
+ return usage_err("--commands - is given twice: standard input is read once");
+ }
+ for &source in &sources {
+ let text = read_commands(source).map_err(Failure::Usage)?;
+ steps.extend(parse_commands(source, &text, steps.len()).map_err(Failure::Usage)?);
+ }
+ if steps.is_empty() {
+ return Err(Failure::Usage(format!("--commands {}: no commands", sources.join(", "))));
+ }
+ let mut b = backend(args, true)?;
+ let mut results = vec![];
+ // The step's index closes the message: a generated list repeats ids, and callers match
+ // the leading `: `.
+ for (n, (id, params)) in steps.into_iter().enumerate() {
+ let r = b.exec(&id, params).map_err(|e| Failure::Error(format!("{id}: {e} (step {n})")))?;
+ if let Some(e) = failed_render(&id, &r) {
+ return Err(Failure::Error(format!("{id}: {e} (step {n})")));
}
if !json_out {
emit(&r, false);
@@ -1189,7 +1296,7 @@ fn adv3d_bench_comp(p: &mut effectcraft_engine::project::Project) -> Option- (i32, String, String) {
+ let mut child = bin().args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
+ child.stdin.take().unwrap().write_all(stdin.as_bytes()).unwrap();
+ let out = child.wait_with_output().unwrap();
+ (out.status.code().unwrap_or(-1), String::from_utf8_lossy(&out.stdout).into_owned(), String::from_utf8_lossy(&out.stderr).into_owned())
+}
+
+/// `run --commands FILE|-` (#2251) reads `{"command", "params"}` steps from a JSON array or JSON
+/// Lines, from a file or standard input, and runs them as the same positional pairs would: after
+/// the pairs, so a generated list can follow a hand-written prefix. One command whose params are
+/// over Linux's 128 KiB per-argument limit goes through.
+#[test]
+fn run_reads_commands_from_a_file_or_stdin() {
+ let steps = [
+ ("comp.new", json!({"name": "A", "width": 64, "height": 64})),
+ ("layer.newSolid", json!({"width": 8, "height": 8, "name": "One"})),
+ ("layer.newSolid", json!({"width": 8, "height": 8, "name": "Two"})),
+ ("project.summary", json!({})),
+ ];
+ let pairs: Vec
= steps.iter().flat_map(|(id, p)| [id.to_string(), p.to_string()]).collect();
+ let pairs: Vec<&str> = pairs.iter().map(String::as_str).collect();
+ let expected = ok_json(&[&["run", "--empty"][..], &pairs[..]].concat());
+ let layers = |v: &Value| v[3]["result"]["items"][0]["layers"].as_u64();
+ assert_eq!(layers(&expected), Some(2), "{expected}");
+
+ let objects: Vec = steps.iter().map(|(id, p)| json!({"command": id, "params": p})).collect();
+ let array = tmp("commands.json");
+ std::fs::write(&array, serde_json::to_string_pretty(&objects).unwrap()).unwrap();
+ assert_eq!(ok_json(&["run", "--commands", array.to_str().unwrap(), "--empty"]), expected);
+
+ let lines: String = objects.iter().map(|o| format!("{o}\n\n")).collect();
+ let (code, out, err) = run_with_stdin(&["run", "--commands", "-", "--empty", "--json"], &lines);
+ assert_eq!(code, 0, "{err}");
+ assert_eq!(serde_json::from_str::(&out).unwrap(), expected);
+
+ // Positional pairs first, then the file.
+ let rest = tmp("commands-rest.jsonl");
+ std::fs::write(&rest, objects[1..].iter().map(|o| format!("{o}\n")).collect::()).unwrap();
+ assert_eq!(ok_json(&[&["run", "--empty", "--commands", rest.to_str().unwrap()][..], &pairs[..2]].concat()), expected);
+
+ let name = "n".repeat(140 * 1024);
+ let big = tmp("commands-big.jsonl");
+ std::fs::write(&big, format!("{}\n{}\n", json!({"command": "comp.new", "params": {"name": name}}), json!({"command": "project.summary"}))).unwrap();
+ let v = ok_json(&["run", "--commands", big.to_str().unwrap(), "--empty"]);
+ assert_eq!(v[1]["result"]["items"][0]["name"].as_str().map(str::len), Some(name.len()));
+
+ for (input, args, error) in [
+ ("", &["run", "--commands", "-", "--empty"][..], "--commands -: no commands"),
+ (
+ "{\"command\":\"project.summary\"}\n{\"command\":\"x\",\"param\":{}}\n",
+ &["run", "project.summary", "{}", "--commands", "-", "--empty"],
+ "step 2 (line 2): unknown key `param`",
+ ),
+ ("[{\"command\":\"project.summary\"}, {\"params\":{}}]", &["run", "--commands", "-", "--empty"], "step 1 (entry 2): no `command`"),
+ ("{}", &["run", "--commands", "-", "--commands", "-", "--empty"], "standard input is read once"),
+ ] {
+ let (code, out, err) = run_with_stdin(args, input);
+ assert_eq!(code, 2, "{input:?}: {err}");
+ assert!(err.contains(error), "{input:?}: {err}");
+ assert!(out.is_empty(), "{input:?}: nothing ran");
+ }
+ // An empty list after positional pairs is no error: there is still something to run.
+ let (code, _, err) = run_with_stdin(&["run", "project.summary", "--commands", "-", "--empty"], "");
+ assert_eq!(code, 0, "{err}");
+ // A step that fails when it runs is named by its index too.
+ let (code, out, err) = run_with_stdin(&["run", "--commands", "-", "--empty", "--json"], "{\"command\":\"project.summary\"}\n{\"command\":\"no.such\"}\n");
+ assert_eq!(code, 1, "{err}");
+ let e = serde_json::from_str::(&out).unwrap()["error"].as_str().map(String::from).unwrap_or_default();
+ assert!(e.starts_with("no.such: ") && e.ends_with(" (step 1)"), "{e}");
+}
+
#[test]
fn render_frame_writes_png() {
let out = tmp("f.png");
diff --git a/apps/effectcraft-cli/tests/it/qa_template.rs b/apps/effectcraft-cli/tests/it/qa_template.rs
index 8cb533fda..ffbdb94e9 100644
--- a/apps/effectcraft-cli/tests/it/qa_template.rs
+++ b/apps/effectcraft-cli/tests/it/qa_template.rs
@@ -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);
diff --git a/apps/effectcraft-web/Cargo.toml b/apps/effectcraft-web/Cargo.toml
index 28ea2f369..7743347af 100644
--- a/apps/effectcraft-web/Cargo.toml
+++ b/apps/effectcraft-web/Cargo.toml
@@ -37,6 +37,8 @@ js-sys = "0.3"
web-sys = { version = "0.3", features = [
"Blob",
"BlobPropertyBag",
+ "AbortController",
+ "AbortSignal",
"CssStyleDeclaration",
"Document",
"Element",
@@ -51,6 +53,12 @@ web-sys = { version = "0.3", features = [
"HtmlInputElement",
"Location",
"Navigator",
+ "ReadableStream",
+ "ReadableStreamDefaultReader",
+ "Request",
+ "RequestCache",
+ "RequestInit",
+ "Response",
"Url",
"UrlSearchParams",
"Window",
diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js
index 9346acc29..c8e91a260 100644
--- a/apps/effectcraft-web/js/host.js
+++ b/apps/effectcraft-web/js/host.js
@@ -7,6 +7,23 @@
//
// Plain functions only: the Rust side owns the logic.
+// ------------------------------------------------------------------ shortcuts
+
+/// Keep Duplicate on the app canvas instead of also opening the browser's bookmarks.
+/// The physical D key counts on layouts where it types no ASCII letter (e.g. Cyrillic), because
+/// eframe falls back to the key code there; it does not on Dvorak, where it types E.
+/// eframe still receives the key; other chords and HTML inputs keep their browser behavior.
+export function installCanvasShortcuts(canvas) {
+ canvas.addEventListener("keydown", (event) => {
+ if (event.target === canvas && (event.ctrlKey || event.metaKey)
+ && !event.shiftKey && !event.altKey && !event.isComposing
+ && (event.key.toLowerCase() === "d"
+ || (event.code === "KeyD" && !/^[a-z]$/i.test(event.key)))) {
+ event.preventDefault();
+ }
+ }, { capture: true });
+}
+
// ------------------------------------------------------------------ storage
const DIR = "effectcraft";
diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs
index 169529e5d..737f59f6a 100644
--- a/apps/effectcraft-web/src/app.rs
+++ b/apps/effectcraft-web/src/app.rs
@@ -6,7 +6,8 @@ use std::sync::atomic::{AtomicBool, Ordering};
use effectcraft_engine::Session;
-use crate::{api, audio, browse, diskcache, files, frames, persist, storage, worker};
+use crate::startup::Startup;
+use crate::{api, audio, browse, config_file, diskcache, files, frames, persist, storage, worker};
use effectcraft_ui_egui::EffectcraftApp;
use serde_json::json;
use wasm_bindgen::prelude::*;
@@ -15,6 +16,8 @@ use wasm_bindgen::prelude::*;
extern "C" {
#[wasm_bindgen(js_name = showDeviceLost)]
fn show_device_lost(message: &str, kept: bool);
+ #[wasm_bindgen(js_name = installCanvasShortcuts)]
+ fn install_canvas_shortcuts(canvas: &web_sys::HtmlCanvasElement);
}
/// Work for the UI thread with access to the app (posted by async tasks: picked files…).
@@ -81,8 +84,12 @@ pub(crate) fn open_or_import(app: &mut EffectcraftApp, paths: Vec) {
/// The eframe app: the shared EffectCraft UI plus the browser host's per-frame duties.
pub struct WebApp {
pub app: EffectcraftApp,
- /// Keep the session snapshot (off with `?empty`).
+ /// Keep the session snapshot (off with `?empty`; off with an explicit `"demo"` startup until
+ /// the user's own work begins, see `demo_start`).
pub persist: bool,
+ /// Started on the demo by the config: the snapshot stays off, so the demo never replaces the
+ /// last visit's project, until the user opens a project from a file or edits the demo.
+ pub demo_start: bool,
}
impl eframe::App for WebApp {
@@ -115,6 +122,11 @@ impl eframe::App for WebApp {
api::poll_replies();
// Render Queue outputs written this frame (here or by a worker) → downloads.
files::flush_downloads();
+ // The user's own work after a demo start is worth keeping too: turn the snapshot on.
+ if self.demo_start && (self.app.session.path.is_some() || self.app.session.is_dirty()) {
+ self.demo_start = false;
+ self.persist = true;
+ }
// What a reload comes back to.
if self.persist {
// The app's monotonic clock: `ctx.input(|i| i.time)` is stale in a logic-only tick.
@@ -204,6 +216,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
effectcraft_ui_egui::i18n::set_system_locales(query_value("lang"), browser.collect());
let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
let canvas: web_sys::HtmlCanvasElement = doc.get_element_by_id(&canvas_id).ok_or("no canvas")?.dyn_into()?;
+ install_canvas_shortcuts(&canvas);
let empty = query_flag("empty");
let home = query_flag("home");
// Browser storage: settings, recent projects, imported media, saved projects, auto-saves and
@@ -220,6 +233,11 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
};
api::set_info("storage", json!({"backend": persist::backend(), "entries": stored, "loadMs": t0.elapsed().as_secs_f64() * 1000.0}));
audio::install_unlock();
+ // `./effectcraft-config.json`: what to open on load.
+ // `?empty` and `?demo` decide by themselves, so they skip the request.
+ let startup = if empty || query_flag("demo") { Startup::default() } else { config_file::startup().await };
+ // An explicit `"demo"` start leaves the saved session alone until the user opens or edits something.
+ let keep_session = !empty && startup.keeps_session();
browse::restore();
diskcache::init();
storage::refresh();
@@ -245,7 +263,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
// The canvas can't present any more (#225): the page says so over it and
// offers a reload, which brings the session back when browser storage keeps it.
let lost = gpu_failures.clone();
- let kept = !empty && persist::backend() != "memory";
+ let kept = keep_session && persist::backend() != "memory";
rs.device.set_device_lost_callback(move |reason, message| {
let message = format!("GPU device lost ({reason:?}): {message}");
lost.report(&message, true);
@@ -254,11 +272,14 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
}
let mut session = session();
session.load_settings();
+ // Scripts in Scripts/Startup run before a project opens.
+ let _ = session.execute("file.runStartupScripts", json!({}));
let recovery = session.begin_recovery();
- // `?empty`: a blank project; `?demo`: the demo project; otherwise the last visit's
- // project (the demo on a first visit).
- let restored = !empty && !query_flag("demo") && persist::restore(&mut session);
- if !restored && !empty {
+ // `?empty`: a blank project; `?demo`: the demo project; otherwise what the config's
+ // `startup` says (default: the last visit's project, the demo on a first visit).
+ let demo = query_flag("demo");
+ let restored = !empty && !demo && startup.restores() && persist::restore(&mut session);
+ if !restored && !empty && (demo || startup.demo_when_nothing_restored()) {
let _ = session.execute("file.openDemoProject", json!({}));
}
api::set_info("restored", json!(restored));
@@ -284,7 +305,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
app = app.with_control(rx);
let backend = cc.wgpu_render_state.as_ref().map(|rs| format!("{:?}", rs.adapter.get_info().backend));
api::set_info("backend", json!(backend));
- Ok(Box::new(WebApp { app, persist: !empty }))
+ Ok(Box::new(WebApp { app, persist: keep_session, demo_start: !empty && !keep_session }))
}),
)
.await?;
diff --git a/apps/effectcraft-web/src/config_file.rs b/apps/effectcraft-web/src/config_file.rs
new file mode 100644
index 000000000..614d7d4cb
--- /dev/null
+++ b/apps/effectcraft-web/src/config_file.rs
@@ -0,0 +1,73 @@
+//! `./effectcraft-config.json`, fetched once per page load and shared by whoever reads a key.
+
+use std::cell::RefCell;
+
+use wasm_bindgen::{JsCast, closure::Closure};
+use wasm_bindgen_futures::JsFuture;
+
+use crate::startup::{self, Startup};
+
+/// Longest the request and the body read may take (ms); a stalled server must not hold up start-up.
+const TIMEOUT_MS: i32 = 5_000;
+
+thread_local! {
+ /// `None`: not fetched yet; `Some(None)`: no usable file.
+ static FILE: RefCell>>> = const { RefCell::new(None) };
+}
+
+/// The config file's bytes, or `None` when it is missing, too large, unreadable or the request
+/// takes longer than [`TIMEOUT_MS`] (the timer aborts the request and covers the body read).
+async fn fetch() -> Option> {
+ let win = web_sys::window()?;
+ let ctl = web_sys::AbortController::new().ok()?;
+ let init = web_sys::RequestInit::new();
+ // Always ask the server: the file is the operator's switch, never a cached copy.
+ init.set_cache(web_sys::RequestCache::NoCache);
+ init.set_signal(Some(&ctl.signal()));
+ let on_timeout = Closure::once(move || ctl.abort());
+ let timer = win.set_timeout_with_callback_and_timeout_and_arguments_0(on_timeout.as_ref().unchecked_ref(), TIMEOUT_MS).ok()?;
+ let out = read(&win, &init).await;
+ win.clear_timeout_with_handle(timer);
+ out
+}
+
+/// Fetch the file and read its body chunk by chunk, giving up as soon as it passes `CONFIG_MAX`.
+async fn read(win: &web_sys::Window, init: &web_sys::RequestInit) -> Option> {
+ let req = web_sys::Request::new_with_str_and_init("effectcraft-config.json", init).ok()?;
+ let resp: web_sys::Response = JsFuture::from(win.fetch_with_request(&req)).await.ok()?.dyn_into().ok()?;
+ if resp.status() != 200 {
+ return None;
+ }
+ let reader: web_sys::ReadableStreamDefaultReader = resp.body()?.get_reader().unchecked_into();
+ let mut out = Vec::new();
+ loop {
+ let chunk = JsFuture::from(reader.read()).await.ok()?;
+ if js_sys::Reflect::get(&chunk, &"done".into()).ok()?.as_bool() == Some(true) {
+ return Some(out);
+ }
+ let bytes: js_sys::Uint8Array = js_sys::Reflect::get(&chunk, &"value".into()).ok()?.dyn_into().ok()?;
+ if out.len().saturating_add(bytes.length() as usize) > startup::CONFIG_MAX {
+ // Stop the download: the limit is enforced while reading, not after the whole body arrived.
+ let _ = reader.cancel();
+ return None;
+ }
+ out.extend_from_slice(&bytes.to_vec());
+ }
+}
+
+/// The config file's bytes (`None` when there is none), fetched on the first call.
+pub async fn bytes() -> Option> {
+ if let Some(done) = FILE.with(|f| f.borrow().clone()) {
+ return done;
+ }
+ let got = fetch().await;
+ FILE.with(|f| *f.borrow_mut() = Some(got.clone()));
+ got
+}
+
+/// The configured startup behaviour.
+pub async fn startup() -> Startup {
+ let s = bytes().await.map(|b| startup::parse(&b)).unwrap_or_default();
+ log::info!("startup: {s:?}");
+ s
+}
diff --git a/apps/effectcraft-web/src/files.rs b/apps/effectcraft-web/src/files.rs
index 206314e4b..f6aac5aa4 100644
--- a/apps/effectcraft-web/src/files.rs
+++ b/apps/effectcraft-web/src/files.rs
@@ -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 {
+ Some(path.to_string())
+ }
}
/// Media import from the table (the bytes are also handed to the media pool, which decodes them).
diff --git a/apps/effectcraft-web/src/frames.rs b/apps/effectcraft-web/src/frames.rs
index c7f350526..69089e308 100644
--- a/apps/effectcraft-web/src/frames.rs
+++ b/apps/effectcraft-web/src/frames.rs
@@ -311,10 +311,11 @@ pub fn stats() -> Value {
})
}
+type PendingFrameCheck = (js_sys::Function, js_sys::Function, (u32, u32, Vec), f64);
+
thread_local! {
/// `workerFrameCheck` promises waiting for their frame: id → (resolve, reject, local frame).
- #[allow(clippy::type_complexity)]
- static CHECKS: RefCell), f64)>> = RefCell::new(Default::default());
+ static CHECKS: RefCell> = RefCell::new(Default::default());
}
/// `effectcraft.workerFrameCheck({time?, scale?, backend?})`: render the active comp's frame in
diff --git a/apps/effectcraft-web/src/lib.rs b/apps/effectcraft-web/src/lib.rs
index 97d2e887f..e1bf85894 100644
--- a/apps/effectcraft-web/src/lib.rs
+++ b/apps/effectcraft-web/src/lib.rs
@@ -18,10 +18,11 @@
//! | cpal audio output | Web Audio: an AudioWorklet fed from the preview mixdown (`audio`) |
//! | TCP control channel / MCP | `window.effectcraft` JavaScript API (`api`) |
//!
-//! [`store`] is portable (unit-tested natively); everything else is `wasm32`-only.
+//! [`store`] and [`startup`] are portable (unit-tested natively); everything else is `wasm32`-only.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+pub mod startup;
pub mod store;
#[cfg(target_arch = "wasm32")]
@@ -33,6 +34,8 @@ pub mod audio;
#[cfg(target_arch = "wasm32")]
pub mod browse;
#[cfg(target_arch = "wasm32")]
+pub mod config_file;
+#[cfg(target_arch = "wasm32")]
pub mod diskcache;
#[cfg(target_arch = "wasm32")]
pub mod files;
diff --git a/apps/effectcraft-web/src/startup.rs b/apps/effectcraft-web/src/startup.rs
new file mode 100644
index 000000000..4759096f3
--- /dev/null
+++ b/apps/effectcraft-web/src/startup.rs
@@ -0,0 +1,55 @@
+//! The `startup` key of `./effectcraft-config.json`: which project the page opens on load.
+//!
+//! | Value | On load |
+//! |---|---|
+//! | `"last"` (and absent) | the last visit's project; the demo project on a first visit |
+//! | `"empty"` | the last visit's project; a blank project on a first visit, never the demo |
+//! | `"demo"` | the demo project, whatever the last visit left |
+//!
+//! The `?empty` and `?demo` URL flags still win. Anything else (a missing key, another type, an
+//! unknown value, a config that is not JSON) is the default, `"last"`.
+
+use serde_json::Value;
+
+/// What the page opens on load.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum Startup {
+ #[default]
+ Last,
+ Empty,
+ Demo,
+}
+
+/// The largest config file read (bytes).
+pub const CONFIG_MAX: usize = 64 * 1024;
+
+/// The `startup` key of a config file's bytes.
+pub fn parse(bytes: &[u8]) -> Startup {
+ if bytes.len() > CONFIG_MAX {
+ return Startup::default();
+ }
+ let Ok(v) = serde_json::from_slice::(bytes) else { return Startup::default() };
+ match v.get("startup").and_then(Value::as_str) {
+ Some("empty") => Startup::Empty,
+ Some("demo") => Startup::Demo,
+ _ => Startup::Last,
+ }
+}
+
+impl Startup {
+ /// Whether the last visit's project is restored (off for the demo).
+ pub fn restores(self) -> bool {
+ self != Startup::Demo
+ }
+
+ /// Whether the session snapshot keeps being saved (off for the demo, so it never replaces the
+ /// project the last visit left).
+ pub fn keeps_session(self) -> bool {
+ self != Startup::Demo
+ }
+
+ /// Whether the demo project opens when nothing was restored (off for a blank start).
+ pub fn demo_when_nothing_restored(self) -> bool {
+ self != Startup::Empty
+ }
+}
diff --git a/apps/effectcraft-web/src/store.rs b/apps/effectcraft-web/src/store.rs
index 8aafdac1c..c5c4ada94 100644
--- a/apps/effectcraft-web/src/store.rs
+++ b/apps/effectcraft-web/src/store.rs
@@ -366,6 +366,21 @@ impl ConfigStore for WebConfig {
v.sort();
v
}
+ /// The first name part of the keys under `dir/`.
+ fn list_dirs(&self, dir: &str) -> Vec {
+ let pre = format!("{CONFIG}{}/", dir.trim_end_matches('/'));
+ let mut v: Vec = self
+ .store
+ .lock()
+ .list(&pre)
+ .into_iter()
+ .filter_map(|(k, ..)| k.strip_prefix(&pre)?.split_once('/').map(|(d, _)| d.to_string()))
+ .filter(|d| !d.is_empty())
+ .collect();
+ v.sort();
+ v.dedup();
+ v
+ }
fn dir(&self) -> Option {
Some(PathBuf::from("/"))
}
@@ -533,6 +548,22 @@ mod tests {
assert_eq!(s.file("/clip.mp4").as_deref(), Some(&b"abc"[..]));
}
+ /// Scripts in a subfolder of the Scripts folder (`Tools/Hello.jsx`) are listed, as on the desktop.
+ #[test]
+ fn config_store_lists_folders() {
+ let c = WebConfig::new(store());
+ for name in ["Scripts/Top.jsx", "Scripts/Tools/Hello.jsx", "Scripts/Tools/More.jsx", "Scripts/ScriptUI Panels/Kit/A.jsx"] {
+ c.write(name, "x").unwrap();
+ }
+ assert_eq!(c.list("Scripts"), ["Top.jsx"]);
+ assert_eq!(c.list_dirs("Scripts"), ["ScriptUI Panels", "Tools"]);
+ assert_eq!(c.list_dirs("Scripts/ScriptUI Panels"), ["Kit"]);
+ let mut s = effectcraft_engine::Session { config: Some(Arc::new(c)), ..Default::default() };
+ let list = s.execute("file.scripts.list", serde_json::json!({})).unwrap();
+ let installed: Vec<&str> = list.as_array().unwrap().iter().filter(|e| e["source"] == "installed").filter_map(|e| e["name"].as_str()).collect();
+ assert_eq!(installed, ["Tools/Hello.jsx", "Tools/More.jsx", "Top.jsx", "Kit/A.jsx"]);
+ }
+
#[test]
fn config_store_and_file_ops() {
let s = store();
diff --git a/apps/effectcraft-web/tests/it/main.rs b/apps/effectcraft-web/tests/it/main.rs
new file mode 100644
index 000000000..a4d242ba5
--- /dev/null
+++ b/apps/effectcraft-web/tests/it/main.rs
@@ -0,0 +1 @@
+mod startup_tests;
diff --git a/apps/effectcraft-web/tests/it/startup_tests.rs b/apps/effectcraft-web/tests/it/startup_tests.rs
new file mode 100644
index 000000000..3ce90569c
--- /dev/null
+++ b/apps/effectcraft-web/tests/it/startup_tests.rs
@@ -0,0 +1,52 @@
+//! The `startup` key of the config file.
+
+use effectcraft_web::startup::{CONFIG_MAX, Startup, parse};
+
+/// The three accepted values parse.
+#[test]
+fn the_three_values() {
+ assert_eq!(parse(br#"{"startup":"empty"}"#), Startup::Empty);
+ assert_eq!(parse(br#"{"startup":"demo"}"#), Startup::Demo);
+ assert_eq!(parse(br#"{"startup":"last"}"#), Startup::Last);
+}
+
+/// A missing key, a wrong type, an unknown value or a non-object file gives `Last`.
+#[test]
+fn absent_or_invalid_is_the_default() {
+ for bytes in
+ [&b"{}"[..], b"", b"not json", b"[]", b"null", br#"{"startup":1}"#, br#"{"startup":"Empty"}"#, br#"{"startup":null}"#, br#"{"startup":["empty"]}"#]
+ {
+ assert_eq!(parse(bytes), Startup::Last, "{:?}", String::from_utf8_lossy(bytes));
+ }
+}
+
+/// Other keys do not hide `startup`.
+#[test]
+fn a_config_with_other_keys_still_has_its_startup() {
+ assert_eq!(parse(br#"{"remote":{"mounts":[]},"startup":"empty"}"#), Startup::Empty);
+ assert_eq!(parse(br#"{"startup":"demo","unknown":{"a":1}}"#), Startup::Demo);
+}
+
+/// A file over `CONFIG_MAX` is ignored.
+#[test]
+fn an_oversized_file_is_the_default() {
+ let mut big = br#"{"startup":"empty","pad":""#.to_vec();
+ big.extend(std::iter::repeat_n(b'x', CONFIG_MAX));
+ big.extend_from_slice(br#""}"#);
+ assert_eq!(parse(&big), Startup::Last);
+}
+
+/// Which project each value restores or opens.
+#[test]
+fn what_each_value_does_on_load() {
+ assert!(Startup::Last.restores() && Startup::Last.demo_when_nothing_restored());
+ assert!(Startup::Empty.restores() && !Startup::Empty.demo_when_nothing_restored());
+ assert!(!Startup::Demo.restores() && Startup::Demo.demo_when_nothing_restored());
+}
+
+/// Only the explicit demo start stops saving the session.
+#[test]
+fn only_the_demo_start_leaves_the_saved_session_alone() {
+ assert!(Startup::Last.keeps_session() && Startup::Empty.keeps_session());
+ assert!(!Startup::Demo.keeps_session());
+}
diff --git a/apps/effectcraft-web/tests/page.mjs b/apps/effectcraft-web/tests/page.mjs
index 063015236..3728e59e2 100644
--- a/apps/effectcraft-web/tests/page.mjs
+++ b/apps/effectcraft-web/tests/page.mjs
@@ -8,6 +8,8 @@
// - A lost WebGPU device: the page says so over the canvas and offers Save Project and Reload;
// the reload writes pending changes to browser storage first, and a failed write is reported
// instead of reloading (#225).
+// - Duplicate cancels the browser's bookmark action on the app canvas, while still delivering
+// the key to the app; other chords and HTML inputs keep their browser behavior (#2495).
// Exits non-zero on failure.
import assert from "node:assert/strict";
import { dirname, join } from "node:path";
@@ -58,6 +60,30 @@ globalThis.location = { reload: () => reloads++ };
const host = await import(pathToFileURL(join(here, "../js/host.js")));
+// #2495: prevent the browser default without swallowing the app's Duplicate key.
+const canvas = new EventTarget();
+const input = new EventTarget();
+host.installCanvasShortcuts(canvas);
+let delivered = 0;
+canvas.addEventListener("keydown", () => delivered++);
+const key = (target, properties) => {
+ const event = new Event("keydown", { cancelable: true });
+ Object.assign(event, { key: "d", ...properties });
+ target.dispatchEvent(event);
+ return event.defaultPrevented;
+};
+assert.equal(key(canvas, { ctrlKey: true }), true);
+assert.equal(key(canvas, { metaKey: true, key: "D" }), true);
+for (const properties of [{}, { ctrlKey: true, shiftKey: true }, { ctrlKey: true, altKey: true }, { ctrlKey: true, isComposing: true }, { ctrlKey: true, key: "r" }]) {
+ assert.equal(key(canvas, properties), false);
+}
+// Non-Latin layouts: the physical D key types "в", and eframe still reads it as D.
+assert.equal(key(canvas, { ctrlKey: true, key: "в", code: "KeyD" }), true);
+// Dvorak: the physical D key types "e", which is not Duplicate.
+assert.equal(key(canvas, { ctrlKey: true, key: "e", code: "KeyD" }), false);
+assert.equal(delivered, 9, "all keys still reach the app");
+assert.equal(key(input, { ctrlKey: true }), false, "an external HTML input keeps its shortcut");
+
// #226: one readable and one unreadable file.
const file = (name, read) => ({ name, webkitRelativePath: "", arrayBuffer: read });
const picked = host.browsePickFiles(false);
diff --git a/apps/effectcraft-web/web/sw.js b/apps/effectcraft-web/web/sw.js
index 4acbeeeeb..8f48a93bc 100644
--- a/apps/effectcraft-web/web/sw.js
+++ b/apps/effectcraft-web/web/sw.js
@@ -3,7 +3,8 @@
// `cargo xtask web` fills in the build's file list and version. Every file is precached into a
// cache named after the version, served cache-first (responses keep the server's headers, so
// the page stays cross-origin isolated: COOP/COEP), and old versions are dropped once the new
-// one activates. Anything else same-origin is fetched from the network and cached for next time.
+// one activates. Anything else same-origin is fetched from the network and cached for next time,
+// except the config file, which must stay live.
const VERSION = "__EC_VERSION__";
const FILES = __EC_FILES__;
const CACHE = `effectcraft-${VERSION}`;
@@ -30,6 +31,7 @@ self.addEventListener("fetch", (event) => {
const req = event.request;
const url = new URL(req.url);
if (req.method !== "GET" || url.origin !== self.location.origin) return;
+ if (url.pathname.endsWith("/effectcraft-config.json")) return;
event.respondWith(
(async () => {
const cache = await caches.open(CACHE).catch(() => null);
diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs
index 6df015290..bdbdcda94 100644
--- a/apps/effectcraft/src/main.rs
+++ b/apps/effectcraft/src/main.rs
@@ -9,8 +9,8 @@
//!
//! `--control ` (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;
@@ -263,6 +263,9 @@ fn main() -> eframe::Result {
// Fit the window to its monitor, or put it back where it was (still hidden).
let saved_window = session.config.as_deref().and_then(window::load);
let opened = cc.winit_window().map(|w| window::restore(w, saved_window));
+ // Scripts in Scripts/Startup run before a project opens (a failing one is reported in
+ // a toast).
+ let _ = session.execute("file.runStartupScripts", json!({}));
let recovery = if control_port.is_none() { session.begin_recovery() } else { None };
let project = files.iter().find(|f| is_project_file(f)).cloned();
if let Some(p) = project {
@@ -365,14 +368,11 @@ fn main() -> eframe::Result {
}))
}),
);
- match &result {
- Ok(()) => log::info!("the window closed"),
- Err(e) => {
- if on_gl {
- launch_guard::opengl_failed(effectcraft_host::config_dir().as_deref());
- }
- startup_failed(e, !on_gl && cfg!(not(target_os = "macos")));
+ if let Err(e) = &result {
+ if on_gl {
+ launch_guard::opengl_failed(effectcraft_host::config_dir().as_deref());
}
+ startup_failed(e, !on_gl && cfg!(not(target_os = "macos")));
}
result
}
@@ -381,9 +381,15 @@ fn main() -> eframe::Result {
/// instead of eframe's fixed set: an older GPU that can't meet those (Intel HD Graphics 5500,
/// #198) still opens the app, and the compositor then checks what it needs and falls back to
/// the CPU when the device can't run it. `backends`: Window Graphics' choice, when it makes one.
+///
+/// The surface is eframe's own default, [`eframe::SurfaceConfig::LOW_LATENCY`]: vsync with one
+/// frame in flight, so a drag or a scrub shows one frame sooner. The bare
+/// `WgpuConfiguration::default()` is `HIGH_THROUGHPUT` instead (two frames in flight, three
+/// drawables on Metal): one more frame of pointer-to-screen lag on every interaction, about 8 ms
+/// at 120 Hz and 17 ms at 60 Hz, and a third 15 MB drawable held for the window.
fn wgpu_options(backends: Option) -> eframe::WgpuConfiguration {
use eframe::egui_wgpu::WgpuSetup;
- let mut config = eframe::WgpuConfiguration::default();
+ let mut config = eframe::WgpuConfiguration::default().with_surface_config(eframe::SurfaceConfig::LOW_LATENCY);
if let WgpuSetup::CreateNew(setup) = &mut config.wgpu_setup {
if let Some(b) = backends {
setup.instance_descriptor.backends = b;
@@ -455,7 +461,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 +471,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,9 +492,14 @@ 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();
+ // Scripts in Scripts/Shutdown run with the project closed.
+ let _ = self.app.session.execute("file.runShutdownScripts", json!({}));
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
@@ -565,6 +578,19 @@ mod tests {
assert!(!setup.instance_descriptor.flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));
}
+ /// The window's surface keeps one frame in flight, like eframe's own default, not the two
+ /// of the bare `WgpuConfiguration::default()` (one extra frame of lag on every drag).
+ #[test]
+ fn the_window_surface_is_low_latency() {
+ for backends in [None, Some(eframe::wgpu::Backends::METAL), Some(eframe::wgpu::Backends::GL)] {
+ let surface = super::wgpu_options(backends).surface;
+ assert_eq!(surface, eframe::SurfaceConfig::LOW_LATENCY, "{backends:?}");
+ assert_eq!(surface.desired_maximum_frame_latency, Some(1));
+ assert_eq!(surface.present_mode, eframe::wgpu::PresentMode::AutoVsync);
+ }
+ assert_eq!(super::wgpu_options(None).surface, eframe::NativeOptions::default().wgpu_options.surface, "eframe's default surface");
+ }
+
/// The window's device never asks for more than the adapter offers (#198).
#[test]
fn device_limits_fit_the_adapter() {
diff --git a/apps/effectcraft/tests/args.rs b/apps/effectcraft/tests/args.rs
index 41281eb23..62c591b45 100644
--- a/apps/effectcraft/tests/args.rs
+++ b/apps/effectcraft/tests/args.rs
@@ -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};
diff --git a/clippy.toml b/clippy.toml
index 287e14edc..3522a15b6 100644
--- a/clippy.toml
+++ b/clippy.toml
@@ -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
diff --git a/contributors/people.toml b/contributors/people.toml
index 1012a37bd..59365207a 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -17,11 +17,19 @@
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
+[people.Krueels]
+sync_github_name = true
+
[people.echelon]
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
+[people.lewandowskit]
+real_name = "Tomasz Lewandowski"
+display_name = "Tomasz Lewandowski"
+sync_github_name = false
+
[people.switch87]
real_name = "Gert Pellin"
display_name = "Gert Pellin"
@@ -98,3 +106,11 @@ sync_github_name = false
[people.ki2pixel]
display_name = "ki2pixel"
+
+[people.becauseno7]
+real_name = "Shuvra Basak"
+display_name = "Shuvra Basak"
+sync_github_name = false
+
+[people.ashokram-si]
+real_name = "Ashok Ram"
diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs
index f396b3da0..06b83a880 100644
--- a/crates/automation/src/confine.rs
+++ b/crates/automation/src/confine.rs
@@ -7,7 +7,9 @@
//! the roots (symbolic links resolved, no `..`), any other absolute path in its parameters too,
//! and commands that write to a file they are not given (Save, Increment and Save, rendering the
//! Render Queue) are checked against where they would write. Commands that run code or open
-//! other applications (scripts, Execute File, Reveal in Finder…) are refused while confined.
+//! other applications (scripts, ScriptUI panels and the events that reach them, Execute File,
+//! Reveal in Finder…) are refused while confined, as is granting scripts the file and network
+//! access the user withheld.
use std::path::{Component, Path, PathBuf};
@@ -28,13 +30,21 @@ pub struct Confinement {
write: Vec,
}
-/// Commands that run code, act outside the project or open other applications.
+/// Commands that run code, act outside the project or open other applications, besides the
+/// commands that run scripts ([`effectcraft_engine::commands::scripts::RUNS_SCRIPTS`]).
const REFUSED: &[&str] = &[
- "script.run",
- "file.runScript",
"file.installScript",
"file.installScriptUIPanel",
"file.uninstallScript",
+ // A ScriptUI panel's script runs when the panel opens, when an event reaches one of its
+ // controls, and when one of its `app.scheduleTask` tasks comes due.
+ "window.scriptPanel",
+ "window.restoreScriptPanels",
+ "script.runTasks",
+ "scriptui.click",
+ "scriptui.set",
+ "scriptui.mouse",
+ "scriptui.close",
"file.executeFile",
"file.revealInFinder",
"layer.openSource",
@@ -64,10 +74,29 @@ const READS: &[(&str, &[&str])] = &[
("effect.plugins.load", &["path", "folder"]),
("mediaBrowser.go", &["path"]),
("mediaBrowser.addFavorite", &["path"]),
+ // #1243: these read the files they are given but were missing, so a relative path went
+ // unchecked.
+ ("essential.templateInfo", &["path"]),
+ ("file.importProject", &["path"]),
+ ("file.watchFolder.poll", &["folder"]),
+ ("footage.relink", &["path"]),
+ ("layer.newModel", &["path"]),
+ ("mediaBrowser.fileInfo", &["path"]),
+ ("mediaBrowser.list", &["path"]),
+ ("mediaBrowser.import", &["paths"]),
+ ("mediaBrowser.removeFavorite", &["path"]),
+ ("face.model.install", &["path"]),
+ ("roto.model.install", &["path"]),
+ // Reads the template at `path`; what it writes is in `MIXED_WRITES`.
+ ("templates.create", &["path"]),
];
+/// Commands that read some parameters (listed in [`READS`]) and write others.
+const MIXED_WRITES: &[(&str, &[&str])] = &[("templates.create", &["projectPath", "footageDir"])];
+
/// Commands that write files named by these parameters.
const WRITES: &[(&str, &[&str])] = &[
+ ("camera.exportBlender", &["path"]),
("file.save", &["path"]),
("file.saveAs", &["path"]),
("file.saveCopy", &["path"]),
@@ -84,6 +113,9 @@ const WRITES: &[(&str, &[&str])] = &[
("essential.exportTemplate", &["path"]),
("shortcuts.export", &["path"]),
("renderQueue.add", &["output", "path"]),
+ ("file.exportTimeline", &["path"]),
+ // `path` / `output` may also be a file-name template, as for render.preRender.
+ ("file.createProxy", &["path", "output"]),
];
/// Commands that write where they are not told: the project's file, the Render Queue's outputs.
@@ -135,16 +167,26 @@ impl Confinement {
/// Check engine command `id` with `params` before it runs. Returns what must still be looked
/// up and passed to [`Confinement::check_lookup`] for commands that write implicitly.
pub fn check_command(&self, id: &str, params: &Value) -> Result> {
- if REFUSED.contains(&id) {
+ if REFUSED.contains(&id) || effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id) {
return Err(Error::BadArgs(format!("`{id}` is not available while file access is confined (--automation-read-root/--automation-write-root)")));
}
+ // Scripts can't grant themselves the access the user withheld
+ // (`effectcraft_engine::prefs::Scripting::same_permissions`), and neither may a confined
+ // agent: `prefs.set` saves the settings file, so a script run long after this session
+ // would still have it. (`prefs.reset` only puts the defaults back, which grant nothing.)
+ if id == "prefs.set" && prefs_keys(params).iter().any(|k| is_scripting_pref(k)) {
+ return Err(Error::BadArgs(
+ "`prefs.set`: the Scripting settings decide what a script may do, so they can't be changed while file access is confined".into(),
+ ));
+ }
if id == "engine.batch" {
for step in params.get("steps").and_then(Value::as_array).into_iter().flatten() {
let Some(inner) = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str) else { continue };
let inner_params = step.get("params").cloned().unwrap_or(Value::Null);
let named = keys_of(READS, inner).or_else(|| keys_of(WRITES, inner)).unwrap_or(&[]);
+ let mixed = keys_of(MIXED_WRITES, inner).unwrap_or(&[]);
// A step's `$N` reference is resolved only as the batch runs: never for a file.
- if named.iter().filter_map(|k| inner_params.get(*k)).any(has_reference) {
+ if named.iter().chain(mixed).filter_map(|k| inner_params.get(*k)).any(has_reference) {
return Err(Error::BadArgs(format!("`{inner}` in a batch: file paths can't be `$N` references while file access is confined")));
}
if self.check_command(inner, &inner_params)?.is_some() {
@@ -170,7 +212,7 @@ impl Confinement {
for p in paths {
// A Render Queue output may name a template instead of a file.
match p.as_str() {
- Some(s) if id == "render.preRender" && !looks_like_path(s) => {}
+ Some(s) if matches!(id, "render.preRender" | "file.createProxy") && !looks_like_path(s) => {}
Some(s) => {
self.check(s, access)?;
}
@@ -178,9 +220,18 @@ impl Confinement {
}
}
}
+ // Parameters of a reading command that it writes to.
+ let mixed = keys_of(MIXED_WRITES, id).unwrap_or(&[]);
+ for key in mixed {
+ if let Some(p) = params.get(*key).filter(|v| !v.is_null()) {
+ let p = p.as_str().ok_or_else(|| Error::BadArgs(format!("`{id}`: `{key}` must be a path")))?;
+ self.check(p, Access::Write)?;
+ }
+ }
// Any other absolute path the command is given.
+ let named: Vec<&str> = named.iter().chain(mixed).copied().collect();
let mut other = vec![];
- absolute_strings(params, named, &mut other);
+ absolute_strings(params, &named, &mut other);
for s in other {
self.check(&s, access)?;
}
@@ -218,6 +269,22 @@ impl Confinement {
}
}
+/// Whether a `prefs.set` key reaches the Scripting page. `Prefs::set` turns the key into a JSON
+/// pointer (`key.replace('.', "/")`), so `scripting/allowScriptsWriteFiles` resolves as well as
+/// the dotted spelling, and the page on its own replaces every setting on it.
+fn is_scripting_pref(key: &str) -> bool {
+ key.split(['.', '/']).next().is_some_and(|page| page.eq_ignore_ascii_case("scripting"))
+}
+
+/// The settings `prefs.set` is given: `key`, and the keys of `values`.
+fn prefs_keys(params: &Value) -> Vec<&str> {
+ let mut keys: Vec<&str> = params.get("key").and_then(Value::as_str).into_iter().collect();
+ if let Some(Value::Object(m)) = params.get("values") {
+ keys.extend(m.keys().map(String::as_str));
+ }
+ keys
+}
+
fn keys_of(table: &[(&str, &'static [&'static str])], id: &str) -> Option<&'static [&'static str]> {
table.iter().find(|(c, _)| *c == id).map(|(_, k)| *k)
}
@@ -274,3 +341,115 @@ fn resolve(p: &Path) -> Option {
at = at.parent()?;
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Commands whose parameters are named like files but aren't: property paths
+ /// (`path|prop`), project folders, layers to track or target.
+ const NOT_FILES: &[&str] = &[
+ "camera.createFromSolve",
+ "edit.label",
+ "edit.pasteReversedKeyframes",
+ "effect.editDropdown",
+ "essential.addProperty",
+ "essential.canAdd",
+ "essential.linkProperty",
+ "essential.unlinkProperty",
+ "keys.paste",
+ "keys.set",
+ "keys.setEase",
+ "keys.setSpatialTangents",
+ "paths.nullsFollowPoints",
+ "paths.pointsFollowNulls",
+ "paths.tracePath",
+ "project.move",
+ "prop.addKey",
+ "prop.convertExpressionToKeyframes",
+ "prop.deselect",
+ "prop.editGradient",
+ "prop.get",
+ "prop.pickWhip",
+ "prop.reset",
+ "prop.select",
+ "prop.set",
+ "prop.setExpression",
+ "prop.toggleAnimation",
+ "prop.toggleKey",
+ "track.new",
+ "track.property",
+ "track.setTarget",
+ ];
+
+ /// Every engine command with a parameter named like a file is classified, so a new one
+ /// can't silently skip confinement (#1243): it reads or writes the files it is given, is
+ /// refused, writes where it isn't told (checked by a lookup), sets Render Queue outputs
+ /// (checked when the queue renders), or is listed in `NOT_FILES`.
+ #[test]
+ fn every_command_with_file_parameters_is_classified() {
+ const FILE_KEYS: &[&str] =
+ &["path", "paths", "folder", "file", "files", "dir", "directory", "output", "outputs", "source", "target", "projectPath", "footageDir"];
+ let mut missing = vec![];
+ for spec in effectcraft_engine::command_specs() {
+ let Some(params) = effectcraft_engine::commands::accepted_params(spec.params) else { continue };
+ let id = spec.id;
+ let classified = keys_of(READS, id).is_some()
+ || keys_of(WRITES, id).is_some()
+ || REFUSED.contains(&id)
+ || effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id)
+ || IMPLICIT_WRITES.contains(&id)
+ || id.starts_with("renderQueue.")
+ || id == "render.addOutputModule"
+ || NOT_FILES.contains(&id);
+ if params.iter().any(|p| FILE_KEYS.contains(&p.as_str())) && !classified {
+ missing.push(format!("{id} {}", spec.params));
+ }
+ }
+ assert!(missing.is_empty(), "add these to READS / WRITES / REFUSED or NOT_FILES:\n{}", missing.join("\n"));
+ // And every listed command exists.
+ for id in READS.iter().chain(WRITES).chain(MIXED_WRITES).map(|(c, _)| *c).chain(NOT_FILES.iter().copied()) {
+ assert!(effectcraft_engine::command_specs().iter().any(|s| s.id == id), "{id} is not a command");
+ }
+ }
+
+ #[test]
+ fn relative_and_outside_paths_of_newly_listed_commands_are_refused() {
+ let base = std::env::temp_dir().join(format!("ec-confine-1243-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&base);
+ let (read, write, outside) = (base.join("in"), base.join("out"), base.join("elsewhere"));
+ for d in [&read, &write, &outside] {
+ std::fs::create_dir_all(d).unwrap();
+ }
+ let c = Confinement::new(std::slice::from_ref(&read), std::slice::from_ref(&write)).unwrap();
+ let p = |d: &std::path::Path, f: &str| d.join(f).to_string_lossy().into_owned();
+ let up = format!("{}/../elsewhere/t.ectemplate", read.display());
+ for (id, key) in [
+ ("essential.templateInfo", "path"),
+ ("file.importProject", "path"),
+ ("footage.relink", "path"),
+ ("layer.newModel", "path"),
+ ("mediaBrowser.fileInfo", "path"),
+ ("mediaBrowser.list", "path"),
+ ("face.model.install", "path"),
+ ("templates.create", "path"),
+ ] {
+ for bad in ["t.ectemplate".to_string(), "../elsewhere/t.ectemplate".to_string(), up.clone(), p(&outside, "t.ectemplate")] {
+ assert!(c.check_command(id, &serde_json::json!({key: bad})).is_err(), "{id} {key}={bad}");
+ }
+ assert!(c.check_command(id, &serde_json::json!({key: p(&read, "t.ectemplate")})).is_ok(), "{id} in a read root");
+ }
+ // Batched too.
+ let steps = serde_json::json!({"steps": [{"command": "essential.templateInfo", "params": {"path": "t.ectemplate"}}]});
+ assert!(c.check_command("engine.batch", &steps).is_err());
+ // Writes: into a write root only.
+ assert!(c.check_command("file.exportTimeline", &serde_json::json!({"path": p(&read, "cut.xml")})).is_err());
+ assert!(c.check_command("file.exportTimeline", &serde_json::json!({"path": p(&write, "cut.xml")})).is_ok());
+ let create = |project: String| serde_json::json!({"path": p(&read, "t.ectemplate"), "projectPath": project});
+ assert!(c.check_command("templates.create", &create(p(&read, "new.ecproj"))).is_err(), "templates.create writes projectPath");
+ assert!(c.check_command("templates.create", &create(p(&write, "new.ecproj"))).is_ok());
+ assert!(c.check_command("file.createProxy", &serde_json::json!({"output": "[compName]_proxy"})).is_ok(), "a template name");
+ assert!(c.check_command("file.createProxy", &serde_json::json!({"output": p(&outside, "proxy.mov")})).is_err());
+ let _ = std::fs::remove_dir_all(&base);
+ }
+}
diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs
index 6e622b377..1121c5d91 100644
--- a/crates/automation/src/tests.rs
+++ b/crates/automation/src/tests.rs
@@ -491,6 +491,8 @@ fn bridge_forwards_to_control_channel() {
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "B", "width": 64, "height": 64}}));
assert_eq!(call_json(&mut s, "get_comp", json!({}))["name"], "B");
+ let cmds = call_json(&mut s, "list_commands", json!({"filter": "layer.newSolid", "compact": true}));
+ assert!(cmds.as_array().unwrap().contains(&json!({"id": "layer.newSolid", "label": "Solid..."})), "{cmds}");
let (c, err) = call(&mut s, "render_frame", json!({}));
assert!(!err, "{c:?}");
@@ -738,6 +740,40 @@ fn every_engine_command_is_reachable_over_mcp() {
}
}
+/// `compact: true` shrinks the listings for MCP clients' tool-result limits (#2020): commands with
+/// only `id` and `label`, effects with only `id`, `name` and `category`.
+#[test]
+fn compact_listings_keep_only_ids_and_names() {
+ let mut s = server();
+ let cmds = call_json(&mut s, "list_commands", json!({"filter": "layer.new", "compact": true}));
+ assert!(cmds.as_array().unwrap().contains(&json!({"id": "layer.newSolid", "label": "Solid..."})), "{cmds}");
+ let fx = call_json(&mut s, "list_effects", json!({"filter": "gaussian", "compact": true}));
+ assert!(fx.as_array().unwrap().contains(&json!({"id": "ec.blur.gaussian", "name": "Gaussian Blur", "category": "Blur & Sharpen"})), "{fx}");
+ // Each compact listing is the full one with the same arguments, entry for entry; `compact`
+ // wins over `schemas`.
+ let check = |s: &mut McpServer, tool: &str, args: Value, keys: &[&str]| {
+ let full = call_json(s, tool, args.clone());
+ let want: Value = full.as_array().unwrap().iter().map(|e| Value::Object(keys.iter().map(|&k| (k.to_string(), e[k].clone())).collect())).collect();
+ let mut compact = args.clone();
+ compact["compact"] = json!(true);
+ assert_eq!(call_json(s, tool, compact), want, "{tool} {args}");
+ };
+ for args in [json!({}), json!({"filter": "layer.new"}), json!({"enabled_only": true}), json!({"schemas": true})] {
+ check(&mut s, "list_commands", args.clone(), &["id", "label"]);
+ check(&mut s, "command_list", args, &["id", "label"]);
+ }
+ check(&mut s, "list_effects", json!({}), &["id", "name", "category"]);
+ check(&mut s, "list_effects", json!({"filter": "gaussian"}), &["id", "name", "category"]);
+ // `compact: false` is the default listing, byte for byte; `compact: true` is a fraction of it.
+ let text = |s: &mut McpServer, tool: &str, args: Value| call(s, tool, args).0[0]["text"].as_str().unwrap().to_string();
+ for (tool, ratio) in [("list_commands", 2), ("list_effects", 10)] {
+ let full = text(&mut s, tool, json!({}));
+ assert_eq!(text(&mut s, tool, json!({"compact": false})), full, "{tool}");
+ let compact = text(&mut s, tool, json!({"compact": true})).len();
+ assert!(compact * ratio < full.len(), "{tool}: {compact} of {} bytes", full.len());
+ }
+}
+
#[test]
fn high_level_tools_add_effect_and_report_state() {
let mut s = server();
@@ -1376,7 +1412,103 @@ fn automation_roots_confine_file_access() {
// Scripts can read and write anything: refused.
denied(&mut s, "run_script", json!({"code": "1 + 1"}));
denied(&mut s, "execute_command", json!({"command": "file.runScript", "params": {"path": p(&read, "x.jsx")}}));
+ for (id, params) in [
+ ("file.runStartupScripts", json!({})),
+ ("file.runShutdownScripts", json!({})),
+ // ScriptUI panels run their script when they open.
+ ("window.scriptPanel", json!({"name": "Layer Tools.jsx"})),
+ ("window.restoreScriptPanels", json!({})),
+ ] {
+ let msg = denied(&mut s, "execute_command", json!({"command": id, "params": params}));
+ assert!(msg.contains("not available while file access is confined"), "{id}: {msg}");
+ let msg = denied(&mut s, "batch", json!({"steps": [{"command": id, "params": params}]}));
+ assert!(msg.contains("not available while file access is confined"), "{id}: {msg}");
+ }
// Writes to the files the agent named: none outside.
assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1, "only the unconfined save");
let _ = std::fs::remove_dir_all(&base);
}
+
+/// #2131 (audit F-11, "Running code"): `script.run` and the Scripts menu are refused while file
+/// access is confined, but a ScriptUI panel's script runs just as freely — when the panel opens,
+/// when an event reaches one of its controls, or when one of its tasks comes due — and
+/// `prefs.set` could grant every script run afterwards the file and network access the user
+/// withheld, in the settings file, long after the session. All of that is refused too.
+#[test]
+fn automation_roots_refuse_running_script_code() {
+ use crate::confine::Confinement;
+ let base = std::env::temp_dir().join(format!("ec-mcp-scripts-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&base);
+ std::fs::create_dir_all(&base).unwrap();
+ let mut s = server();
+ s.backend().confine(Confinement::new(&[], std::slice::from_ref(&base)).unwrap());
+ let refused = |s: &mut McpServer, name: &str, args: Value| {
+ let (c, err) = call(s, name, args.clone());
+ assert!(err, "{name} {args} should be refused: {c:?}");
+ c[0]["text"].as_str().unwrap_or_default().to_string()
+ };
+ // Every way a script's code starts.
+ for (command, params) in [
+ ("window.scriptPanel", json!({"name": "Layer Tools.jsx"})),
+ ("window.restoreScriptPanels", json!({})),
+ ("script.runTasks", json!({})),
+ ("scriptui.click", json!({"window": 1, "widget": 2})),
+ ("scriptui.set", json!({"window": 1, "widget": 2, "value": 1})),
+ ("scriptui.mouse", json!({"window": 1, "widget": 2, "type": "click"})),
+ ("scriptui.close", json!({"window": 1})),
+ ] {
+ let msg = refused(&mut s, "execute_command", json!({"command": command, "params": params}));
+ assert!(msg.contains("not available while file access is confined"), "`{command}`: {msg}");
+ }
+ // Including as a batch step.
+ let msg = refused(&mut s, "batch", json!({"steps": [{"command": "window.scriptPanel", "params": {"name": "x.jsx"}}]}));
+ assert!(msg.contains("not available while file access is confined"), "{msg}");
+ // What scripts may do stays as the user left it, by either spelling of the setting.
+ for params in [
+ json!({"key": "scripting.allowScriptsWriteFiles", "value": true}),
+ json!({"key": "scripting.allow_scripts_write_files", "value": true}),
+ json!({"values": {"scripting.warnExecutingFiles": false}}),
+ json!({"values": {"general.showToolTips": false, "scripting.enableJsDebugger": true}}),
+ // `Prefs::set` makes a JSON pointer of the key, so this spelling resolves too…
+ json!({"key": "scripting/allowScriptsWriteFiles", "value": true}),
+ // …and the page on its own replaces every setting on it.
+ json!({"key": "scripting", "value": {"allowScriptsWriteFiles": true}}),
+ json!({"values": {"scripting": {"warnExecutingFiles": false}}}),
+ ] {
+ let msg = refused(&mut s, "execute_command", json!({"command": "prefs.set", "params": params}));
+ assert!(msg.contains("what a script may do"), "{msg}");
+ }
+ let got = call_json(&mut s, "execute_command", json!({"command": "prefs.get", "params": {"key": "scripting.allowScriptsWriteFiles"}}));
+ assert_eq!(got, json!(false), "{got}");
+ // Other settings, and looking at the script windows, still work.
+ call_json(&mut s, "execute_command", json!({"command": "prefs.set", "params": {"key": "general.showToolTips", "value": false}}));
+ call_json(&mut s, "execute_command", json!({"command": "scriptui.list"}));
+ let _ = std::fs::remove_dir_all(&base);
+}
+
+#[test]
+fn blender_export_requires_an_absolute_path_in_a_write_root() {
+ use crate::confine::Confinement;
+ let base = std::env::temp_dir().join(format!("ec-blender-confine-{}", std::process::id()));
+ let (read, write) = (base.join("read"), base.join("write"));
+ for dir in [&read, &write] {
+ std::fs::create_dir_all(dir).unwrap();
+ }
+ let confinement = Confinement::new(std::slice::from_ref(&read), std::slice::from_ref(&write)).unwrap();
+ let check = |path: &std::path::Path| confinement.check_command("camera.exportBlender", &json!({"path": path}));
+ assert_eq!(check(&write.join("scene.py")).unwrap(), None);
+ for path in [read.join("scene.py"), base.join("outside.py"), write.join("../read/scene.py")] {
+ assert!(check(&path).is_err(), "must deny {}", path.display());
+ }
+ for path in ["scene.py", "./scene.py", "write/scene.py", "../scene.py"] {
+ let error = check(std::path::Path::new(path)).unwrap_err().to_string();
+ assert!(error.contains("give an absolute path"), "{path}: {error}");
+ }
+ let read_only = Confinement::new(std::slice::from_ref(&write), &[]).unwrap();
+ assert!(read_only.check_command("camera.exportBlender", &json!({"path": write.join("scene.py")})).is_err());
+ for path in [json!(read.join("scene.py")), json!("scene.py"), json!("$1.path")] {
+ let batch = json!({"steps": [{"command": "camera.exportBlender", "params": {"path": path}}]});
+ assert!(confinement.check_command("engine.batch", &batch).is_err());
+ }
+ std::fs::remove_dir_all(&base).unwrap();
+}
diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs
index a83e0d1ec..1d6074247 100644
--- a/crates/automation/src/tools.rs
+++ b/crates/automation/src/tools.rs
@@ -175,16 +175,21 @@ fn params_of(v: Option<&Value>) -> Result {
// ------------------------------------------------------------------ engine tools
+/// `list_commands` tool: the engine command list, minus empty fields; with `compact`, only `id` and `label`.
fn list_commands(b: &mut Backend, a: &Value) -> Result {
- let schemas = get(a, "schemas").and_then(Value::as_bool).unwrap_or(false).then_some(json!(true));
+ let compact = get(a, "compact").and_then(Value::as_bool).unwrap_or(false);
+ let schemas = (!compact && get(a, "schemas").and_then(Value::as_bool).unwrap_or(false)).then_some(json!(true));
let r = b.exec("command.list", obj(&[("filter", get(a, "filter")), ("enabledOnly", get(a, "enabled_only")), ("schemas", schemas.as_ref())]))?;
- // Drop empty fields to keep the listing compact.
let list: Vec = r
.as_array()
.cloned()
.unwrap_or_default()
.into_iter()
.map(|mut c| {
+ if compact {
+ return json!({"id": c["id"], "label": c["label"]});
+ }
+ // Drop empty fields.
if let Some(o) = c.as_object_mut() {
o.retain(|_, v| !(v.is_null() || v.as_array().is_some_and(Vec::is_empty) || v.as_str() == Some("{}")));
}
@@ -205,8 +210,13 @@ fn describe_command(b: &mut Backend, a: &Value) -> Result {
json_reply(b.exec("command.describe", json!({"command": id}))?)
}
+/// `list_effects` tool: the effect registry; with `compact`, only `id`, `name` and `category`.
fn list_effects(b: &mut Backend, a: &Value) -> Result {
- json_reply(b.exec("effect.list", obj(&[("filter", get(a, "filter"))]))?)
+ let mut r = b.exec("effect.list", obj(&[("filter", get(a, "filter"))]))?;
+ if get(a, "compact").and_then(Value::as_bool).unwrap_or(false) {
+ r = r.as_array().into_iter().flatten().map(|e| json!({"id": e["id"], "name": e["name"], "category": e["category"]})).collect();
+ }
+ json_reply(r)
}
fn list_fonts(b: &mut Backend, a: &Value) -> Result {
@@ -542,14 +552,15 @@ fn control(b: &mut Backend, a: &Value) -> Result {
static TOOLS: &[ToolDef] = &[
ToolDef {
name: "list_commands",
- description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`).",
+ description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`); `compact: true` lists only ids and labels.",
bridge_only: false,
schema: || {
schema(
json!({
"filter": {"type": "string", "description": "Only commands whose id or label contains this text (case-insensitive)."},
"enabled_only": {"type": "boolean", "description": "Only commands that can run right now."},
- "schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."}
+ "schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."},
+ "compact": {"type": "boolean", "description": "Return only each command's `id` and `label` (overrides `schemas`)."}
}),
&[],
)
@@ -696,9 +707,17 @@ static TOOLS: &[ToolDef] = &[
},
ToolDef {
name: "list_effects",
- description: "The effect registry: id, After Effects name, category, GPU and 32-bpc support. Narrow with `filter` (id, name or category text). Apply with add_effect.",
+ description: "The effect registry: id, After Effects name, category, GPU and 32-bpc support. Narrow with `filter` (id, name or category text); `compact: true` lists only ids, names and categories. Apply with add_effect.",
bridge_only: false,
- schema: || schema(json!({"filter": {"type": "string", "description": "Only effects whose id, name or category contains this text."}}), &[]),
+ schema: || {
+ schema(
+ json!({
+ "filter": {"type": "string", "description": "Only effects whose id, name or category contains this text."},
+ "compact": {"type": "boolean", "description": "Return only each effect's `id`, `name` and `category`."}
+ }),
+ &[],
+ )
+ },
run: list_effects,
},
ToolDef {
@@ -911,14 +930,15 @@ static TOOLS: &[ToolDef] = &[
},
ToolDef {
name: "command_list",
- description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`).",
+ description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`); `compact: true` lists only ids and labels.",
bridge_only: false,
schema: || {
schema(
json!({
"filter": {"type": "string", "description": "Only commands whose id or label contains this text (case-insensitive)."},
"enabled_only": {"type": "boolean", "description": "Only commands that can run right now."},
- "schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."}
+ "schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."},
+ "compact": {"type": "boolean", "description": "Return only each command's `id` and `label` (overrides `schemas`)."}
}),
&[],
)
diff --git a/crates/automation/tests/qa/compositing.rs b/crates/automation/tests/qa/compositing.rs
index 52d5e4fb2..d5de8f687 100644
--- a/crates/automation/tests/qa/compositing.rs
+++ b/crates/automation/tests/qa/compositing.rs
@@ -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,14 +29,13 @@ 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 imp = qa.exec("file.import", json!({"paths": [seq]}));
+ let seq = write_sequence(qa);
+ let imp = qa.exec("file.import", json!({"paths": [seq], "sequence": true}));
let seq_item = imp["items"][0].clone();
let project = qa.tool("get_project", json!({}));
let it = project["items"].as_array().unwrap().iter().find(|i| i["id"] == seq_item).unwrap().clone();
@@ -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 {
diff --git a/crates/automation/tests/qa/tracking.rs b/crates/automation/tests/qa/tracking.rs
index 837872658..ee6bbb2e9 100644
--- a/crates/automation/tests/qa/tracking.rs
+++ b/crates/automation/tests/qa/tracking.rs
@@ -52,7 +52,7 @@ fn shake_offset(f: u32) -> (i64, i64) {
/// Import a sequence at 12 fps and make a comp of it; returns (comp, layer).
fn plate_comp(qa: &mut Qa, first: &str, name: &str) -> (Value, Value) {
- let item = qa.exec("file.import", json!({"paths": [first]}))["items"][0].clone();
+ let item = qa.exec("file.import", json!({"paths": [first], "sequence": true}))["items"][0].clone();
qa.exec("file.interpretFootage", json!({"items": [item.clone()], "frameRate": 12}));
let comp = qa.exec("comp.new", json!({"name": name, "width": 160, "height": 90, "frameRate": 12, "duration": 1}))["comp"].clone();
let layer = qa.exec("layer.addItem", json!({"item": item}))["layer"].clone();
diff --git a/crates/av1enc/README.md b/crates/av1enc/README.md
index c922c95eb..dcc343693 100644
--- a/crates/av1enc/README.md
+++ b/crates/av1enc/README.md
@@ -48,8 +48,10 @@ reconstruction, so prediction never drifts.
reconstruction loop; full coefficient coding (all_zero, tx type, eob_pt, eob_extra,
coeff_base_eob, coeff_base, coeff_br, dc_sign, Golomb) with the exact contexts.
- **Rate control**: `ConstantQ(base_q_idx)`; `Bitrate { kbps }` adapts `base_q_idx` per frame
- towards the average target (key frames get a lower quantizer and a larger share; the first
- key frame is searched in a few passes).
+ towards the average target (key frames get a lower quantizer and a larger share of the budget of
+ their group of `keyint` frames, the inter frames the rest; the first key frame is searched in a
+ few passes, a later key frame that misses its budget by more than 19 % is coded again; the
+ deviation from the budget is paid back over 16 frames).
## Limits
diff --git a/crates/av1enc/src/cdf.rs b/crates/av1enc/src/cdf.rs
index 0466a1592..777164d1a 100644
--- a/crates/av1enc/src/cdf.rs
+++ b/crates/av1enc/src/cdf.rs
@@ -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 {
diff --git a/crates/av1enc/src/decide.rs b/crates/av1enc/src/decide.rs
index 494e914c0..d8bc1f825 100644
--- a/crates/av1enc/src/decide.rs
+++ b/crates/av1enc/src/decide.rs
@@ -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, half_planes, predict_inter, predict_intra};
+use crate::tables::{
+ AC_QLOOKUP, BLOCK_8X8, BLOCK_64X64, DC_PRED, DC_QLOOKUP, DCT_DCT, DEFAULT_SCAN_4X4, DEFAULT_SCAN_8X8, DEFAULT_SCAN_16X16, DEFAULT_SCAN_32X32, MODE_TO_TXFM,
+ NEWMV, NUM_4X4_BLOCKS_WIDE, PARTITION_SPLIT, PARTITION_SUBSIZE, TX_4X4, TX_8X8, TX_16X16, TX_32X32, TX_64X64, TX_SET_DCTONLY, TX_SET_INTER_3,
+ 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,
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; 3],
@@ -127,13 +174,20 @@ pub(crate) struct Decider<'a> {
ac_q: f32,
dc_qi: i64,
ac_qi: i64,
+ /// Smallest magnitude that quantises to a non-zero level, `[dc, ac][intra]`.
+ dead_zone: [[f32; 2]; 2],
lambda: f64,
lambda_sad: f64,
/// Half-sample interpolations of the reference luma (visible area): (8, 0), (0, 8), (8, 8).
half: [Vec; 3],
+ /// Transform buffers, taken out while a block is being tried (see `code_tx`).
+ scratch: Option>,
}
const MODE_BITS_INTRA: f64 = 3.0;
+/// Dead-zone quantiser rounding offsets (below one half) for inter and intra blocks.
+const INTER_RND: f32 = 0.30;
+const INTRA_RND: f32 = 0.38;
impl<'a> Decider<'a> {
pub fn new(g: &'a FrameGeom, bd: u32, qidx: u8, intra_only: bool, src: &'a [Plane; 3], refr: Option<&'a RefFrame>) -> Self {
@@ -160,12 +214,14 @@ impl<'a> Decider<'a> {
ac_q: ac_qi as f32,
dc_qi,
ac_qi,
+ dead_zone: [dc_qi, ac_qi].map(|q| [INTER_RND, INTRA_RND].map(|rnd| zero_threshold(q as f32, rnd))),
lambda,
lambda_sad: lambda.sqrt(),
half: match refr {
- Some(rf) => [(8, 0), (0, 8), (8, 8)].map(|(fx, fy)| subpel_plane(&rf.planes[0], g.width, g.height, fx, fy, bd)),
+ Some(rf) => half_planes(&rf.planes[0], g.width, g.height, bd),
None => Default::default(),
},
+ scratch: None,
}
}
@@ -324,8 +380,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 +401,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>)> = 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 +432,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 +486,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 +514,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);
}
@@ -515,8 +583,9 @@ impl<'a> Decider<'a> {
let (dy, dx) = (m[0] >> 3, m[1] >> 3);
dx >= lim_x.0 && dx <= lim_x.1 && dy >= lim_y.0 && dy <= lim_y.1
};
+ let lg = log2_table();
let mv_bits = |m: [i32; 2]| -> f64 {
- let comp = |d: i32| if d == 0 { 0.5 } else { 2.0 + 2.0 * ((d.abs() / 8) as f64 + 1.0).log2() };
+ let comp = |d: i32| if d == 0 { 0.5 } else { 2.0 + 2.0 * log2_of(lg, (d.abs() / 8) as usize + 1) };
comp(m[0] - pred_mv[0]) + comp(m[1] - pred_mv[1])
};
let cost =
@@ -771,36 +840,48 @@ 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>) {
+ /// 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];
+ let rnd = if intra { INTRA_RND } else { INTER_RND };
+ let [dc_thr, ac_thr] = [self.dead_zone[0][intra as usize], self.dead_zone[1][intra as usize]];
+ 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 };
- let a = (cf.abs() / q + rnd).floor() as i32;
+ let (q, thr) = if k == 0 { (self.dc_q, dc_thr) } else { (self.ac_q, ac_thr) };
+ // dead zone: below the exact threshold the level is zero, no division needed
+ if cf.abs() < thr {
+ continue;
+ }
+ // the operand is >= 0, so truncation equals floor
+ let a = (cf.abs() / q + rnd) as i32;
if a != 0 {
*l = if cf < 0.0 { -a.min(1 << 16) } else { a.min(1 << 16) };
nonzero = true;
@@ -808,23 +889,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 {
+ /// 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,13 +918,41 @@ 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;
+ }
}
}
+/// Smallest `x >= 0` with `(x / q + rnd) as i32 >= 1` in f32 arithmetic. The expression is
+/// monotone in `x`, so a binary search over the bit patterns finds the exact boundary.
+fn zero_threshold(q: f32, rnd: f32) -> f32 {
+ let (mut lo, mut hi) = (0u32, f32::MAX.to_bits());
+ while lo < hi {
+ let mid = lo + (hi - lo) / 2;
+ if (f32::from_bits(mid) / q + rnd) as i32 >= 1 {
+ hi = mid;
+ } else {
+ lo = mid + 1;
+ }
+ }
+ f32::from_bits(lo)
+}
+
+const LOG2_TABLE_LEN: usize = 1025;
+
+/// `log2(i)` for `i < LOG2_TABLE_LEN`, computed with the same `f64::log2` call as the direct form.
+fn log2_table() -> &'static [f64; LOG2_TABLE_LEN] {
+ static TABLE: std::sync::OnceLock<[f64; LOG2_TABLE_LEN]> = std::sync::OnceLock::new();
+ TABLE.get_or_init(|| std::array::from_fn(|i| (i as f64).log2()))
+}
+
+fn log2_of(table: &[f64; LOG2_TABLE_LEN], v: usize) -> f64 {
+ if v < LOG2_TABLE_LEN { table[v] } else { (v as f64).log2() }
+}
+
/// Unnormalised Walsh-Hadamard transform of 4 or 8 values, in place.
fn hadamard(v: &mut [i32]) {
let n = v.len();
@@ -869,13 +981,14 @@ fn estimate_bits(levels: &[i32], tx_sz: usize) -> f64 {
if eob == 0 {
return 1.0;
}
- let mut bits = 3.0 + (eob as f64).log2();
+ let lg = log2_table();
+ let mut bits = 3.0 + log2_of(lg, eob);
for &pos in &scan[..eob] {
let a = levels[pos as usize].unsigned_abs();
bits += match a {
0 => 0.7,
1 => 2.5,
- _ => 3.5 + 1.6 * (a as f64).log2(),
+ _ => 3.5 + 1.6 * log2_of(lg, a as usize),
};
}
bits
@@ -889,3 +1002,64 @@ pub(crate) fn scan_for(tx_sz: usize) -> &'static [u16] {
_ => &DEFAULT_SCAN_32X32,
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::tables::{AC_QLOOKUP, DC_QLOOKUP};
+
+ /// The quantiser as it was before the dead-zone threshold and the truncation.
+ fn quant_ref(cf: f32, q: f32, rnd: f32) -> i32 {
+ (cf.abs() / q + rnd).floor() as i32
+ }
+
+ fn quant_new(cf: f32, q: f32, rnd: f32, thr: f32) -> i32 {
+ if cf.abs() < thr {
+ return 0;
+ }
+ (cf.abs() / q + rnd) as i32
+ }
+
+ #[test]
+ fn dead_zone_threshold_is_exact() {
+ let mut state = 0x1234_5678_9abc_def0u64;
+ let mut next = move || {
+ state ^= state << 13;
+ state ^= state >> 7;
+ state ^= state << 17;
+ state
+ };
+ for table in AC_QLOOKUP.iter().chain(DC_QLOOKUP.iter()) {
+ for &qi in table.iter() {
+ let q = qi as f32;
+ for rnd in [INTER_RND, INTRA_RND] {
+ let thr = zero_threshold(q, rnd);
+ // the boundary itself and its neighbours
+ let b = thr.to_bits();
+ for bits in b.saturating_sub(3)..=b + 3 {
+ let cf = f32::from_bits(bits);
+ assert_eq!(quant_ref(cf, q, rnd), quant_new(cf, q, rnd, thr), "q {q} rnd {rnd} cf {cf}");
+ assert_eq!(quant_ref(-cf, q, rnd), quant_new(-cf, q, rnd, thr));
+ }
+ for _ in 0..200 {
+ let cf = f32::from_bits((next() >> 32) as u32 & 0x7fff_ffff);
+ if cf.is_nan() {
+ continue;
+ }
+ assert_eq!(quant_ref(cf, q, rnd), quant_new(cf, q, rnd, thr));
+ let cf = (next() % 100_000) as f32 / 100.0;
+ assert_eq!(quant_ref(cf, q, rnd), quant_new(cf, q, rnd, thr));
+ }
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn log2_table_matches_direct_log2() {
+ let t = log2_table();
+ for v in 1..3000usize {
+ assert_eq!(log2_of(t, v).to_bits(), (v as f64).log2().to_bits(), "{v}");
+ }
+ }
+}
diff --git a/crates/av1enc/src/lib.rs b/crates/av1enc/src/lib.rs
index 6b1edfbe8..b86db0fa2 100644
--- a/crates/av1enc/src/lib.rs
+++ b/crates/av1enc/src/lib.rs
@@ -24,6 +24,7 @@ use bitw::{BitWriter, obu};
use decide::{Decider, FrameGeom, RefFrame};
use loopfilter::{LfInfo, loop_filter};
use pred::Plane;
+use tables::AC_QLOOKUP;
use writer::TileWriter;
/// Rate control.
@@ -136,10 +137,118 @@ struct RateState {
frame_bits: f64,
/// Quantizer index for inter frames (continuous).
q: f64,
+ /// Key frame quantizer index minus the inter frame one.
+ key_off: f64,
+ /// Quantizer index steps per halving of the frame size, measured from consecutive inter
+ /// frames (the size/quantizer curve is much steeper than the default on noisy content).
+ slope: f64,
+ /// Quantizer index and size (in bits) of the previous inter frame.
+ prev_inter: Option<(f64, f64)>,
/// Accumulated (spent - budget) bits.
debt: f64,
}
+/// Quantizer index steps per halving of the frame size before any measurement.
+const RC_SLOPE: f64 = 28.0;
+/// Fraction of the quantizer step the size error asks for that is applied per frame.
+const RC_GAIN: f64 = 0.8;
+/// Frames over which an accumulated deviation from the budget is paid back.
+const RC_PAYBACK: f64 = 16.0;
+
+impl RateState {
+ /// The nominal bit budget and the starting quantizer index of a key or inter frame. A key
+ /// frame gets a larger share, and the inter frames of the group get the rest, so that a whole
+ /// group of `keyint` frames adds up to `keyint * frame_bits`.
+ fn frame_budget(&self, keyint: u32, key: bool) -> (f64, f64) {
+ let keyint = keyint.max(1) as f64;
+ let boost = (1.0 + (keyint.clamp(1.0, 60.0) - 1.0).sqrt() * 0.75).min(0.5 * keyint + 0.5);
+ if key {
+ (self.frame_bits * boost, (self.q + self.key_off).clamp(1.0, 255.0))
+ } else if keyint > 1.0 {
+ (self.frame_bits * (keyint - boost) / (keyint - 1.0), self.q)
+ } else {
+ (self.frame_bits, self.q)
+ }
+ }
+
+ /// Adapts the quantizers to the size of the frame just coded at `q_used` against `target`.
+ fn update(&mut self, key: bool, first: bool, q_used: f64, bits: f64, target: f64) {
+ let err = (bits / target).log2().clamp(-1.5, 1.5);
+ if first && key {
+ // four passes ran: the key frame quantizer is known, the inter one follows from the offset
+ self.q = (q_used - self.key_off).clamp(1.0, 255.0);
+ } else if key {
+ self.key_off = q_used - self.q + RC_GAIN * self.slope * err;
+ } else {
+ if let Some((pq, pbits)) = self.prev_inter {
+ let (dq, dr) = (q_used - pq, (pbits / bits).log2());
+ if dq.abs() >= 3.0 && dr.abs() >= 0.15 && dq * dr > 0.0 {
+ self.slope = (0.6 * self.slope + 0.4 * (dq / dr)).clamp(3.0, 45.0);
+ }
+ }
+ self.prev_inter = Some((q_used, bits));
+ self.q = (q_used + RC_GAIN * self.slope * err).clamp(1.0, 255.0);
+ }
+ }
+}
+
+/// Most passes on the first frame, and how far from the target (octaves, 3 %) its size may be
+/// before it is accepted.
+const RC_FIRST_PASSES: usize = 4;
+const RC_FIRST_TOLERANCE: f64 = 0.04;
+/// Quantizer index steps per halving of the size, for the first correction of the first frame
+/// (the starting quantizer comes from the bits per sample; the first pass shows how far off it
+/// is, and from there on the passes are placed by `next_probe_q`).
+const RC_FIRST_SLOPE: f64 = 30.0;
+/// The same for a key frame after the first: coded again when its size is more than 0.25 octaves
+/// (19 %) off the target.
+const RC_KEY_PASSES: usize = 3;
+const RC_KEY_TOLERANCE: f64 = 0.25;
+/// Octaves of frame size per octave of quantizer step assumed for the first correction of a frame.
+const RC_PROBE_SLOPE: f64 = 1.0;
+/// Largest change of the quantizer step (octaves) between two passes on one frame.
+const RC_PROBE_MAX_STEP: f64 = 2.0;
+
+/// The quantizer index whose step size is closest to `2^x`.
+fn q_for_step(bit_depth: u8, x: f64) -> u8 {
+ let table = &AC_QLOOKUP[((bit_depth - 8) / 2) as usize];
+ let mut best = 1;
+ for q in 1..=255usize {
+ if ((table[q] as f64).log2() - x).abs() < ((table[best] as f64).log2() - x).abs() {
+ best = q;
+ }
+ }
+ best as u8
+}
+
+/// The next quantizer to try for a frame, from the `(quantizer, log2(size / target))`
+/// pairs coded so far. The size follows the quantizer step (not the index, which is linear in the
+/// step only below about 60) in a near log-log line: false position between the closest pair of
+/// passes that enclose the target, otherwise a step from the latest one with the slope of the
+/// last two. `None` when no untried quantizer lies between the enclosing passes.
+fn next_probe_q(bit_depth: u8, tried: &[(u8, f64)]) -> Option {
+ let table = &AC_QLOOKUP[((bit_depth - 8) / 2) as usize];
+ let x = |q: u8| (table[q as usize] as f64).log2();
+ let over = tried.iter().filter(|t| t.1 > 0.0).max_by_key(|t| t.0);
+ let under = tried.iter().filter(|t| t.1 <= 0.0).min_by_key(|t| t.0);
+ if let (Some(&(q_lo, e_lo)), Some(&(q_hi, e_hi))) = (over, under) {
+ if q_hi - q_lo < 2 {
+ return None;
+ }
+ let next = x(q_lo) + (x(q_hi) - x(q_lo)) * e_lo / (e_lo - e_hi);
+ return Some(q_for_step(bit_depth, next).clamp(q_lo + 1, q_hi - 1));
+ }
+ let &(q, err) = tried.last()?;
+ let mut slope = RC_PROBE_SLOPE;
+ if let [.., (q0, e0), (q1, e1)] = tried
+ && (x(*q1) - x(*q0)).abs() > 0.05
+ && (e0 - e1) / (x(*q1) - x(*q0)) > 0.0
+ {
+ slope = ((e0 - e1) / (x(*q1) - x(*q0))).clamp(0.25, 3.0);
+ }
+ Some(q_for_step(bit_depth, x(q) + (err / slope).clamp(-RC_PROBE_MAX_STEP, RC_PROBE_MAX_STEP)))
+}
+
/// AV1 encoder. Frames come out in input order (no hidden/reordered frames).
pub struct Encoder {
cfg: EncoderConfig,
@@ -185,7 +294,7 @@ impl Encoder {
// initial guess from the bits per sample
let bpp = frame_bits / (cfg.width as f64 * cfg.height as f64);
let q = (110.0 - 40.0 * (bpp / 0.1).log2()).clamp(20.0, 230.0);
- Some(RateState { frame_bits, q, debt: 0.0 })
+ Some(RateState { frame_bits, q, key_off: -24.0, slope: RC_SLOPE, prev_inter: None, debt: 0.0 })
}
};
let mut enc = Encoder { cfg, geom, level, seq_obu: Vec::new(), frame_num: 0, refr: None, rc };
@@ -409,11 +518,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));
@@ -469,38 +580,52 @@ impl Encoder {
fn encode_rate_controlled(&mut self, src: &[Plane; 3], key: bool, rc: &mut RateState) -> (Vec, RefFrame) {
let first = self.frame_num == 0;
- let (frame_bits, debt, q_inter) = (rc.frame_bits, rc.debt, rc.q);
- // key frames get a lower quantizer and a larger share of the budget
- let key_boost = 1.0 + (self.cfg.keyint.clamp(1, 60) as f64 - 1.0).sqrt() * 0.75;
- let budget = if key { frame_bits * key_boost } else { frame_bits };
- // pay back a fraction of the accumulated deviation
- let target = (budget - debt * 0.25).max(budget * 0.25);
- let mut q = if key { q_inter - 24.0 } else { q_inter };
- let mut best: Option<(Vec, RefFrame, f64)> = None;
- let passes = if first { 4 } else { 1 };
- for pass in 0..passes {
+ let (budget, q_start) = rc.frame_budget(self.cfg.keyint, key);
+ // pay back the accumulated deviation over the next RC_PAYBACK frames
+ let target = (budget - rc.debt / RC_PAYBACK).clamp(budget * 0.35, budget * 2.0);
+ let mut q = q_start;
+ let mut best: Option<(Vec, RefFrame, f64, f64)> = None;
+ // The first frame is coded up to four times to find its quantizer (it fixes the
+ // quantizers of everything after it), a later key frame up to three times (it is a large
+ // part of the budget of its group). A frame is coded again only while its size is far from
+ // the target and an untried quantizer is left to try; other frames are coded once.
+ let (max_passes, tolerance) = if first {
+ (RC_FIRST_PASSES, RC_FIRST_TOLERANCE)
+ } else if key {
+ (RC_KEY_PASSES, RC_KEY_TOLERANCE)
+ } else {
+ (1, 0.0)
+ };
+ let mut tried: Vec<(u8, f64)> = Vec::new();
+ for _ in 0..max_passes {
let qi = q.round().clamp(1.0, 255.0) as u8;
+ if tried.iter().any(|t| t.0 == qi) {
+ break;
+ }
let (obu, rf) = self.encode_frame(src, key, qi);
let bits = obu.len() as f64 * 8.0;
let err = (bits / target).log2();
- let better = best.as_ref().is_none_or(|b| err.abs() < (b.2 / target).log2().abs());
- if better {
- best = Some((obu, rf, bits));
+ tried.push((qi, err));
+ if best.as_ref().is_none_or(|b| err.abs() < (b.2 / target).log2().abs()) {
+ best = Some((obu, rf, bits, qi as f64));
}
- if pass + 1 < passes {
- q = (q + 30.0 * err).clamp(1.0, 255.0);
+ if err.abs() <= tolerance {
+ break;
+ }
+ let next = if first && tried.len() == 1 {
+ Some((q + RC_FIRST_SLOPE * err).clamp(1.0, 255.0))
+ } else {
+ next_probe_q(self.cfg.bit_depth, &tried).map(f64::from)
+ };
+ match next {
+ Some(next) => q = next,
+ None => break,
}
}
// At least one pass ran, so `best` is set.
- let Some((obu, rf, bits)) = best else { return self.encode_frame(src, key, q.round().clamp(1.0, 255.0) as u8) };
+ let Some((obu, rf, bits, q_used)) = best else { return self.encode_frame(src, key, q.round().clamp(1.0, 255.0) as u8) };
rc.debt += bits - budget;
- // adapt the inter quantizer: about 30 index steps per doubling of the rate
- let err = (bits / target).log2().clamp(-1.0, 1.0);
- if first && key {
- rc.q = (q + 24.0).clamp(1.0, 255.0);
- } else {
- rc.q = (rc.q + 12.0 * err).clamp(1.0, 255.0);
- }
+ rc.update(key, first, q_used, bits, target);
(obu, rf)
}
diff --git a/crates/av1enc/src/loopfilter.rs b/crates/av1enc/src/loopfilter.rs
index 2add41edc..cf350fbd2 100644
--- a/crates/av1enc/src/loopfilter.rs
+++ b/crates/av1enc/src/loopfilter.rs
@@ -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;
diff --git a/crates/av1enc/src/pred.rs b/crates/av1enc/src/pred.rs
index d61a6fa94..b899202f4 100644
--- a/crates/av1enc/src/pred.rs
+++ b/crates/av1enc/src/pred.rs
@@ -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);
@@ -293,7 +297,8 @@ pub(crate) fn predict_inter(
/// The visible `w x h` area of a reference plane interpolated at the sub-sample phase
/// (`fx`, `fy`) (1/16 units) with the EIGHTTAP filter, as `predict_inter` computes it for
-/// blocks wider and taller than 4. Used for motion search.
+/// blocks wider and taller than 4. The reference for `half_planes`, which the encoder uses.
+#[cfg(test)]
pub(crate) fn subpel_plane(refp: &Plane, w: usize, h: usize, fx: usize, fy: usize, bit_depth: u32) -> Vec {
let kh = &SUBPEL_FILTERS[EIGHTTAP][fx];
let kv = &SUBPEL_FILTERS[EIGHTTAP][fy];
@@ -323,6 +328,66 @@ pub(crate) fn subpel_plane(refp: &Plane, w: usize, h: usize, fx: usize, fy: usiz
out
}
+/// The three half-sample planes (8, 0), (0, 8), (8, 8) of `subpel_plane` in one pass: the
+/// horizontal half-sample pass is shared by (8, 0) and (8, 8), and the phase-0 kernels reduce to
+/// exact shifts (Round2( 128 * v, 11 ) = Round2( v, 4 ), Round2( 16 * s, 11 ) = Round2( s, 7 )),
+/// so the result equals three `subpel_plane` calls bit for bit.
+pub(crate) fn half_planes(refp: &Plane, w: usize, h: usize, bit_depth: u32) -> [Vec; 3] {
+ let k = &SUBPEL_FILTERS[EIGHTTAP][8];
+ let max = (1i32 << bit_depth) - 1;
+ let (lx, ly) = (w as i32 - 1, h as i32 - 1);
+ // horizontal pass at phase 8: Round2( sum, 3 )
+ let mut hp = vec![0i32; w * h];
+ for y in 0..h {
+ let row = &refp.row(y)[..w];
+ let out = &mut hp[y * w..y * w + w];
+ let tap = |x: i32| -> i32 {
+ let mut s = 0i32;
+ for t in 0..8 {
+ s += k[t] as i32 * row[(x + t as i32 - 3).clamp(0, lx) as usize] as i32;
+ }
+ round2(s, 3)
+ };
+ if w >= 8 {
+ for x in 0..3 {
+ out[x] = tap(x as i32);
+ }
+ for (x, o) in out.iter_mut().enumerate().take(w - 4).skip(3) {
+ let seg = &row[x - 3..x + 5];
+ let mut s = 0i32;
+ for t in 0..8 {
+ s += k[t] as i32 * seg[t] as i32;
+ }
+ *o = round2(s, 3);
+ }
+ for x in w - 4..w {
+ out[x] = tap(x as i32);
+ }
+ } else {
+ for (x, o) in out.iter_mut().enumerate() {
+ *o = tap(x as i32);
+ }
+ }
+ }
+ let p80: Vec = hp.iter().map(|&v| round2(v, 4).clamp(0, max) as u16).collect();
+ let mut p08 = vec![0u16; w * h];
+ let mut p88 = vec![0u16; w * h];
+ for y in 0..h {
+ let rows: [usize; 8] = std::array::from_fn(|t| (y as i32 + t as i32 - 3).clamp(0, ly) as usize);
+ let (o08, o88) = (&mut p08[y * w..y * w + w], &mut p88[y * w..y * w + w]);
+ for x in 0..w {
+ let (mut a, mut b) = (0i32, 0i32);
+ for t in 0..8 {
+ a += k[t] as i32 * refp.data[rows[t] * refp.stride + x] as i32;
+ b += k[t] as i32 * hp[rows[t] * w + x];
+ }
+ o08[x] = round2(a, 7).clamp(0, max) as u16;
+ o88[x] = round2(b, 11).clamp(0, max) as u16;
+ }
+ }
+ [p80, p08, p88]
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -340,7 +405,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));
@@ -353,6 +422,20 @@ mod tests {
}
}
+ #[test]
+ fn half_planes_match_subpel_plane() {
+ for (w, h) in [(37usize, 29usize), (5, 3), (1, 1), (64, 64)] {
+ let mut p = Plane::new(64, 64);
+ for (i, v) in p.data.iter_mut().enumerate() {
+ *v = ((i * 7919) % 1024) as u16;
+ }
+ let hp = half_planes(&p, w, h, 10);
+ for (i, (fx, fy)) in [(8, 0), (0, 8), (8, 8)].into_iter().enumerate() {
+ assert_eq!(hp[i], subpel_plane(&p, w, h, fx, fy, 10), "{w}x{h} phase ({fx}, {fy})");
+ }
+ }
+ }
+
#[test]
fn phase_zero_filters_are_impulses() {
for f in SUBPEL_FILTERS.iter() {
diff --git a/crates/av1enc/src/transform.rs b/crates/av1enc/src/transform.rs
index 90042e26a..9d81ead0b 100644
--- a/crates/av1enc/src/transform.rs
+++ b/crates/av1enc/src/transform.rs
@@ -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 {
@@ -32,6 +32,7 @@ fn sin128(angle: i32) -> i64 {
cos128(angle - 64)
}
+#[inline(always)]
fn brev(num_bits: u32, x: usize) -> usize {
let mut t = 0;
for i in 0..num_bits {
@@ -63,14 +64,24 @@ fn hd(t: &mut [i32], a: usize, b: usize, flip: bool, r: u32) {
t[b] = (x - y).clamp(lo, hi);
}
-/// Inverse DCT of length 2^n, n = 2..=5 (7.13.2.3), in place.
-fn inverse_dct(t: &mut [i32], n: u32, r: u32) {
+/// Inverse DCT of length 2^N, N = 2..=5 (7.13.2.3), in place. `N` is a const generic so that the
+/// bit-reversal permutations and loop bounds fold at compile time.
+#[inline(always)]
+fn inverse_dct_n(t: &mut [i32], r: u32) {
+ let n = N;
let len = 1usize << n;
let mut copy = [0i32; 32];
copy[..len].copy_from_slice(&t[..len]);
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.
+#[inline(always)]
+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 +114,11 @@ fn inverse_dct(t: &mut [i32], n: u32, r: u32) {
}
}
}
+}
+
+/// Evaluate the four-point core and merge progressively larger branches.
+#[inline(always)]
+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);
}
@@ -180,6 +196,7 @@ const SINPI_2_9: i64 = 2482;
const SINPI_3_9: i64 = 3344;
const SINPI_4_9: i64 = 3803;
+#[inline(always)]
fn inverse_adst4(t: &mut [i32]) {
let (t0, t1, t2, t3) = (t[0] as i64, t[1] as i64, t[2] as i64, t[3] as i64);
let mut s = [SINPI_1_9 * t0, SINPI_2_9 * t0, SINPI_3_9 * t1, SINPI_4_9 * t2, SINPI_1_9 * t2, SINPI_2_9 * t3, SINPI_4_9 * t3];
@@ -197,7 +214,9 @@ fn inverse_adst4(t: &mut [i32]) {
}
}
-fn adst_in_permute(t: &mut [i32], n: u32) {
+#[inline(always)]
+fn adst_in_permute(t: &mut [i32]) {
+ let n = N;
let n0 = 1usize << n;
let mut copy = [0i32; 16];
copy[..n0].copy_from_slice(&t[..n0]);
@@ -207,7 +226,9 @@ fn adst_in_permute(t: &mut [i32], n: u32) {
}
}
-fn adst_out_permute(t: &mut [i32], n: u32) {
+#[inline(always)]
+fn adst_out_permute(t: &mut [i32]) {
+ let n = N;
let n0 = 1usize << n;
let mut copy = [0i32; 16];
copy[..n0].copy_from_slice(&t[..n0]);
@@ -221,8 +242,9 @@ fn adst_out_permute(t: &mut [i32], n: u32) {
}
}
+#[inline(always)]
fn inverse_adst8(t: &mut [i32], r: u32) {
- adst_in_permute(t, 3);
+ adst_in_permute::<3>(t);
for i in 0..4 {
bf(t, 2 * i, 2 * i + 1, 60 - 16 * i as i32, true);
}
@@ -240,11 +262,12 @@ fn inverse_adst8(t: &mut [i32], r: u32) {
for i in 0..2 {
bf(t, 2 + 4 * i, 3 + 4 * i, 32, true);
}
- adst_out_permute(t, 3);
+ adst_out_permute::<3>(t);
}
+#[inline(always)]
fn inverse_adst16(t: &mut [i32], r: u32) {
- adst_in_permute(t, 4);
+ adst_in_permute::<4>(t);
for i in 0..8 {
bf(t, 2 * i, 2 * i + 1, 62 - 8 * i as i32, true);
}
@@ -273,7 +296,7 @@ fn inverse_adst16(t: &mut [i32], r: u32) {
for i in 0..4 {
bf(t, 2 + 4 * i, 3 + 4 * i, 32, true);
}
- adst_out_permute(t, 4);
+ adst_out_permute::<4>(t);
}
/// 1-D transform kind.
@@ -298,6 +321,17 @@ pub(crate) fn kinds(tx_type: usize) -> (Kind, Kind) {
}
}
+#[inline(always)]
+fn inverse_dct(t: &mut [i32], n: u32, r: u32) {
+ match n {
+ 2 => inverse_dct_n::<2>(t, r),
+ 3 => inverse_dct_n::<3>(t, r),
+ 4 => inverse_dct_n::<4>(t, r),
+ _ => inverse_dct_n::<5>(t, r),
+ }
+}
+
+#[inline(always)]
fn inverse_1d(t: &mut [i32], kind: Kind, n: u32, r: u32) {
match kind {
Kind::Dct => inverse_dct(t, n, r),
@@ -312,7 +346,17 @@ fn inverse_1d(t: &mut [i32], kind: Kind, n: u32, r: u32) {
/// 2-D inverse transform (7.13.3) of a square `n x n` block (n = 4..=32) of dequantised
/// coefficients (row stride `n`) into `residual` (row stride `n`).
pub(crate) fn inverse_2d(dequant: &[i32], residual: &mut [i32], tx_sz: usize, tx_type: usize, bit_depth: u32) {
- let log2 = TX_WIDTH_LOG2[tx_sz] as u32;
+ match TX_WIDTH_LOG2[tx_sz] {
+ 2 => inverse_2d_n::<2>(dequant, residual, tx_sz, tx_type, bit_depth),
+ 3 => inverse_2d_n::<3>(dequant, residual, tx_sz, tx_type, bit_depth),
+ 4 => inverse_2d_n::<4>(dequant, residual, tx_sz, tx_type, bit_depth),
+ _ => inverse_2d_n::<5>(dequant, residual, tx_sz, tx_type, bit_depth),
+ }
+}
+
+/// `inverse_2d` for a block of width `1 << LOG2`.
+fn inverse_2d_n(dequant: &[i32], residual: &mut [i32], tx_sz: usize, tx_type: usize, bit_depth: u32) {
+ let log2 = LOG2;
let n = 1usize << log2;
let row_shift = TRANSFORM_ROW_SHIFT[tx_sz] as u32;
let row_clamp = bit_depth + 8;
@@ -419,6 +463,51 @@ fn basis(kind: Kind, n: usize) -> &'static [f32] {
/// Forward 2-D transform of the `n x n` residual (row stride `n`) into orthonormal-domain
/// coefficients scaled by 8 (so that `level = out / q` with the spec's dequantiser).
pub(crate) fn forward_2d(residual: &[i32], out: &mut [f32], n: usize, tx_type: usize) {
+ match n {
+ 4 => forward_2d_n::<4, 16>(residual, out, tx_type),
+ 8 => forward_2d_n::<8, 64>(residual, out, tx_type),
+ 16 => forward_2d_n::<16, 256>(residual, out, tx_type),
+ _ => forward_2d_dyn(residual, out, n, tx_type),
+ }
+}
+
+/// `forward_2d` for a fixed size, so `tmp` is exactly `NN = N * N` floats (no 4 KiB zeroing).
+#[inline(always)]
+fn forward_2d_n(residual: &[i32], out: &mut [f32], tx_type: usize) {
+ let n = N;
+ let (ck, rk) = kinds(tx_type);
+ let bc = &basis(ck, n)[..NN];
+ let brt = &basis_t(rk, n)[..NN];
+ let residual = &residual[..NN];
+ let out = &mut out[..NN];
+ let mut tmp = [0f32; NN];
+ for i in 0..n {
+ let t = &mut tmp[i * n..i * n + n];
+ for j in 0..n {
+ let r = residual[i * n + j];
+ if r == 0 {
+ continue;
+ }
+ let r = r as f32;
+ for (o, &b) in t.iter_mut().zip(&brt[j * n..j * n + n]) {
+ *o += r * b;
+ }
+ }
+ }
+ out.iter_mut().for_each(|v| *v = 0.0);
+ for k in 0..n {
+ let o = &mut out[k * n..k * n + n];
+ for i in 0..n {
+ let b = 8.0 * bc[k * n + i];
+ for (v, &t) in o.iter_mut().zip(&tmp[i * n..i * n + n]) {
+ *v += b * t;
+ }
+ }
+ }
+}
+
+/// 32x32 (and any other size): a const-generic 32 version measured slower.
+fn forward_2d_dyn(residual: &[i32], out: &mut [f32], n: usize, tx_type: usize) {
let (ck, rk) = kinds(tx_type);
let bc = basis(ck, n);
let brt = basis_t(rk, n);
@@ -454,6 +543,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.
diff --git a/crates/av1enc/src/writer.rs b/crates/av1enc/src/writer.rs
index 54c545fa4..a10bd2e9a 100644
--- a/crates/av1enc/src/writer.rs
+++ b/crates/av1enc/src/writer.rs
@@ -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; 3],
/// LoopfilterTxSizes per plane (row stride mi_cols + 32).
lf_tx: [Vec; 3],
+ /// Scratch for the levels coded so far in `coeffs`.
+ quant: Vec,
// 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 = 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) {
diff --git a/crates/av1enc/tests/it/roundtrip.rs b/crates/av1enc/tests/it/roundtrip.rs
index 282880e08..edc98c370 100644
--- a/crates/av1enc/tests/it/roundtrip.rs
+++ b/crates/av1enc/tests/it/roundtrip.rs
@@ -80,6 +80,23 @@ fn bitrate_mode_tracks_target() {
assert!(kbps > 400.0 / 2.0 && kbps < 400.0 * 2.0, "{kbps} kbit/s");
}
+/// Over several key frame intervals the average rate stays within 5 % of the target, for a
+/// range of targets (the key frame budget is taken out of the inter frames of its group).
+#[test]
+fn bitrate_mode_is_accurate_over_many_groups() {
+ let n = 180;
+ for (kbps, keyint, bd) in [(40, 30, 8), (100, 30, 8), (200, 60, 8), (150, 20, 10)] {
+ let pics: Vec = (0..n).map(|t| synth(192, 128, bd, t)).collect();
+ let mut c = cfg(192, 128, bd as u8, 0, keyint);
+ c.rate = RateControl::Bitrate { kbps };
+ let mut enc = effectcraft_av1enc::Encoder::new(c).unwrap();
+ let bits: usize = pics.iter().map(|p| enc.encode(&p.frame()).data.len() * 8).sum();
+ let got = bits as f64 / (n as f64 / 30.0) / 1000.0;
+ eprintln!("{bd} bit, keyint {keyint}: {got:.1} kbit/s for {kbps}");
+ assert!((got / kbps as f64 - 1.0).abs() < 0.05, "{got:.1} kbit/s for a {kbps} kbit/s target ({bd} bit, keyint {keyint})");
+ }
+}
+
#[test]
fn headers() {
let enc = effectcraft_av1enc::Encoder::new(cfg(1920, 1080, 10, 100, 60)).unwrap();
diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs
index 2a0dfc50e..71eb43296 100644
--- a/crates/color/src/lib.rs
+++ b/crates/color/src/lib.rs
@@ -97,7 +97,9 @@ pub fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
return (0.0, 0.0, l);
}
let d = max - min;
- let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
+ let denom = if l > 0.5 { 2.0 - max - min } else { max + min };
+ // Over-range (HDR) or negative inputs can make the denominator zero or negative.
+ let s = if denom > 0.0 { d / denom } else { 1.0 };
let h = if max == r {
(((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0).rem_euclid(1.0)
} else if max == g {
@@ -262,6 +264,20 @@ impl Label {
mod tests {
use super::*;
+ #[test]
+ fn hsl_round_trip_is_finite_for_out_of_range_colours() {
+ for (r, g, b) in [(1.5, 0.5, 0.5), (2.0, 0.1, 0.3), (-0.5, 0.2, 0.9)] {
+ let (h, s, l) = rgb_to_hsl(r, g, b);
+ assert!(h.is_finite() && s.is_finite() && l.is_finite(), "{h} {s} {l}");
+ let (r2, g2, b2) = hsl_to_rgb(h, s, l);
+ assert!(r2.is_finite() && g2.is_finite() && b2.is_finite(), "{r2} {g2} {b2}");
+ }
+ // A 32 bpc colour whose denominator stays positive still round-trips exactly.
+ let (h, s, l) = rgb_to_hsl(1.2, 0.5, 0.5);
+ let (r, g, b) = hsl_to_rgb(h, s, l);
+ assert!((r - 1.2).abs() < 1e-5 && (g - 0.5).abs() < 1e-5 && (b - 0.5).abs() < 1e-5, "{r} {g} {b}");
+ }
+
#[test]
fn hsv_to_rgb_tiny_negative_hue_is_red() {
let (r, g, b) = hsv_to_rgb(-1e-9, 1.0, 1.0);
diff --git a/crates/effects/src/analysis_cache.rs b/crates/effects/src/analysis_cache.rs
new file mode 100644
index 000000000..98764d205
--- /dev/null
+++ b/crates/effects/src/analysis_cache.rs
@@ -0,0 +1,97 @@
+//! Bounded parsed-JSON caches. Keep the content key with the owned source text so repeated
+//! renders do not rehash long analyses. Compare contents on lookup: evaluated parameter
+//! strings can be cloned, edited in place, or allocated again at an old address.
+
+use std::sync::{Arc, Mutex, OnceLock};
+
+struct Entry {
+ json: Box,
+ key: u64,
+ value: Arc,
+}
+
+pub(crate) struct AnalysisCache {
+ entries: OnceLock>>>,
+}
+
+pub(crate) fn fnv(bytes: &[u8]) -> u64 {
+ let mut h: u64 = 0xcbf2_9ce4_8422_2325;
+ for b in bytes {
+ h ^= *b as u64;
+ h = h.wrapping_mul(0x0100_0000_01b3);
+ }
+ h
+}
+
+impl AnalysisCache {
+ pub(crate) const fn new() -> Self {
+ Self { entries: OnceLock::new() }
+ }
+
+ pub(crate) fn get(&self, json: &str, parse: impl FnOnce(&str) -> Option) -> Option<(u64, Arc)> {
+ let entries = self.entries.get_or_init(Default::default);
+ {
+ let m = entries.lock().unwrap_or_else(|e| e.into_inner());
+ if let Some(e) = m.iter().rev().find(|e| e.json.as_ref() == json) {
+ return Some((e.key, e.value.clone()));
+ }
+ }
+ let value = Arc::new(parse(json)?);
+ let key = fnv(json.as_bytes());
+ let mut m = entries.lock().unwrap_or_else(|e| e.into_inner());
+ // Another render may have filled the entry while this one parsed it.
+ if let Some(e) = m.iter().rev().find(|e| e.json.as_ref() == json) {
+ return Some((e.key, e.value.clone()));
+ }
+ // Preserve the existing nine-entry bound and clear-on-overflow policy.
+ if m.len() > 8 {
+ m.clear();
+ }
+ m.push(Entry { json: json.into(), key, value: value.clone() });
+ Some((key, value))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::cell::Cell;
+
+ #[test]
+ fn reuses_content_and_invalidates_same_allocation_edits() {
+ let cache = AnalysisCache::new();
+ let calls = Cell::new(0);
+ let parse = |s: &str| {
+ calls.set(calls.get() + 1);
+ Some(s.to_owned())
+ };
+ let mut json = String::from("analysis-1");
+ let (key, a) = cache.get(&json, parse).unwrap();
+ let copy = json.clone();
+ assert_ne!(json.as_ptr(), copy.as_ptr());
+ let (same_key, same) = cache.get(©, parse).unwrap();
+ assert_eq!(key, fnv(json.as_bytes()));
+ assert_eq!(key, same_key);
+ assert!(Arc::ptr_eq(&a, &same));
+ assert_eq!(calls.get(), 1);
+ let ptr = json.as_ptr();
+ json.replace_range(9..10, "2");
+ assert_eq!(ptr, json.as_ptr());
+ let (changed_key, changed) = cache.get(&json, parse).unwrap();
+ assert_ne!(key, changed_key);
+ assert_eq!(&*changed, "analysis-2");
+ assert_eq!(&*a, "analysis-1");
+ assert_eq!(calls.get(), 2);
+ assert!(cache.get("invalid", |_| None).is_none());
+ assert!(Arc::ptr_eq(&cache.get(&json, parse).unwrap().1, &changed));
+ }
+
+ #[test]
+ fn source_text_retention_is_bounded() {
+ let cache = AnalysisCache::new();
+ for i in 0..30 {
+ cache.get(&i.to_string(), |s| Some(s.to_owned())).unwrap();
+ assert!(cache.entries.get().unwrap().lock().unwrap().len() <= 9);
+ }
+ }
+}
diff --git a/crates/effects/src/audio_fx.rs b/crates/effects/src/audio_fx.rs
index cd9153d66..2aab70309 100644
--- a/crates/effects/src/audio_fx.rs
+++ b/crates/effects/src/audio_fx.rs
@@ -206,6 +206,12 @@ pub fn reverses(id: &str) -> bool {
id == "ec.audio.backwards"
}
+/// Does each output sample depend only on the input sample at the same time (no state), so the
+/// mixer may run the effect in short pieces with parameters animated between them?
+pub fn per_sample(id: &str) -> bool {
+ matches!(id, "ec.audio.stereomixer" | "ec.audio.backwards")
+}
+
/// Seconds of input history the effect needs before a block to reach the state it would have
/// had when processing continuously.
pub fn preroll(id: &str, params: &Params) -> f64 {
diff --git a/crates/effects/src/camera_tracker.rs b/crates/effects/src/camera_tracker.rs
index ba7878abf..c58df3593 100644
--- a/crates/effects/src/camera_tracker.rs
+++ b/crates/effects/src/camera_tracker.rs
@@ -16,8 +16,9 @@
//! on it draws the solved points (as the viewer shows them) into the frame. The viewer overlay, target and the Create
//! commands live in the engine and the UI.
-use std::collections::HashMap;
-use std::sync::{Arc, Mutex, OnceLock};
+use std::sync::Arc;
+
+use crate::analysis_cache::AnalysisCache;
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
@@ -122,50 +123,24 @@ pub fn solve_key(st: &SolveSettings) -> String {
format!("{:?}|{hfov}|{:?}|{:?}{lens}", st.shot, st.method, st.deleted)
}
-fn fnv(bytes: &[u8]) -> u64 {
- let mut h: u64 = 0xcbf2_9ce4_8422_2325;
- for b in bytes {
- h ^= *b as u64;
- h = h.wrapping_mul(0x0100_0000_01b3);
- }
- h
-}
-
-type Cache = Mutex>>;
-
-fn cached(c: &'static OnceLock>, json: &str, parse: impl Fn(&str) -> Option) -> Option> {
- let key = fnv(json.as_bytes());
- let c = c.get_or_init(Default::default);
- if let Some(a) = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
- return Some(a.clone());
- }
- let a = Arc::new(parse(json)?);
- let mut m = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
- if m.len() > 8 {
- m.clear();
- }
- m.insert(key, a.clone());
- Some(a)
-}
-
/// The stored feature tracks of an instance (`None` when not analysed).
pub fn tracks(params: &Params) -> Option> {
- static C: OnceLock> = OnceLock::new();
+ static C: AnalysisCache = AnalysisCache::new();
let j = s(params, TRACKS);
if j.is_empty() {
return None;
}
- cached(&C, j, CameraTracks::from_json).filter(|t| !t.is_empty())
+ C.get(j, CameraTracks::from_json).map(|(_, a)| a).filter(|t| !t.is_empty())
}
/// The stored camera solve of an instance (`None` when not solved).
pub fn solve(params: &Params) -> Option> {
- static C: OnceLock> = OnceLock::new();
+ static C: AnalysisCache = AnalysisCache::new();
let j = s(params, SOLVE);
if j.is_empty() {
return None;
}
- cached(&C, j, CameraSolve::from_json).filter(|t| !t.is_empty())
+ C.get(j, CameraSolve::from_json).map(|(_, a)| a).filter(|t| !t.is_empty())
}
/// Screen size (layer pixels) of a target drawn for a point at depth `z`, relative to the
@@ -338,6 +313,27 @@ mod tests {
assert!(!st.lens_distortion && !solve_key(&st).contains("lens"));
}
+ #[test]
+ fn tracks_and_solve_follow_analysis_replacements() {
+ let mut t = CameraTracks { frames: 3, start: 1.0, ..Default::default() };
+ let mut sol = CameraSolve { frames: vec![SolvedFrame::default()], start: 1.0, ..Default::default() };
+ let mut ps = params_with(&sol, false);
+ ps.values.insert(TRACKS.into(), Value::Str(t.to_json()));
+ assert_eq!(*tracks(&ps).unwrap(), t);
+ assert_eq!(*solve(&ps).unwrap(), sol);
+ t.start = 2.0;
+ sol.start = 2.0;
+ ps.values.insert(TRACKS.into(), Value::Str(t.to_json()));
+ ps.values.insert(SOLVE.into(), Value::Str(sol.to_json()));
+ assert_eq!(*tracks(&ps).unwrap(), t);
+ assert_eq!(*solve(&ps).unwrap(), sol);
+ assert_eq!(*tracks(&ps.clone()).unwrap(), t);
+ ps.values.insert(TRACKS.into(), Value::Str("invalid".into()));
+ ps.values.insert(SOLVE.into(), Value::Str(String::new()));
+ assert!(tracks(&ps).is_none());
+ assert!(solve(&ps).is_none());
+ }
+
#[test]
fn undistort_footage_remaps_through_the_solved_lens() {
use effectcraft_track::camtrack::Distortion;
diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs
index 80a937b22..85be739ed 100644
--- a/crates/effects/src/catalog.rs
+++ b/crates/effects/src/catalog.rs
@@ -182,6 +182,20 @@ mod tests {
assert!(have == want, "docs/effects.md is stale: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current");
}
+ /// The README quotes the number of effects (#2565): every count it gives is the registry's.
+ #[test]
+ fn readme_effect_counts_are_current() {
+ let readme = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../README.md")).unwrap_or_default();
+ let words: Vec<&str> = readme.split_whitespace().collect();
+ let counts: Vec<&str> = words
+ .windows(3)
+ .filter(|w| w[0].parse::().is_ok() && (w[1].starts_with("effects") || (w[1] == "built-in" && w[2].starts_with("effects"))))
+ .map(|w| w[0])
+ .collect();
+ let n = super::registry().len().to_string();
+ assert!(!counts.is_empty() && counts.iter().all(|c| *c == n), "README.md says {counts:?} effects; the registry has {n}");
+ }
+
#[test]
fn cpu_only_controls_keep_effects_off_the_gpu() {
use effectcraft_keyframe::Value;
diff --git a/crates/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs
index 08241107c..dc65c3255 100644
--- a/crates/effects/src/channel3d.rs
+++ b/crates/effects/src/channel3d.rs
@@ -7,6 +7,11 @@
//! pass for a precomp of a 3D comp. Without auxiliary channels they pass the layer through
//! (EXtractoR still maps the layer's own R, G, B and A).
//!
+//! 3D Layer Instance (Create 3D Layer Instance's own effect, Layer ▸ Create) is not an aux
+//! reader: it replaces the layer's pixels with a referenced layer's render — for a 3D model
+//! layer, its render on its own through the comp's camera — so 2D effects can be applied to a
+//! 3D layer's render.
+//!
//! Aux pixels are looked up nearest-neighbour (IDs must not be interpolated) at each buffer
//! pixel's layer position, so render scale and layer padding are respected.
@@ -582,6 +587,19 @@ fn extractor(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
+/// A 2D copy of a referenced layer (Create 3D Layer Instance's solid): the layer's pixels
+/// replaced with the referenced layer's render, alpha included — the copy is transparent
+/// where the referenced layer isn't. A 3D model or primitive layer is rendered on its own
+/// through the comp's camera, as After Effects renders 3D layers; another layer is read with
+/// its masks and effects. With no layer chosen the effect passes the layer through, as a layer
+/// parameter with nothing chosen does.
+fn instance_3d(ctx: &EffectCtx, mut b: Buf) -> Buf {
+ if let Some(o) = ctx.layer_param("layer", true) {
+ b.img = crate::util::fit_layer(ctx, &b, &o, false);
+ }
+ b
+}
+
pub fn specs() -> Vec {
let text = |id: &'static str, name: &'static str, d: &str| p(id, name, Value::Str(d.into()), ParamUi::Text);
vec![
@@ -682,6 +700,7 @@ pub fn specs() -> Vec {
],
identifier,
),
+ spec("ec.3d.instance3d", "3D Layer Instance", vec![p("layer", "Layer", Value::Layer(None), ParamUi::Layer)], instance_3d),
]
}
diff --git a/crates/effects/src/channel3d/tests.rs b/crates/effects/src/channel3d/tests.rs
index 4fc84440e..e69c2a6aa 100644
--- a/crates/effects/src/channel3d/tests.rs
+++ b/crates/effects/src/channel3d/tests.rs
@@ -373,3 +373,29 @@ fn channel_extract_antialias_averages_sub_pixels() {
let soft = run("ec.3d.channelextract", &[vals.as_slice(), &[("antialias", Value::Bool(true))]].concat(), &host);
assert!((soft.get(5, 5)[0] - 0.5).abs() < 1e-5, "{:?}", soft.get(5, 5));
}
+
+#[test]
+fn instance3d_replaces_the_pixels_with_the_referenced_layer() {
+ // Create 3D Layer Instance's copy: the chosen layer's render takes the place of the layer's
+ // own pixels, alpha included — the copy is transparent where the referenced layer isn't —
+ // and with no layer chosen the layer passes through.
+ struct Host(Image);
+ impl EffectHost for Host {
+ fn layer(&self, id: u64, _: bool) -> Option {
+ (id == 7).then(|| LayerPixels { buf: Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 }, size: [self.0.width as f64, self.0.height as f64] })
+ }
+ fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> {
+ None
+ }
+ }
+ let solid = || Image::filled(4, 4, [1.0, 1.0, 1.0, 1.0]);
+ let mut render = Image::new(4, 4);
+ render.set(1, 1, [1.0, 0.0, 0.0, 1.0]);
+ let env = EffectEnv { host: Some(&Host(render)), ..Default::default() };
+ let out = run_fx("ec.3d.instance3d", &[("layer", Value::Layer(Some(7)))], solid(), 0.0, env);
+ assert_eq!(out.img.get(1, 1), [1.0, 0.0, 0.0, 1.0], "the referenced layer's pixel");
+ assert_eq!(out.img.get(3, 3), [0.0; 4], "transparent where the referenced layer isn't");
+ // No layer chosen: the layer's own pixels.
+ let out = run_fx("ec.3d.instance3d", &[], solid(), 0.0, EffectEnv::default());
+ assert_eq!(out.img.data, solid().data);
+}
diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs
index 534da8061..916e162f6 100644
--- a/crates/effects/src/distort.rs
+++ b/crates/effects/src/distort.rs
@@ -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 = (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 = 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);
diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs
index 10ec59735..7add5fc58 100644
--- a/crates/effects/src/distort2.rs
+++ b/crates/effects/src/distort2.rs
@@ -19,7 +19,7 @@ use effectcraft_project::ParamUi;
use effectcraft_raster::{Image, Px};
use rayon::prelude::*;
-use crate::generate::value_noise;
+use crate::generate::value_noise_linear_z;
use crate::util::{SRC_NAMES, Src, fit_layer, layer_cover_pad, layer_rect, lerp4, map_xy, pick, premul, remap, smoothstep, src_at, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
@@ -37,13 +37,15 @@ fn pct(max: f64) -> ParamUi {
// ---------------------------------------------------------------- Turbulent Displace
+/// Fractional-octave fBm of value noise in 0..1. Evolution (`z`) is linear: with the eased z of
+/// plain value noise a few degrees of Evolution barely moved the pattern (#943).
fn fbm(u: f64, v: f64, z: f64, seed: u32, octaves: f64, falloff: f32) -> f32 {
let n = octaves.ceil().max(1.0) as usize;
let frac = (octaves - octaves.floor()) as f32;
let (mut sum, mut norm, mut amp, mut f) = (0.0f32, 0.0f32, 1.0f32, 1.0f32);
for o in 0..n {
let w = if o + 1 == n && frac > 0.0 { frac } else { 1.0 };
- sum += value_noise(u as f32 * f, v as f32 * f, z as f32 + o as f32 * 5.17, seed.wrapping_add(o as u32 * 101)) * amp * w;
+ sum += value_noise_linear_z(u as f32 * f, v as f32 * f, z as f32 + o as f32 * 5.17, seed.wrapping_add(o as u32 * 101)) * amp * w;
norm += amp * w;
amp *= falloff;
f *= 2.0;
@@ -93,6 +95,9 @@ fn turbulent_displace(ctx: &EffectCtx, mut b: Buf) -> Buf {
let size = (ctx.params.f("size") * b.scale).max(1.0);
if ctx.params.b("resizeLayer") && !ctx.adjustment {
b.pad(amount.abs().ceil() as u32 + 1);
+ } else {
+ // Room for pixels displaced past the content, up to the layer's edge (#1205).
+ b.pad(crate::cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale, amount.abs().ceil() as u32 + 1));
}
let off = b.to_px(ctx.params.v2("offset"));
let oct = ctx.params.f("complexity").clamp(1.0, 10.0);
@@ -1115,6 +1120,45 @@ mod tests {
assert_eq!(TURBULENT_PINNING[11], "Pin Left Locked");
}
+ /// #943: every non-equivalent Evolution value renders a visibly different frame. The eased
+ /// evolution axis left 0 and 37 degrees (or 360 and 400) within a hundredth of each other.
+ #[test]
+ fn turbulent_displace_evolution_changes_the_output() {
+ // A smooth ramp (red = x, green = y) with Pin All Locked, so the frame stays opaque and
+ // the difference is purely how far the pattern moved.
+ let mut ramp = Image::new(64, 64);
+ for y in 0..64u32 {
+ for x in 0..64u32 {
+ ramp.set(x, y, [x as f32 / 63.0, y as f32 / 63.0, 0.25, 1.0]);
+ }
+ }
+ let render = |evo: f64, extra: &[(&str, Value)]| {
+ let base = [("evolution", num(evo)), ("size", num(10.0)), ("pinning", Value::Enum(8))];
+ run("ec.distort.turbulentdisplace", &[base.as_slice(), extra].concat(), ramp.clone()).img
+ };
+ let evolutions = [0.0, 37.0, 90.5, 180.0, 360.0, 400.25, 725.0, 1000.5];
+ let frames: Vec = evolutions.iter().map(|&e| render(e, &[])).collect();
+ for (i, a) in frames.iter().enumerate() {
+ for (j, b) in frames.iter().enumerate().skip(i + 1) {
+ let d = max_diff(a, b);
+ assert!(d > 0.01, "evolution {} and {} differ by only {d}", evolutions[i], evolutions[j]);
+ }
+ }
+ // Constant rate: 20 degrees next to a whole revolution (from either side) is a visible
+ // step, not the rounding-error size the eased axis gave (under 0.001).
+ assert!(max_diff(&frames[0], &render(20.0, &[])) > 0.02);
+ assert!(max_diff(&render(340.0, &[]), &frames[4]) > 0.02);
+ // 0 and 360 degrees are different frames without Cycle Evolution, and the same frame with
+ // Cycle (in Revolutions) = 1, at any starting angle.
+ assert!(max_diff(&frames[0], &frames[4]) > 0.01);
+ let cycle = [("evolutionOptions/cycleEvolution", Value::Bool(true)), ("evolutionOptions/cycle", num(1.0))];
+ assert!(max_diff(&render(0.0, &cycle), &render(360.0, &cycle)) < 1e-4);
+ assert!(max_diff(&render(37.0, &cycle), &render(397.0, &cycle)) < 1e-4);
+ assert!(max_diff(&render(0.0, &cycle), &render(37.0, &cycle)) > 0.01);
+ // Random Seed still gives a different pattern at the same Evolution.
+ assert!(max_diff(&frames[1], &render(37.0, &[("evolutionOptions/randomSeed", num(5.0))])) > 0.01);
+ }
+
#[test]
fn magnify_blending_none_and_scaling() {
let img = test_img(32, 32);
diff --git a/crates/effects/src/generate.rs b/crates/effects/src/generate.rs
index 637cc428e..ae0d61861 100644
--- a/crates/effects/src/generate.rs
+++ b/crates/effects/src/generate.rs
@@ -292,10 +292,22 @@ pub(crate) fn fade(t: f32) -> f32 {
t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
}
-/// 3D value noise in 0..1 (z = evolution).
+/// 3D value noise in 0..1 (z = evolution). The quintic fade applies on all three axes, so z
+/// eases in and out of every integer.
pub fn value_noise(x: f32, y: f32, z: f32, seed: u32) -> f32 {
+ value_noise_z(x, y, z, seed, true)
+}
+
+/// [`value_noise`] with the evolution axis z interpolated linearly (x and y keep the quintic
+/// fade). The fade's zero slope at every integer z made a small Evolution change almost invisible
+/// next to a whole revolution (#943, #1816); linear z moves the pattern at a constant rate.
+pub fn value_noise_linear_z(x: f32, y: f32, z: f32, seed: u32) -> f32 {
+ value_noise_z(x, y, z, seed, false)
+}
+
+fn value_noise_z(x: f32, y: f32, z: f32, seed: u32, ease_z: bool) -> f32 {
let (x0, y0, z0) = (x.floor(), y.floor(), z.floor());
- let (fx, fy, fz) = (fade(x - x0), fade(y - y0), fade(z - z0));
+ let (fx, fy, fz) = (fade(x - x0), fade(y - y0), if ease_z { fade(z - z0) } else { z - z0 });
let (ix, iy, iz) = (x0 as i32, y0 as i32, z0 as i32);
let l = |a: f32, b: f32, t: f32| a + (b - a) * t;
let c = |dx: i32, dy: i32, dz: i32| lattice(ix + dx, iy + dy, iz + dz, seed);
@@ -414,6 +426,27 @@ mod generator_tests {
Image::filled(32, 32, [1.0, 0.0, 0.0, 1.0])
}
+ /// #943: the evolution axis is linear, so z moves the noise at a constant rate, while the
+ /// classic `value_noise` keeps its eased z (other effects and their pinned goldens use it).
+ #[test]
+ fn linear_z_value_noise_moves_at_a_constant_rate() {
+ for (x, y, seed) in [(0.3f32, 0.7f32, 5u32), (2.4, -1.6, 11), (-3.1, 4.9, 0)] {
+ let a = value_noise_linear_z(x, y, 0.0, seed);
+ let b = value_noise_linear_z(x, y, 1.0, seed);
+ for z in [0.1f32, 0.25, 0.5, 0.75, 0.9] {
+ let want = a + (b - a) * z;
+ let got = value_noise_linear_z(x, y, z, seed);
+ assert!((got - want).abs() < 1e-5, "({x}, {y}) z {z}: {got} vs {want}");
+ }
+ // Same lattice values at whole z, and x / y are untouched.
+ assert_eq!(value_noise_linear_z(x, y, 2.0, seed), value_noise(x, y, 2.0, seed));
+ }
+ // The eased version still has the old shape: at z = 0.25 it lags the linear one.
+ let (a, b) = (value_noise(0.3, 0.7, 0.0, 5), value_noise(0.3, 0.7, 1.0, 5));
+ let eased = value_noise(0.3, 0.7, 0.25, 5);
+ assert!((eased - (a + (b - a) * 0.25)).abs() > 1e-3);
+ }
+
#[test]
fn gradient_ramp_stays_inside_the_layer_alpha() {
// #1063: a shape's transparent pixels stay transparent; the ramp fills only the shape.
diff --git a/crates/effects/src/generate2.rs b/crates/effects/src/generate2.rs
index 62bce1706..b4781b793 100644
--- a/crates/effects/src/generate2.rs
+++ b/crates/effects/src/generate2.rs
@@ -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, mains: &mut Vec) {
- 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,
+ mains: Vec,
+}
+
+/// 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 {
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 {
// 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.
diff --git a/crates/effects/src/generate3/audio.rs b/crates/effects/src/generate3/audio.rs
index f1ac54db8..6c8d66e4a 100644
--- a/crates/effects/src/generate3/audio.rs
+++ b/crates/effects/src/generate3/audio.rs
@@ -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
diff --git a/crates/effects/src/generate3/common.rs b/crates/effects/src/generate3/common.rs
index b996cdc99..7e341ad8d 100644
--- a/crates/effects/src/generate3/common.rs
+++ b/crates/effects/src/generate3/common.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{EffectSpec, ParamUi, Value, slider};
pub(super) fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec {
EffectSpec { id, name, category: "Generate", params, render, gpu: false, float: true }
diff --git a/crates/effects/src/generate3/fill.rs b/crates/effects/src/generate3/fill.rs
index a86f123ee..5df782a89 100644
--- a/crates/effects/src/generate3/fill.rs
+++ b/crates/effects/src/generate3/fill.rs
@@ -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;
diff --git a/crates/effects/src/generate3/fractal.rs b/crates/effects/src/generate3/fractal.rs
index 519bec640..05a76d5f0 100644
--- a/crates/effects/src/generate3/fractal.rs
+++ b/crates/effects/src/generate3/fractal.rs
@@ -1,5 +1,5 @@
use super::raster::over;
-use super::*;
+use super::{Buf, EffectCtx, IndexedParallelIterator, ParallelIterator, ParallelSliceMut, Px, hsl_to_rgb};
// ---------------------------------------------------------------- Fractal
diff --git a/crates/effects/src/generate3/paths.rs b/crates/effects/src/generate3/paths.rs
index 779892cc7..319368a82 100644
--- a/crates/effects/src/generate3/paths.rs
+++ b/crates/effects/src/generate3/paths.rs
@@ -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.
diff --git a/crates/effects/src/generate3/raster.rs b/crates/effects/src/generate3/raster.rs
index f9acd88a3..1b85818d0 100644
--- a/crates/effects/src/generate3/raster.rs
+++ b/crates/effects/src/generate3/raster.rs
@@ -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 {
diff --git a/crates/effects/src/generate3/registry.rs b/crates/effects/src/generate3/registry.rs
index f7f6b040f..06c2b35fb 100644
--- a/crates/effects/src/generate3/registry.rs
+++ b/crates/effects/src/generate3/registry.rs
@@ -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
diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs
index cc0aad515..c865d4f96 100644
--- a/crates/effects/src/lib.rs
+++ b/crates/effects/src/lib.rs
@@ -8,6 +8,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+mod analysis_cache;
pub mod audio_fx;
mod blur2;
mod blur3;
@@ -78,7 +79,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 +87,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 +108,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};
@@ -113,7 +117,7 @@ pub use ocio::color_stabilizer_maps;
// GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes).
pub use channel3d::{CRYPTO_LAYERS, CryptoAux, aux_point, crypto_aux_image, crypto_aux_image_with, id_color, selection_hashes};
pub use ocio::{ColorOp, Straight, Tf, color_program};
-pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, drizzle_drops, mercury_blobs, sprite_plan};
+pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, bubble_sample_bounds, drizzle_drops, mercury_blobs, sprite_plan};
pub use sim2::{
CausticsSetup, FoamDisc, FoamEnv, FoamPlan, Piece, PiecePlan, PieceTex, PixelPlan, WavePlan, Waves, caustics_setup, foam_full_plan, piece_plan, pixel_plan,
wave_world_plan,
@@ -287,15 +291,66 @@ impl Buf {
self.offset[0] += origin[0] * self.scale;
self.offset[1] += origin[1] * self.scale;
}
- /// Grow by `pad` transparent pixels on every side.
- pub fn pad(&mut self, pad: u32) {
- if pad == 0 {
+ /// 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;
}
- self.img = self.img.padded(pad);
- self.offset[0] += pad as f64;
- self.offset[1] += pad as f64;
+ 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 `left` and `top` transparent pixels on those sides only.
+ pub fn grow_left_top(&mut self, left: u32, top: u32) {
+ self.pad_sides([left, top, 0, 0]);
+ }
+ /// Give the buffer the pixel-grid phase (modulo [`GRID_ALIGN`]) that [`Buf::cover`] of
+ /// `size` would, by growing left/top only ([`align_plan`]). A downscaled layer is resampled
+ /// through a pyramid whose 2x2 blocks follow the buffer's grid, so a buffer that starts at
+ /// its content's corner instead of the cover's resamples to different pixels (#1205).
+ pub fn align(&mut self, size: [f64; 2]) {
+ if let Some((l, t)) = align_plan(self.offset, self.scale, size, self.img.width, self.img.height) {
+ self.grow_left_top(l, t);
+ }
+ }
+ /// Grow by `pad` transparent pixels on every side, within [`pad_within_budget`] and as
+ /// clamped by [`Image::effective_pad`].
+ pub fn pad(&mut self, pad: u32) {
+ let eff = self.img.effective_pad(pad_within_budget(self.img.width, self.img.height, pad));
+ if eff == 0 {
+ return;
+ }
+ self.img = self.img.padded(eff);
+ self.offset[0] += eff as f64;
+ self.offset[1] += eff as f64;
+ }
+}
+
+/// `pad` on every side of a `w` × `h` effect buffer, cut so the padded buffer stays within
+/// 16 Mpx (256 MiB of f32 RGBA) or four times its own area, whichever is larger: a small layer
+/// far from an effect's centre can't ask for gigabytes (#2261). Shared with the GPU path.
+pub fn pad_within_budget(w: u32, h: u32, pad: u32) -> u32 {
+ const MIN_BUDGET: f64 = 16_777_216.0;
+ let (w, h) = (f64::from(w), f64::from(h));
+ let budget = MIN_BUDGET.max(4.0 * w * h);
+ if (w + 2.0 * f64::from(pad)) * (h + 2.0 * f64::from(pad)) <= budget {
+ return pad;
+ }
+ // (w + 2p)(h + 2p) = budget, solved for p.
+ let p = (((w - h).powi(2) + 4.0 * budget).sqrt() - (w + h)) / 4.0;
+ (p.floor().max(0.0) as u32).min(pad)
}
/// Motion Tile's output frame for a buffer with layer origin `offset` (pixels) at `scale`:
@@ -335,6 +390,33 @@ pub fn adjustment_crop(offset: [f64; 2], canvas_offset: [f64; 2], size: (u32, u3
Some((dx as i64, dy as i64))
}
+/// Effects that grow their own buffer by what they need (blur and glow margins, Repeat Edge
+/// Pixels through [`Buf::cover`], displacement through [`cover_pad`]): a stack of only these
+/// skips the renderer's cover of the whole layer rectangle (#1205).
+pub const SKIPS_COVER: &[&str] = &["ec.blur.gaussian", "ec.blur.fastbox", "ec.stylize.glow", "ec.distort.turbulentdisplace"];
+
+/// The grid phase [`Buf::align`] keeps and the step [`cover_pad`] grows in: covers the levels
+/// of the resampling pyramid used for downscaled layers.
+pub const GRID_ALIGN: u32 = 64;
+
+/// Left/top growth giving a buffer [`cover_plan`]'s grid phase modulo [`GRID_ALIGN`]; `None`
+/// when there is nothing to add.
+pub fn align_plan(offset: [f64; 2], scale: f64, size: [f64; 2], width: u32, height: u32) -> Option<(u32, u32)> {
+ let (dx, dy, _, _) = cover_plan(offset, scale, size, width, height)?;
+ let (l, t) = (dx % GRID_ALIGN, dy % GRID_ALIGN);
+ (l != 0 || t != 0).then_some((l, t))
+}
+
+/// Padding for an effect that moves pixels up to `want` past the content (not an adjustment
+/// layer): `want`, capped at what reaches the layer rectangle, in whole [`GRID_ALIGN`] steps so
+/// the grid phase stays. 0 once the buffer covers the layer.
+pub fn cover_pad(ctx: &EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f64, want: u32) -> u32 {
+ if ctx.adjustment {
+ return 0;
+ }
+ want.min(util::layer_cover_pad(ctx, size, offset, scale)).div_ceil(GRID_ALIGN) * GRID_ALIGN
+}
+
/// Where [`Buf::cover`] would take a buffer: shift its pixels by `(dx, dy)` and resize it to
/// `width` × `height` so it spans the `size`-layer-pixel region that starts at its offset — the
/// whole layer rectangle, which a sized source's buffer already is. `None` when the buffer
@@ -421,6 +503,18 @@ pub trait EffectHost: Sync {
fn params_at(&self, _layer_time: f64) -> Option {
None
}
+ /// Parameter `id` (a [`flatten_params`] id such as `birthRate`) of effect `effect_index` in
+ /// the layer's stack (the running effect's [`EffectEnv::effect_index`]) at another **layer
+ /// time**: CC Particle World samples its Birth Rate at every step it simulates (#1373).
+ /// `None` when unavailable.
+ fn param_at(&self, _effect_index: usize, _id: &str, _layer_time: f64) -> Option {
+ None
+ }
+ /// Parameter `id` of effect `effect_index` has one value at every time (no keyframes, no
+ /// expression), so sampling it over time can be skipped. `false` when unknown.
+ fn param_is_static(&self, _effect_index: usize, _id: &str) -> bool {
+ false
+ }
/// A particle simulation backend (GPU particles; see [`psim`]). `None` = simulate on the
/// CPU.
fn particles(&self) -> Option<&dyn psim::ParticleSim> {
@@ -501,6 +595,15 @@ pub struct EffectSpec {
pub float: bool,
}
+impl EffectSpec {
+ /// True when this effect grows its own buffer (blur/glow margins, Repeat Edge
+ /// [`Buf::cover`], displacement [`cover_pad`]) so a stack of only these can skip the
+ /// renderer's full-layer cover and keep the pyramid grid phase (#1205).
+ pub fn skips_cover(&self) -> bool {
+ SKIPS_COVER.contains(&self.id)
+ }
+}
+
pub(crate) fn p(id: &'static str, name: &'static str, default: Value, ui: ParamUi) -> ParamSpec {
ParamSpec { id, name, default, ui }
}
@@ -1276,6 +1379,13 @@ pub fn is_time_dependent(id: &str) -> bool {
id.starts_with("ec.sim.") || TIME_DEPENDENT.contains(&id)
}
+/// Does effect `id` read its own parameters at earlier times (the stepped particle systems sample
+/// their Birth Rate at every step, #1373)? Caches keyed on the current values can't see what an
+/// expression returned then.
+pub fn reads_param_history(id: &str) -> bool {
+ matches!(id, "ec.sim.ccparticleworld" | "ec.sim.ccparticlesystems2" | "ec.sim.ccmrmercury")
+}
+
/// Run an effect on a buffer.
pub fn apply(spec: &EffectSpec, ctx: &EffectCtx, buf: Buf) -> Buf {
(spec.render)(ctx, buf)
@@ -1300,6 +1410,23 @@ pub(crate) fn run_fx(id: &str, vals: &[(&str, Value)], img: Image, time: f64, en
mod tests {
use super::*;
+ /// #2261: a 20 × 20 layer with CC Radial Fast Blur's Center far away grows to at most 16 Mpx;
+ /// big layers may still grow to four times their area, and small pads are untouched.
+ #[test]
+ fn padded_buffers_stay_within_the_budget() {
+ let area = |w: u32, h: u32, p: u32| (u64::from(w) + 2 * u64::from(p)) * (u64::from(h) + 2 * u64::from(p));
+ let far = radial_fast_pad((6500.0, 6500.0), 20, 20, 0.8);
+ assert_eq!(far, 4096, "the effect asks for its full pad");
+ let mut b = Buf { img: Image::new(20, 20), offset: [0.0; 2], scale: 1.0 };
+ b.pad(far);
+ assert!(area(20, 20, 0) < area(b.img.width, b.img.height, 0) && area(b.img.width, b.img.height, 0) <= 1 << 24, "{}x{}", b.img.width, b.img.height);
+ assert_eq!(b.offset, [f64::from((b.img.width - 20) / 2); 2], "the content keeps its place");
+ let p = pad_within_budget(3840, 2160, 4096);
+ assert!(p > 1000 && area(3840, 2160, p) <= 4 * 3840 * 2160 && area(3840, 2160, p + 1) > 4 * 3840 * 2160);
+ assert_eq!(pad_within_budget(640, 360, 200), 200);
+ assert_eq!(pad_within_budget(20, 20, 0), 0);
+ }
+
#[test]
fn run_fx_passthrough() {
let img = Image::filled(4, 3, [0.5, 0.25, 0.125, 1.0]);
@@ -1307,6 +1434,20 @@ mod tests {
assert_eq!(out.img.data, img.data);
}
+ #[test]
+ fn pad_offset_follows_the_clamped_pad() {
+ // Image::padded clamps a hostile pad to keep each side within MAX_PADDED_SIDE; the offset
+ // must move by the pad actually applied, or the content drifts from its layer position.
+ let mut img = Image::new(29_990, 2);
+ img.set(0, 0, [1.0, 0.0, 0.0, 1.0]);
+ let mut buf = Buf { img, offset: [3.0, 4.0], scale: 1.0 };
+ buf.pad(u32::MAX);
+ // Width binds: (MAX_PADDED_SIDE - 29 990) / 2 = 5.
+ assert_eq!(buf.offset, [8.0, 9.0]);
+ let (x, y) = buf.to_px([-3.0, -4.0]);
+ assert_eq!(buf.img.get(x as i64, y as i64), [1.0, 0.0, 0.0, 1.0]);
+ }
+
#[test]
#[ignore]
fn dump_registry() {
@@ -1491,4 +1632,24 @@ mod tests {
assert_eq!(g.get("noiseSourceLayerSource").map(|p| p.value.clone()), Some(Value::Enum(0)));
assert_eq!(g.props().filter(|p| p.match_id == "noiseSourceLayerSource").count(), 1);
}
+
+ #[test]
+ fn align_plan_is_cover_shift_mod_grid() {
+ // Content inset from the layer origin: pixel 0 maps to layer (50, 30).
+ let offset = [-50.0, -30.0];
+ let scale = 1.0;
+ let size = [1920.0, 1080.0];
+ let (dx, dy, _, _) = cover_plan(offset, scale, size, 80, 40).unwrap();
+ assert_eq!((dx, dy), (50, 30));
+ assert_eq!(align_plan(offset, scale, size, 80, 40), Some((50, 30)));
+ assert_eq!(align_plan([-64.0, -128.0], 1.0, size, 80, 40), None);
+ }
+
+ #[test]
+ fn known_margin_effects_skip_cover_and_twirl_does_not() {
+ for id in SKIPS_COVER {
+ assert!(find(id).expect(id).skips_cover(), "{id}");
+ }
+ assert!(!find("ec.distort.twirl").unwrap().skips_cover());
+ }
}
diff --git a/crates/effects/src/migrate.rs b/crates/effects/src/migrate.rs
index b5976b761..35d0960e0 100644
--- a/crates/effects/src/migrate.rs
+++ b/crates/effects/src/migrate.rs
@@ -344,6 +344,23 @@ pub fn upgrade_instance(spec: &EffectSpec, g: &mut PropGroup, ids: &mut Ids, lay
*g != before
}
+/// The spec id of the parameter that an instance of `spec` saved at `id` (`a/b/param`) is now:
+/// `id` itself when the spec still has it, the new id of a renamed parameter
+/// ([`PARAM_ID_ALIASES`]), or the twirl-down path of a top-level parameter that moved into a
+/// group, as [`upgrade_instance`] finds them. Returns `None` when no parameter matches.
+pub fn current_param_id(spec: &EffectSpec, id: &str) -> Option<&'static str> {
+ if let Some(ps) = spec.params.iter().find(|ps| ps.id == id) {
+ return Some(ps.id);
+ }
+ if let Some((_, _, new)) = PARAM_ID_ALIASES.iter().find(|(e, old, _)| *e == spec.id && *old == id) {
+ return Some(new);
+ }
+ if id.contains('/') {
+ return None;
+ }
+ spec.params.iter().find(|ps| ps.id.rsplit('/').next() == Some(id)).map(|ps| ps.id)
+}
+
/// Bring an effect instance saved before project schema 2 into effect space (#227).
///
/// Layers without a source rectangle (shape, text) have comp-sized effect bounds centred on
@@ -559,6 +576,21 @@ mod tests {
assert_eq!(prop_at(&mut g, "stabilization/smoothness").unwrap().value, Value::Scalar(12.0));
}
+ /// A saved parameter id maps to the parameter `upgrade_instance` moves it to.
+ #[test]
+ fn current_param_id_follows_renames_and_twirl_down_moves() {
+ let stab = find(crate::warp_stab::ID).unwrap();
+ assert_eq!(current_param_id(stab, "stabilization/smoothness"), Some("stabilization/smoothness"));
+ assert_eq!(current_param_id(stab, "smoothness"), Some("stabilization/smoothness"));
+ let ctc = find("ec.color.changetocolor").unwrap();
+ assert_eq!(current_param_id(ctc, "tolerance"), Some("toleranceGroup/hue"));
+ // Radio Waves' old Velocity is the growth speed, not the new Velocity.
+ let waves = find("ec.generate.radiowaves").unwrap();
+ assert_eq!(current_param_id(waves, "velocity"), Some("waveMotion/expansion"));
+ assert_eq!(current_param_id(stab, "noSuchParam"), None);
+ assert_eq!(current_param_id(stab, "other/smoothness"), None);
+ }
+
#[test]
fn card_wipe_back_layer_popup_becomes_a_layer_parameter() {
let spec = find("ec.transition.cardwipe").unwrap();
diff --git a/crates/effects/src/misc.rs b/crates/effects/src/misc.rs
index 7468c6b84..15d5d0aac 100644
--- a/crates/effects/src/misc.rs
+++ b/crates/effects/src/misc.rs
@@ -34,6 +34,9 @@ fn gaussian(ctx: &EffectCtx, mut b: Buf) -> Buf {
let repeat = ctx.params.b("repeatEdge");
if !repeat && !ctx.adjustment {
b.pad((s * 3.0).ceil() as u32);
+ } else if repeat && !ctx.adjustment {
+ // The edge it repeats is the layer's, not the painted content's (#1205).
+ b.cover(ctx.layer_size);
}
let (sx, sy) = (s * kx, s * ky);
// Small sigmas: integer-radius box passes would step (and do nothing below ~0.6 px), so sample the kernel (#1939).
@@ -60,6 +63,9 @@ fn box_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
if !repeat && !ctx.adjustment {
let reach = if frac > 0.0 { r + 1 } else { r };
b.pad((reach * it) as u32 + 1);
+ } else if repeat && !ctx.adjustment {
+ // The edge it repeats is the layer's, not the painted content's (#1205).
+ b.cover(ctx.layer_size);
}
let edge = repeat || ctx.adjustment;
let lo = effectcraft_raster::box_blur(&b.img, r * kx as usize, r * ky as usize, it, edge);
@@ -142,6 +148,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 +233,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::>();
- *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 +561,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 +630,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 +921,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).
diff --git a/crates/effects/src/noise.rs b/crates/effects/src/noise.rs
index d39fd6528..9d21e77a7 100644
--- a/crates/effects/src/noise.rs
+++ b/crates/effects/src/noise.rs
@@ -264,25 +264,30 @@ pub(crate) fn sample_boxes(img: &Image, count: usize, size: usize) -> Vec<(usize
}
let low = Plane::luma(img);
let step = (size / 2).max(1);
- let mut cands: Vec<(f32, usize, usize)> = Vec::new();
- for y in (0..=h - size).step_by(step) {
- for x in (0..=w - size).step_by(step) {
- // Variance of the picture inside the box (the flattest boxes hold the least detail).
- let (mut s, mut sq, mut n, mut opaque) = (0.0f32, 0.0f32, 0.0f32, true);
- for yy in y..y + size {
- for xx in x..x + size {
- let v = low.data[yy * w + xx];
- s += v;
- sq += v * v;
- n += 1.0;
- opaque &= img.data[yy * w + xx][3] > 0.5;
+ // Candidate rows in parallel; the sort below is a total order (variance, y, x), so the
+ // result does not depend on the collection order.
+ let rows: Vec = (0..=h - size).step_by(step).collect();
+ let mut cands: Vec<(f32, usize, usize)> = rows
+ .par_iter()
+ .flat_map_iter(|&y| {
+ let data = low.data.as_slice();
+ let alpha = img.data.as_slice();
+ (0..=w - size).step_by(step).filter_map(move |x| {
+ // Variance of the picture inside the box (the flattest boxes hold the least detail).
+ let (mut s, mut sq, mut n, mut opaque) = (0.0f32, 0.0f32, 0.0f32, true);
+ for yy in y..y + size {
+ for xx in x..x + size {
+ let v = data[yy * w + xx];
+ s += v;
+ sq += v * v;
+ n += 1.0;
+ opaque &= alpha[yy * w + xx][3] > 0.5;
+ }
}
- }
- if opaque {
- cands.push((sq / n - (s / n).powi(2), x, y));
- }
- }
- }
+ opaque.then(|| (sq / n - (s / n).powi(2), x, y))
+ })
+ })
+ .collect();
cands.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.2.cmp(&b.2)).then(a.1.cmp(&b.1)));
cands.into_iter().take(count).map(|(_, x, y)| (x, y, size)).collect()
}
@@ -479,16 +484,7 @@ pub(crate) fn sampling_boxes(ctx: &EffectCtx, img: &Image, scale: f64) -> Vec<(u
/// The grain level (0.5..2) measured in the sample boxes of `img`.
fn grain_level(img: &Image, boxes: &[(usize, usize, usize)]) -> f32 {
- let mut m = vec![false; img.data.len()];
- let w = img.width as usize;
- for &(x, y, s) in boxes {
- for yy in y..y + s {
- for xx in x..x + s {
- m[yy * w + xx] = true;
- }
- }
- }
- let st = crate::noise2::grain_stats_masked(img, Some(&m));
+ let st = crate::noise2::grain_stats_boxes(img, boxes);
let s = (st.std[0] + st.std[1] + st.std[2]) / 3.0;
if boxes.is_empty() { 1.0 } else { (s / 0.02).clamp(0.5, 2.0) }
}
diff --git a/crates/effects/src/noise2.rs b/crates/effects/src/noise2.rs
index 4c22f6d8f..48d840416 100644
--- a/crates/effects/src/noise2.rs
+++ b/crates/effects/src/noise2.rs
@@ -75,6 +75,102 @@ pub(crate) fn grain_stats_masked(img: &Image, mask: Option<&[bool]>) -> GrainSta
GrainStats { std, size: (1.0 / (1.0 - rho as f32)).clamp(0.5, 20.0) * 0.6 }
}
+/// Grain statistics over sample `boxes` (in image pixels), measured from small crops around each
+/// box instead of the whole image. Empty `boxes` measures every opaque pixel, like
+/// [`grain_stats_masked`] without a mask. Overlapping boxes are deduplicated, so the sums match
+/// the masked version.
+pub(crate) fn grain_stats_boxes(img: &Image, boxes: &[(usize, usize, usize)]) -> GrainStats {
+ if boxes.is_empty() {
+ return grain_stats_masked(img, None);
+ }
+ let (w, h) = (img.width as usize, img.height as usize);
+ if w < 3 || h < 3 {
+ return GrainStats::default();
+ }
+ // The Gaussian's three box passes at sigma 1.5 have radius 1 each, so 3 px of halo make the
+ // residual exact inside a box; one more column covers the right neighbour used by the lag-1
+ // correlation.
+ const HALO: usize = 4;
+ let mut mask = vec![false; w * h];
+ for &(x, y, s) in boxes {
+ for yy in y..y.saturating_add(s).min(h) {
+ for xx in x..x.saturating_add(s).min(w) {
+ mask[yy * w + xx] = true;
+ }
+ }
+ }
+ let mut counted = vec![false; w * h];
+ let mut n = [0.0f64; 3];
+ let mut sum = [0.0f64; 3];
+ let mut sq = [0.0f64; 3];
+ let mut rho_num = 0.0f64;
+ let mut rho_den = 0.0f64;
+ let mut crop: Vec<([f32; 3], f32)> = Vec::new();
+ let mut planes: Vec = Vec::new();
+ for &(bx, by, bs) in boxes {
+ let x0 = bx.saturating_sub(HALO);
+ let y0 = by.saturating_sub(HALO);
+ let x1 = bx.saturating_add(bs).saturating_add(HALO).min(w);
+ let y1 = by.saturating_add(bs).saturating_add(HALO).min(h);
+ if x1 <= x0 || y1 <= y0 {
+ continue;
+ }
+ let (cw, ch) = (x1 - x0, y1 - y0);
+ crop.clear();
+ crop.extend((y0..y1).flat_map(|y| (x0..x1).map(move |x| unpremul(img.data[y * w + x]))));
+ planes.clear();
+ for k in 0..3 {
+ let pl = Plane { w: cw, h: ch, data: crop.iter().map(|s| s.0[k]).collect() };
+ planes.push(gauss_plane(&pl, 1.5, 1.5));
+ }
+ for ly in 0..bs {
+ let y = by + ly;
+ if y >= h {
+ break;
+ }
+ for lx in 0..bs {
+ let x = bx + lx;
+ if x >= w {
+ break;
+ }
+ let g = y * w + x;
+ if !mask[g] || counted[g] {
+ continue;
+ }
+ counted[g] = true;
+ let l = (y - y0) * cw + (x - x0);
+ let (_, a) = crop[l];
+ if a <= 0.5 {
+ continue;
+ }
+ for k in 0..3 {
+ let hp = crop[l].0[k] - planes[k].data[l];
+ sum[k] += hp as f64;
+ sq[k] += (hp as f64) * (hp as f64);
+ n[k] += 1.0;
+ }
+ if x + 1 < w && mask[g + 1] && crop[l + 1].1 > 0.5 {
+ for k in 0..3 {
+ let hp = crop[l].0[k] - planes[k].data[l];
+ let hn = crop[l + 1].0[k] - planes[k].data[l + 1];
+ rho_num += (hp * hn) as f64;
+ rho_den += (hp * hp) as f64;
+ }
+ }
+ }
+ }
+ }
+ let mut std = [0.0f32; 3];
+ for k in 0..3 {
+ if n[k] > 1.0 {
+ let mean = sum[k] / n[k];
+ std[k] = ((sq[k] / n[k] - mean * mean).max(0.0)).sqrt() as f32;
+ }
+ }
+ let rho = if rho_den > 1e-12 { (rho_num / rho_den).clamp(0.0, 0.95) } else { 0.0 };
+ GrainStats { std, size: (1.0 / (1.0 - rho as f32)).clamp(0.5, 20.0) * 0.6 }
+}
+
/// Match Grain's Viewing Mode options.
const MATCH_GRAIN_VIEWS: [&str; 5] = ["Preview", "Noise Samples", "Compensation Samples", "Blending Matte", "Final Output"];
@@ -86,34 +182,24 @@ fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf {
let view = ctx.params.e("viewingMode");
let source = layer_or_self(ctx, &b, "noiseSourceLayer", false, false);
let src_boxes = sampling_boxes(ctx, &source, b.scale);
- let own_boxes = sampling_boxes(ctx, &b.img, b.scale);
let box_col = ctx.params.get("sampling/sampleBoxColor").map(|v| v.as_color()).unwrap_or([1.0, 1.0, 0.0, 1.0]);
if view == 1 || view == 2 {
// Noise Samples (on the source) / Compensation Samples (on this layer).
- let (mut img, boxes) = if view == 1 { (source, src_boxes) } else { (b.img.clone(), own_boxes) };
+ let (mut img, boxes) = if view == 1 { (source, src_boxes) } else { (b.img.clone(), sampling_boxes(ctx, &b.img, b.scale)) };
draw_boxes(&mut img, &boxes, box_col);
b.img = img;
return b;
}
- let mask_of = |img: &Image, boxes: &[(usize, usize, usize)]| -> Option> {
- if boxes.is_empty() {
- return None;
- }
- let w = img.width as usize;
- let mut m = vec![false; img.data.len()];
- for &(x, y, s) in boxes {
- for yy in y..y + s {
- for xx in x..x + s {
- m[yy * w + xx] = true;
- }
- }
- }
- Some(m)
- };
- let src_stats = grain_stats_masked(&source, mask_of(&source, &src_boxes).as_deref());
+ let src_stats = grain_stats_boxes(&source, &src_boxes);
let comp = (ctx.params.f("compensateForExistingNoise") / 100.0) as f32;
- let own = if comp > 0.0 { grain_stats_masked(&b.img, mask_of(&b.img, &own_boxes).as_deref()) } else { GrainStats::default() };
- let orig = b.img.clone();
+ // The own-layer boxes only matter for the compensation term; skip them when it is off.
+ let own = if comp > 0.0 {
+ let own_boxes = sampling_boxes(ctx, &b.img, b.scale);
+ grain_stats_boxes(&b.img, &own_boxes)
+ } else {
+ GrainStats::default()
+ };
+ let orig = (view == 0).then(|| b.img.clone());
let look = GrainLook::from_params(ctx);
let amp: [f32; 3] = [0, 1, 2].map(|k| {
let s = src_stats.std[k];
@@ -147,8 +233,8 @@ fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf {
}
}
});
- if view == 0 {
- crate::noise::preview_compose(ctx, &mut b, &orig);
+ if let Some(orig) = &orig {
+ crate::noise::preview_compose(ctx, &mut b, orig);
}
b
}
@@ -306,6 +392,63 @@ mod tests {
img
}
+ #[test]
+ fn boxed_grain_stats_match_the_masked_ones() {
+ // Noisy image with alpha holes; the boxes cover overlapping, edge-adjacent and
+ // bottom-right corner cases (clamped crops).
+ let mut img = noisy(64, 48, 0.2, 9);
+ for y in 0..img.height {
+ for x in 0..img.width {
+ if (x / 7 + y / 5) % 6 == 0 {
+ let mut p = img.get(x as i64, y as i64);
+ p[3] = 0.0;
+ img.set(x, y, p);
+ }
+ }
+ }
+ let boxes = [(3usize, 4usize, 9usize), (8, 6, 9), (40, 20, 12), (39, 21, 12), (55, 39, 9)];
+ let w = img.width as usize;
+ let mut mask = vec![false; img.data.len()];
+ for &(x, y, s) in &boxes {
+ for yy in y..y + s {
+ for xx in x..x + s {
+ mask[yy * w + xx] = true;
+ }
+ }
+ }
+ let masked = grain_stats_masked(&img, Some(&mask));
+ let boxed = grain_stats_boxes(&img, &boxes);
+ for k in 0..3 {
+ assert!((masked.std[k] - boxed.std[k]).abs() < 1e-6, "{k}: {masked:?} vs {boxed:?}");
+ }
+ assert!((masked.size - boxed.size).abs() <= 1e-7, "size: {masked:?} vs {boxed:?}");
+ // A denser grid where every box overlaps its neighbours (mask dedup across boxes).
+ let img2 = noisy(96, 64, 0.15, 21);
+ let mut boxes2 = Vec::new();
+ for gy in 0..5 {
+ for gx in 0..6 {
+ boxes2.push((gx * 11 + (gy % 3) * 4, gy * 10 + gx % 2, 12));
+ }
+ }
+ let w2 = img2.width as usize;
+ let mut mask2 = vec![false; img2.data.len()];
+ for &(x, y, s) in &boxes2 {
+ for yy in y..y + s {
+ for xx in x..x + s {
+ mask2[yy * w2 + xx] = true;
+ }
+ }
+ }
+ let masked2 = grain_stats_masked(&img2, Some(&mask2));
+ let boxed2 = grain_stats_boxes(&img2, &boxes2);
+ for k in 0..3 {
+ assert!((masked2.std[k] - boxed2.std[k]).abs() < 1e-6, "{k}: {masked2:?} vs {boxed2:?}");
+ }
+ assert!((masked2.size - boxed2.size).abs() <= 1e-7, "size: {masked2:?} vs {boxed2:?}");
+ // No boxes measures the whole image, like no mask.
+ assert_eq!(grain_stats_boxes(&img, &[]), grain_stats(&img));
+ }
+
#[test]
fn match_grain_transfers_grain_strength() {
let flat = Image::filled(48, 48, [0.5, 0.5, 0.5, 1.0]);
diff --git a/crates/effects/src/noise3.rs b/crates/effects/src/noise3.rs
index bf1f05ed6..49f200553 100644
--- a/crates/effects/src/noise3.rs
+++ b/crates/effects/src/noise3.rs
@@ -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, 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 = (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 = (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))];
diff --git a/crates/effects/src/object_matte.rs b/crates/effects/src/object_matte.rs
index d98ec73ad..6804ea2c7 100644
--- a/crates/effects/src/object_matte.rs
+++ b/crates/effects/src/object_matte.rs
@@ -191,6 +191,11 @@ pub fn roto_data(params: &Params, layer_size: [f64; 2], f: i64, with_model: bool
/// Seed of the chain keys: what the frames are (the Input Key), the layer size, the frame rate,
/// the mode and the model. The prompt is in each frame's key.
pub fn seed(params: &Params, layer_size: [f64; 2], fps: f64, model: Option<&dyn MaskModel>) -> u64 {
+ seed_of(params, layer_size, fps, model.map_or(effectcraft_segment::CLASSICAL, |m| m.info().id))
+}
+
+/// [`seed`] for the model with id `model` ([`effectcraft_segment::CLASSICAL`]: no model).
+fn seed_of(params: &Params, layer_size: [f64; 2], fps: f64, model: &str) -> u64 {
let mut h = 0xcbf2_9ce4_8422_2325;
fnv(&mut h, ID.as_bytes());
fnv(&mut h, s(params, INPUT_KEY).as_bytes());
@@ -198,7 +203,7 @@ pub fn seed(params: &Params, layer_size: [f64; 2], fps: f64, model: Option<&dyn
fnv(&mut h, &v.to_bits().to_le_bytes());
}
fnv(&mut h, &[tracks(params) as u8]);
- fnv(&mut h, model.map_or(effectcraft_segment::CLASSICAL, |m| m.info().id).as_bytes());
+ fnv(&mut h, model.as_bytes());
h
}
@@ -255,9 +260,14 @@ pub fn frame_keys(params: &Params, layer_size: [f64; 2], fps: f64, frames: &[i64
/// What frozen mattes depend on: what the frames are (the seed: Input Key, size, rate, mode,
/// model), the prompt, the tracked span and the matte settings.
pub fn frozen_key(params: &Params, layer_size: [f64; 2], fps: f64, model: Option<&dyn MaskModel>) -> String {
- let mut h = seed(params, layer_size, fps, model);
+ frozen_key_of(params, layer_size, fps, model.map_or(effectcraft_segment::CLASSICAL, |m| m.info().id))
+}
+
+/// [`frozen_key`] for the model with id `model` ([`effectcraft_segment::CLASSICAL`]: no model).
+fn frozen_key_of(params: &Params, layer_size: [f64; 2], fps: f64, model: &str) -> String {
+ let mut h = seed_of(params, layer_size, fps, model);
let base = reference_frame(params);
- if let Some(st) = prompt_strokes(params, layer_size, base, model.is_some()) {
+ if let Some(st) = prompt_strokes(params, layer_size, base, model != effectcraft_segment::CLASSICAL) {
fnv(&mut h, serde_json::to_string(&st).unwrap_or_default().as_bytes());
}
for v in [base, reach(params)[0], reach(params)[1]] {
@@ -273,7 +283,9 @@ pub fn is_frozen(params: &Params) -> bool {
}
/// Frame `f`'s frozen matte on a `w`×`h` grid, when the instance is frozen, the mattes still
-/// match ([`frozen_key`]) and `f` is one of them.
+/// match ([`frozen_key`]) and `f` is one of them. The mattes match under the session's `model`
+/// or under the model that froze them (which this session may not have loaded, e.g. a fresh
+/// headless render): frozen mattes need no segmentation.
pub fn frozen_alpha(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, w: usize, h: usize, model: Option<&dyn MaskModel>) -> Option> {
let j = s(params, FROZEN);
if j.is_empty() {
@@ -281,7 +293,10 @@ pub fn frozen_alpha(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, w:
}
let fc = frozen_cache(j)?;
if fc.key != frozen_key(params, layer_size, fps, model) {
- return None;
+ let ids = std::iter::once(effectcraft_segment::CLASSICAL).chain(effectcraft_segment::MODELS.iter().map(|m| m.id));
+ if !ids.into_iter().any(|id| fc.key == frozen_key_of(params, layer_size, fps, id)) {
+ return None;
+ }
}
let fr = fc.frames.get(&f)?;
let [fw, fh] = fc.size;
@@ -358,20 +373,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>,
frame: &mut dyn FnMut(i64) -> Option>,
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 +427,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 +627,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 +657,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 +670,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]));
}
@@ -692,4 +720,28 @@ mod tests {
p.values.insert(FROZEN.into(), Value::Str(fc2.to_json()));
assert_eq!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 100, 80, None), None);
}
+
+ /// Mattes frozen with a model stay usable in a session where that model is not loaded (a
+ /// fresh headless render): they are used as stored, not re-segmented with another model.
+ #[test]
+ fn frozen_mattes_survive_a_session_without_their_model() {
+ let mut p = params(&[
+ ("objectPoint", Value::Vec2([40.0, 40.0])),
+ ("useBox", Value::Bool(true)),
+ ("boxTopLeft", Value::Vec2([10.0, 10.0])),
+ ("boxBottomRight", Value::Vec2([90.0, 70.0])),
+ ("objectTracking/framesAfter", Value::Scalar(3.0)),
+ ]);
+ p.values.insert(INPUT_KEY.into(), Value::Str("frozen-model-test".into()));
+ let stub = Stub::default();
+ let alpha: Vec = (0..100 * 80).map(|i| ((i % 100) < 50) as u8 as f32).collect();
+ let mut fc = FrozenCache { key: frozen_key(&p, [100.0, 80.0], 25.0, Some(&stub)), size: [100, 80], ..Default::default() };
+ fc.frames.insert(2, roto::freeze_frame(&alpha, &[]));
+ p.values.insert(FROZEN.into(), Value::Str(fc.to_json()));
+ assert!(is_frozen(&p));
+ assert!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 100, 80, Some(&stub)).is_some());
+ assert_eq!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 50, 40, None).map(|a| (a[20 * 50 + 5], a[20 * 50 + 45])), Some((1.0, 0.0)));
+ let out = run(&p, 2);
+ assert!(out.data[40 * 100 + 10][3] > 0.99 && out.data[40 * 100 + 90][3] < 0.01, "the frozen matte is used");
+ }
}
diff --git a/crates/effects/src/ocio/cdl.rs b/crates/effects/src/ocio/cdl.rs
index 1feab04a2..dba8c68fe 100644
--- a/crates/effects/src/ocio/cdl.rs
+++ b/crates/effects/src/ocio/cdl.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{ParamUi, slider};
// ---------------------------------------------------------------- ASC CDL
diff --git a/crates/effects/src/ocio/effects.rs b/crates/effects/src/ocio/effects.rs
index 7d07011a4..5df24096d 100644
--- a/crates/effects/src/ocio/effects.rs
+++ b/crates/effects/src/ocio/effects.rs
@@ -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
diff --git a/crates/effects/src/ocio/lut.rs b/crates/effects/src/ocio/lut.rs
index ff4cbd935..8a6481eb6 100644
--- a/crates/effects/src/ocio/lut.rs
+++ b/crates/effects/src/ocio/lut.rs
@@ -1,5 +1,5 @@
use super::cdl::{Cdl, parse_cdl_xml};
-use super::*;
+use super::{Arc, HashMap, Lut, Mutex, OnceLock, parse_cube};
// ---------------------------------------------------------------- LUT files
diff --git a/crates/effects/src/ocio/program.rs b/crates/effects/src/ocio/program.rs
index ab9c4ea78..a223dc7b4 100644
--- a/crates/effects/src/ocio/program.rs
+++ b/crates/effects/src/ocio/program.rs
@@ -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)
diff --git a/crates/effects/src/ocio/registry.rs b/crates/effects/src/ocio/registry.rs
index b020b053b..cd3bd02a7 100644
--- a/crates/effects/src/ocio/registry.rs
+++ b/crates/effects/src/ocio/registry.rs
@@ -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 {
let cc = "Color Correction";
diff --git a/crates/effects/src/ocio/space.rs b/crates/effects/src/ocio/space.rs
index 44fcf84d0..1a1d8f69b 100644
--- a/crates/effects/src/ocio/space.rs
+++ b/crates/effects/src/ocio/space.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{invert, mul, mul_vec, rgb_to_xyz};
pub(super) type M3 = [[f64; 3]; 3];
diff --git a/crates/effects/src/psim.rs b/crates/effects/src/psim.rs
index aabbcdce8..5716a2dcb 100644
--- a/crates/effects/src/psim.rs
+++ b/crates/effects/src/psim.rs
@@ -85,21 +85,27 @@ pub trait ParticleSim: Send + Sync {
/// (the CPU step's `carry` arithmetic, exactly). `None` when more than `cap` would be born (the
/// CPU drops births at its live-particle cap, which an independent simulation can't know).
pub fn births(rate: f64, steps: u64, cap: usize) -> Option> {
- let per = rate / SPS;
- if per * steps as f64 > cap as f64 + 2.0 {
+ if rate / SPS * steps as f64 > cap as f64 + 2.0 {
return None;
}
+ births_at(steps, cap, |_| rate)
+}
+
+/// [`births`] with the rate of each step, `rate(i)` particles per second during step `i` (a
+/// keyframed Birth Rate, #1373).
+pub fn births_at(steps: u64, cap: usize, rate: impl Fn(u64) -> f64) -> Option> {
let mut out = Vec::new();
let mut carry = 0.0f64;
for i in 0..steps {
- carry += per;
+ carry += rate(i) / SPS;
let n = carry.floor() as u32;
carry -= n as f64;
- for _ in 0..n {
- out.push(i as u32);
+ if n as usize > cap.saturating_sub(out.len()) {
+ return None;
}
+ out.extend(std::iter::repeat_n(i as u32, n as usize));
}
- (out.len() <= cap).then_some(out)
+ Some(out)
}
/// Steps per second of the stepped simulations.
diff --git a/crates/effects/src/roto.rs b/crates/effects/src/roto.rs
index 4917cc231..2467a719d 100644
--- a/crates/effects/src/roto.rs
+++ b/crates/effects/src/roto.rs
@@ -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 {
diff --git a/crates/effects/src/sim.rs b/crates/effects/src/sim.rs
index 3a0743af2..333549e7d 100644
--- a/crates/effects/src/sim.rs
+++ b/crates/effects/src/sim.rs
@@ -15,7 +15,7 @@ use effectcraft_raster::{Image, Px};
use rayon::prelude::*;
use crate::generate::value_noise;
-use crate::util::{SimCache, hash1, params_key, params_key_except, unpremul};
+use crate::util::{SimCache, hash1, params_key_except, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
mod common;
@@ -28,15 +28,17 @@ mod weather;
pub(crate) use common::{h, hs, pct, pt, spec};
pub use engine::particle_state;
#[cfg(test)]
-pub(crate) use engine::{PState, PW_ANIMS, Phys, simulate};
+use engine::{MERCURY, PS2, PW_EFFECT_CAMERA, Stepped, rate_history, sim_key, simulate_rates};
#[cfg(test)]
-use engine::{PW_EFFECT_CAMERA, sim_key};
+pub(crate) use engine::{PState, PW_ANIMS, Phys, simulate};
pub(crate) use engine::{SPS, steps_at};
pub use particle_fx::{Blob, mercury_blobs};
+#[cfg(test)]
+use particle_fx::{PW_RATE, mercury_phys, ps2_phys, pw_phys};
pub use registry::{specs, sprite_plan};
pub use sprites::{Acc, Post, Shape, Sprite, SpritePlan, Tint};
pub(crate) use sprites::{combine, raster, splat};
-pub use weather::{Bubble, bubble_list, drizzle_drops};
+pub use weather::{Bubble, bubble_list, bubble_sample_bounds, drizzle_drops};
#[cfg(test)]
mod tests;
diff --git a/crates/effects/src/sim/common.rs b/crates/effects/src/sim/common.rs
index c11c3f886..294d787f1 100644
--- a/crates/effects/src/sim/common.rs
+++ b/crates/effects/src/sim/common.rs
@@ -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, render: crate::RenderFn) -> EffectSpec {
EffectSpec { id, name, category: "Simulation", params, render, gpu: false, float: true }
diff --git a/crates/effects/src/sim/engine.rs b/crates/effects/src/sim/engine.rs
index 973b7b5e8..beda8c318 100644
--- a/crates/effects/src/sim/engine.rs
+++ b/crates/effects/src/sim/engine.rs
@@ -1,7 +1,7 @@
use super::common::{h, hs};
-use super::particle_fx::{PS2_CACHE, PW_CACHE, ps2_phys, pw_phys};
+use super::particle_fx::{MERCURY_CACHE, MERCURY_RATE, PS2_CACHE, PS2_RATE, PW_CACHE, PW_RATE, 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
@@ -29,6 +29,14 @@ pub(crate) struct PState {
pub parts: Vec,
pub carry: f64,
pub next_id: u32,
+ /// The birth rates of the steps so far (see [`rates_hash`]): a checkpoint is only resumed
+ /// from with the same history.
+ pub hist: u64,
+}
+
+/// `hist` after one more step at `rate` particles per second.
+fn rates_hash(hist: u64, rate: f64) -> u64 {
+ (hist ^ rate.to_bits()).wrapping_mul(0x0100_0000_01b3).rotate_left(5)
}
/// Physics configuration (world units; y down).
@@ -106,7 +114,15 @@ impl Phys {
Particle { p, v, age: 0.0, life, rnd: h(id, 11, seed), id }
}
+ /// One step at the constant [`Phys::rate`] (the tests' reference).
+ #[cfg(test)]
pub(crate) fn step(&self, st: &mut PState, step: u64) {
+ self.step_at_rate(st, step, self.rate);
+ }
+
+ /// One step with `rate` particles per second born during it (rather than [`Phys::rate`]):
+ /// the rate only decides the births, particles already alive move and age as ever.
+ pub(crate) fn step_at_rate(&self, st: &mut PState, step: u64, rate: f64) {
let dt = (1.0 / SPS) as f32;
let t = step as f32 * dt;
let mut drag = self.resistance;
@@ -164,7 +180,8 @@ impl Phys {
q.age += dt;
});
st.parts.retain(|q| q.age < q.life);
- st.carry += self.rate / SPS;
+ st.hist = rates_hash(st.hist, rate);
+ st.carry += rate / SPS;
let n = st.carry.floor() as u32;
st.carry -= n as f64;
for _ in 0..n {
@@ -211,42 +228,111 @@ impl Phys {
}
/// The state at layer time `ctx.time` from the host's particle backend (GPU particles), or
-/// `None` (simulate on the CPU).
-pub(super) fn accelerated(ctx: &EffectCtx, key: u64, phys: &Phys) -> Option> {
+/// `None` (simulate on the CPU). `rates`: the birth rate of each step, when it changes over time
+/// (else [`Phys::rate`]).
+pub(super) fn accelerated(ctx: &EffectCtx, key: u64, phys: &Phys, rates: Option<&[f64]>) -> Option> {
let backend = ctx.env.host?.particles()?;
let steps = steps_at(ctx.time);
- let births = crate::psim::births(phys.rate, steps, phys.max)?;
+ let births = match rates {
+ Some(r) => crate::psim::births_at(steps, phys.max, |i| r.get(i as usize).copied().unwrap_or(0.0))?,
+ None => crate::psim::births(phys.rate, steps, phys.max)?,
+ };
let req = crate::psim::SimRequest { key, steps, births: &births, system: crate::psim::ParticleSystem::Engine(phys.desc()) };
let parts = backend.simulate(&req)?;
Some(std::sync::Arc::new(PState {
parts: parts.into_iter().map(|q| Particle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect(),
carry: 0.0,
next_id: births.len() as u32,
+ hist: 0,
}))
}
+/// The birth rate (particles per second) of each step up to layer time `ctx.time`: parameter
+/// `id` × `k`, sampled where the step starts (a keyframed or expression-driven Birth Rate only
+/// decides the births of its own time, #1373). Without a host that evaluates the effect at
+/// other times, the current value for every step.
+pub(super) fn rate_history(ctx: &EffectCtx, id: &str, k: f64) -> Vec {
+ let now = ctx.params.f(id);
+ // A rate without keyframes or an expression is the same at every step.
+ let host = ctx.env.host.filter(|h| !h.param_is_static(ctx.env.effect_index, id));
+ (0..steps_at(ctx.time))
+ .map(|i| {
+ let v = host.and_then(|h| h.param_at(ctx.env.effect_index, id, i as f64 / SPS)).map_or(now, |v| v.as_f64());
+ if v.is_finite() { v.max(0.0) * k } else { 0.0 }
+ })
+ .collect()
+}
+
+/// Run a particle system through `rates` (one birth rate per step, see [`rate_history`]) with
+/// `cache`, resuming only from checkpoints reached with the same rates.
+pub(super) fn simulate_rates(cache: &SimCache, key: u64, phys: &Phys, rates: &[f64]) -> std::sync::Arc {
+ // `hist` of the state after each number of steps.
+ let mut prefix = Vec::with_capacity(rates.len() + 1);
+ prefix.push(0u64);
+ for r in rates {
+ prefix.push(rates_hash(prefix.last().copied().unwrap_or(0), *r));
+ }
+ let rate = |i: u64| rates.get(i as usize).copied().unwrap_or(0.0);
+ cache.run_where(key, rates.len() as u64, PState::default, |st, i| phys.step_at_rate(st, i, rate(i)), |s, st| prefix.get(s as usize) == Some(&st.hist))
+}
+
+/// A stepped particle system's particles at `ctx.time`, born at the Birth Rate of their own
+/// time (`birthRate` × `k` particles per second, see [`rate_history`]). The cache key is every
+/// parameter but Birth Rate (its history is checked through `PState::hist`) and those under
+/// `skip`, with `salt`. `gpu`: the host's particle backend may simulate it.
+pub(super) struct Stepped<'a> {
+ pub(super) cache: &'a SimCache,
+ pub(super) salt: u64,
+ pub(super) skip: &'a [&'a str],
+ pub(super) k: f64,
+ pub(super) gpu: bool,
+}
+
+impl Stepped<'_> {
+ pub(super) fn state(&self, ctx: &EffectCtx, phys: &Phys) -> std::sync::Arc {
+ let rates = rate_history(ctx, "birthRate", self.k);
+ let mut skip = self.skip.to_vec();
+ skip.push("birthRate");
+ let key = params_key_except(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, self.salt, &skip);
+ let gpu = if self.gpu { accelerated(ctx, key, phys, Some(&rates)) } else { None };
+ gpu.unwrap_or_else(|| simulate_rates(self.cache, key, phys, &rates))
+ }
+}
+
+/// CC Particle World.
+const PW: Stepped = Stepped { cache: &PW_CACHE, salt: 1, skip: &[PW_EFFECT_CAMERA], k: PW_RATE, gpu: true };
+/// CC Particle Systems II.
+pub(super) const PS2: Stepped = Stepped { cache: &PS2_CACHE, salt: 2, skip: &[], k: PS2_RATE, gpu: true };
+/// CC Mr. Mercury (its blobs are simulated on the CPU).
+pub(super) const MERCURY: Stepped = Stepped { cache: &MERCURY_CACHE, salt: 3, skip: &[], k: MERCURY_RATE, gpu: false };
+
+/// CC Particle World's particles at `ctx.time`, born at the Birth Rate of their own time.
+pub(super) fn pw_state(ctx: &EffectCtx, phys: &Phys) -> std::sync::Arc {
+ PW.state(ctx, phys)
+}
+
/// The live particles of a stepped particle effect (`ec.sim.ccparticleworld`,
/// `ec.sim.ccparticlesystems2`) at `ctx.time`: from the host's particle backend when it has
/// one (GPU particles), else from the CPU simulation. For comparing the two.
pub fn particle_state(id: &str, ctx: &EffectCtx) -> Option> {
- let (phys, cache, salt) = match id {
- "ec.sim.ccparticleworld" => (pw_phys(ctx), &PW_CACHE, 1),
- "ec.sim.ccparticlesystems2" => (ps2_phys(ctx), &PS2_CACHE, 2),
+ let st = match id {
+ "ec.sim.ccparticleworld" => pw_state(ctx, &pw_phys(ctx)),
+ "ec.sim.ccparticlesystems2" => PS2.state(ctx, &ps2_phys(ctx)),
_ => return None,
};
- let key = sim_key(ctx, salt);
- let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(cache, key, ctx.time, &phys));
Some(st.parts.iter().map(|q| crate::psim::SimParticle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect())
}
-/// The simulation cache key of a stepped particle effect: every parameter but CC Particle World's
-/// Effect Camera, which only changes how the particles are seen (animating it doesn't restart
-/// the simulation).
+/// The simulation cache key of a stepped particle effect at a constant Birth Rate (the tests'
+/// reference): every parameter but CC Particle World's Effect Camera.
+#[cfg(test)]
pub(super) fn sim_key(ctx: &EffectCtx, salt: u64) -> u64 {
params_key_except(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, salt, &[PW_EFFECT_CAMERA])
}
-/// Run a particle system to layer time `t` through `cache`.
+/// Run a particle system at its constant [`Phys::rate`] to layer time `t` through `cache` (the
+/// tests' reference).
+#[cfg(test)]
pub(crate) fn simulate(cache: &SimCache, key: u64, t: f64, phys: &Phys) -> std::sync::Arc {
cache.run(key, steps_at(t), PState::default, |st, i| phys.step(st, i))
}
@@ -263,7 +349,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 },
diff --git a/crates/effects/src/sim/particle_fx.rs b/crates/effects/src/sim/particle_fx.rs
index f8af6cba0..440951c83 100644
--- a/crates/effects/src/sim/particle_fx.rs
+++ b/crates/effects/src/sim/particle_fx.rs
@@ -1,16 +1,19 @@
-use super::engine::{PState, PW_EFFECT_CAMERA, Phys, accelerated, norm3, particle_look, sim_key, simulate, type_sprite};
+use super::engine::{MERCURY, PS2, PState, PW_EFFECT_CAMERA, Phys, norm3, particle_look, pw_state, type_sprite};
use super::sprites::{Acc, Post, Sprite, SpritePlan, raster, splat};
-use super::*;
+use super::{Buf, EffectCtx, Image, IntoParallelRefIterator, ParallelIterator, SimCache, Value, unpremul};
// ------------------------------------------------------------------ CC Particle World
pub(super) static PW_CACHE: SimCache = SimCache::new(6);
+/// Particles per second per unit of CC Particle World's Birth Rate.
+pub(super) const PW_RATE: f64 = 120.0;
+
/// CC Particle World's physics.
pub(super) fn pw_phys(ctx: &EffectCtx) -> Phys {
let pr = ctx.params;
Phys {
- rate: pr.f("birthRate").max(0.0) * 120.0,
+ rate: pr.f("birthRate").max(0.0) * PW_RATE,
life: pr.f("longevity").max(0.0) as f32,
producer: [pr.f("producer/producerX") as f32, pr.f("producer/producerY") as f32, pr.f("producer/producerZ") as f32],
radius: [pr.f("producer/radiusX").max(0.0) as f32, pr.f("producer/radiusY").max(0.0) as f32, pr.f("producer/radiusZ").max(0.0) as f32],
@@ -152,8 +155,7 @@ pub(super) fn particle_world_plan(ctx: &EffectCtx, b: &Buf) -> Option Option, mode: u32) -> SpritePlan {
pub(super) static PS2_CACHE: SimCache = SimCache::new(6);
+/// Particles per second per unit of CC Particle Systems II's Birth Rate.
+pub(super) const PS2_RATE: f64 = 60.0;
+
/// CC Particle Systems II's physics.
pub(super) fn ps2_phys(ctx: &EffectCtx) -> Phys {
let pr = ctx.params;
@@ -198,7 +203,7 @@ pub(super) fn ps2_phys(ctx: &EffectCtx) -> Phys {
let pos = pr.v2("producer/position");
let dir = (pr.f("physics/direction") as f32).to_radians();
Phys {
- rate: pr.f("birthRate").max(0.0) * 60.0,
+ rate: pr.f("birthRate").max(0.0) * PS2_RATE,
life: pr.f("longevity").max(0.0) as f32,
producer: [pos[0] as f32 / unit, pos[1] as f32 / unit, 0.0],
radius: [pr.f("producer/radiusX").max(0.0) as f32 / unit, pr.f("producer/radiusY").max(0.0) as f32 / unit, 0.0],
@@ -225,9 +230,7 @@ pub(super) fn particle_systems2_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan {
let pr = ctx.params;
let lh = ctx.layer_size[1] as f32;
let unit = lh.max(1.0);
- let phys = ps2_phys(ctx);
- let key = sim_key(ctx, 2);
- let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(&PS2_CACHE, key, ctx.time, &phys));
+ let st = PS2.state(ctx, &ps2_phys(ctx));
let kind = pr.e("particle/particleType");
let birth = pr.color("particle/birthColor");
let death = pr.color("particle/deathColor");
@@ -242,7 +245,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"))
@@ -250,7 +253,10 @@ pub(super) fn particle_systems2_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan {
// ------------------------------------------------------------------ CC Mr. Mercury
-static MERCURY_CACHE: SimCache = SimCache::new(6);
+pub(super) static MERCURY_CACHE: SimCache = SimCache::new(6);
+
+/// Blobs per second per unit of CC Mr. Mercury's Birth Rate.
+pub(super) const MERCURY_RATE: f64 = 10.0;
/// A CC Mr. Mercury blob (buffer px).
#[derive(Clone, Copy, Debug)]
@@ -260,15 +266,14 @@ pub struct Blob {
pub r: f32,
}
-/// CC Mr. Mercury's blobs this frame, from its particle simulation (for buffer geometry `b`).
-pub fn mercury_blobs(ctx: &EffectCtx, b: &Buf) -> Vec {
+/// CC Mr. Mercury's physics.
+pub(super) fn mercury_phys(ctx: &EffectCtx) -> Phys {
let pr = ctx.params;
- let lh = ctx.layer_size[1] as f32;
- let unit = lh.max(1.0);
+ let unit = (ctx.layer_size[1] as f32).max(1.0);
let pos = pr.v2("producer");
let dir = (pr.f("direction") as f32).to_radians();
- let phys = Phys {
- rate: pr.f("birthRate").max(0.0) * 10.0,
+ Phys {
+ rate: pr.f("birthRate").max(0.0) * MERCURY_RATE,
life: pr.f("longevity").max(0.0) as f32,
producer: [pos[0] as f32 / unit, pos[1] as f32 / unit, 0.0],
radius: [pr.f("radiusX").max(0.0) as f32 / unit, pr.f("radiusY").max(0.0) as f32 / unit, 0.0],
@@ -282,8 +287,15 @@ pub fn mercury_blobs(ctx: &EffectCtx, b: &Buf) -> Vec {
seed: ctx.seed.wrapping_mul(0xc2b2ae35),
max: 4_000,
flat: true,
- };
- let st = simulate(&MERCURY_CACHE, params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 3), ctx.time, &phys);
+ }
+}
+
+/// CC Mr. Mercury's blobs this frame, from its particle simulation (for buffer geometry `b`).
+pub fn mercury_blobs(ctx: &EffectCtx, b: &Buf) -> Vec {
+ let pr = ctx.params;
+ let unit = (ctx.layer_size[1] as f32).max(1.0);
+ let phys = mercury_phys(ctx);
+ let st = MERCURY.state(ctx, &phys);
let (bs, ds) = (pr.f("blobBirthSize") as f32, pr.f("blobDeathSize") as f32);
let s = b.scale as f32;
st.parts
diff --git a/crates/effects/src/sim/registry.rs b/crates/effects/src/sim/registry.rs
index a688f1046..f29eb2653 100644
--- a/crates/effects/src/sim/registry.rs
+++ b/crates/effects/src/sim/registry.rs
@@ -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
diff --git a/crates/effects/src/sim/sprites.rs b/crates/effects/src/sim/sprites.rs
index 982579a5c..78c405abd 100644
--- a/crates/effects/src/sim/sprites.rs
+++ b/crates/effects/src/sim/sprites.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{Buf, Image, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, ParallelIterator, ParallelSliceMut, Px, unpremul};
// ------------------------------------------------------------------ band rasteriser
diff --git a/crates/effects/src/sim/tests.rs b/crates/effects/src/sim/tests.rs
index c6f83ab4e..06ce973b2 100644
--- a/crates/effects/src/sim/tests.rs
+++ b/crates/effects/src/sim/tests.rs
@@ -97,6 +97,60 @@ fn bubbles_rise() {
assert!(y0 >= 0.0 && y1 >= 0.0 && (y1 < y0 || y0 < 8.0), "{y0} -> {y1}");
}
+/// #2024: bubbles near the layer's edges show its edge pixels, not the pale colour that stands
+/// in for transparent ones.
+#[test]
+fn bubbles_near_edges_show_the_layer() {
+ let blue = Image::filled(40, 40, [0.0, 0.0, 1.0, 1.0]);
+ let pale = [0, 1].map(|reflection| {
+ let vals = [("bubbleAmount", num(200.0)), ("bubbleSize", num(3.0)), ("reflectionType", Value::Enum(reflection))];
+ let a = run("ec.sim.ccbubbles", &vals, blue.clone(), 0.3);
+ assert!(a.data.iter().any(|p| p[3] > 0.5), "bubbles drawn");
+ // Blue plus the white highlight keeps blue − red = alpha; the pale colour leaves 0.2 · alpha.
+ a.data.iter().filter(|p| (p[2] - p[0] - p[3]).abs() > 1e-4).count()
+ });
+ assert_eq!(pale, [0, 0], "pale bubble pixels (liquid, metal)");
+}
+
+/// CC Bubbles on a 40 × 40 layer `img` at `scale`, in a buffer padded by 6 transparent pixels
+/// (a half-resolution preview's layout).
+fn padded_bubbles(img: Image, scale: f64, reflection: u32) -> Image {
+ let size = 40.0;
+ let s = crate::find("ec.sim.ccbubbles").unwrap();
+ let mut params = crate::Params { values: s.params.iter().map(|p| (p.id.to_string(), crate::default_value(p, [size; 2]))).collect() };
+ for (k, v) in [("bubbleAmount", num(200.0)), ("bubbleSize", num(3.0)), ("reflectionType", Value::Enum(reflection))] {
+ params.values.insert(k.into(), v);
+ }
+ let ctx = EffectCtx { params: ¶ms, time: 0.3, layer_size: [size; 2], seed: 1, adjustment: false, env: EffectEnv::default() };
+ let mut b = Buf { img, offset: [0.0; 2], scale };
+ b.pad(6);
+ crate::apply(s, &ctx, b).img
+}
+
+/// #2024: the same when the buffer has transparent padding around the layer: samples past the
+/// layer's edges repeat its edge pixels, not the padding, and a clear hole inside the layer is
+/// still refracted as the pale colour.
+#[test]
+fn bubbles_near_padded_edges_show_the_layer() {
+ let blue = |side| Image::filled(side, side, [0.0, 0.0, 1.0, 1.0]);
+ let pale = |img: &Image| img.data.iter().filter(|p| (p[2] - p[0] - p[3]).abs() > 1e-4).count();
+ for (scale, side) in [(1.0, 40), (0.5, 20)] {
+ let counts = [0, 1].map(|reflection| {
+ let a = padded_bubbles(blue(side), scale, reflection);
+ assert!(a.data.iter().any(|p| p[3] > 0.5), "bubbles drawn");
+ pale(&a)
+ });
+ assert_eq!(counts, [0, 0], "pale bubble pixels at scale {scale} (liquid, metal)");
+ }
+ let mut holed = blue(40);
+ for y in 12..28 {
+ for x in 12..28 {
+ holed.set(x, y, [0.0; 4]);
+ }
+ }
+ assert!(pale(&padded_bubbles(holed, 1.0, 0)) > 0, "the hole refracts as the pale colour");
+}
+
#[test]
fn drizzle_ripples() {
let a = run("ec.sim.ccdrizzle", &[], ramp(48, 48), 0.6);
@@ -405,3 +459,266 @@ fn half_resolution_scales_sprites() {
let sum = |i: &Image| i.data.iter().map(|p| p[0]).sum::() / i.data.len() as f32;
assert!((sum(&full.img) - sum(&half.img)).abs() < 0.05, "{} {}", sum(&full.img), sum(&half.img));
}
+
+// ---------------------------------------------------------- Birth Rate over time (#1373)
+
+/// A host that evaluates effect `index`'s Birth Rate at other layer times as `rate(t)`.
+struct Rate {
+ index: usize,
+ rate: fn(f64) -> f64,
+}
+impl crate::EffectHost for Rate {
+ fn layer(&self, _: u64, _: bool) -> Option {
+ None
+ }
+ fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> {
+ None
+ }
+ fn param_at(&self, effect_index: usize, id: &str, t: f64) -> Option {
+ (effect_index == self.index && id == "birthRate").then(|| num((self.rate)(t)))
+ }
+}
+
+/// Birth Rate 3 until 0.5 s, then 0 (the issue's one-shot burst).
+fn burst(t: f64) -> f64 {
+ if t < 0.5 { 3.0 } else { 0.0 }
+}
+
+/// The same burst, cut at 0.25 s (an earlier key moved).
+fn short_burst(t: f64) -> f64 {
+ if t < 0.25 { 3.0 } else { 0.0 }
+}
+
+/// Birth Rate falling linearly from 3 at 0 s to 0 at 0.5 s.
+fn falling(t: f64) -> f64 {
+ (3.0 * (1.0 - t / 0.5)).max(0.0)
+}
+
+/// CC Particle World's parameters: random seed `seed`, Birth Rate `now` (its value at the
+/// rendered time), Longevity 1 s.
+fn pw_params(seed: f64, now: f64) -> crate::Params {
+ let spec = crate::find("ec.sim.ccparticleworld").unwrap();
+ let mut p = crate::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), crate::default_value(ps, [100.0, 100.0]))).collect() };
+ for (k, v) in [("birthRate", num(now)), ("extras/randomSeed", num(seed)), ("longevity", num(1.0))] {
+ p.values.insert(k.to_string(), v);
+ }
+ p
+}
+
+fn pw_ctx<'a>(p: &'a crate::Params, t: f64, host: Option<&'a Rate>, index: usize) -> EffectCtx<'a> {
+ let env = EffectEnv { host: host.map(|h| h as &dyn crate::EffectHost), effect_index: index, ..Default::default() };
+ EffectCtx { params: p, time: t, layer_size: [100.0, 100.0], seed: 1, adjustment: false, env }
+}
+
+/// The live particles at layer time `t` with Birth Rate `now` there and `host`'s history.
+fn pw_at(seed: f64, now: f64, t: f64, host: Option<&Rate>) -> Vec {
+ let p = pw_params(seed, now);
+ particle_state("ec.sim.ccparticleworld", &pw_ctx(&p, t, host, host.map_or(0, |h| h.index))).unwrap()
+}
+
+/// The same, simulated from layer time 0 with a cache of its own (the reference).
+fn pw_fresh(seed: f64, now: f64, t: f64, host: &Rate) -> Vec {
+ let p = pw_params(seed, now);
+ let ctx = pw_ctx(&p, t, Some(host), host.index);
+ let key = params_key_except(&ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 1, &[PW_EFFECT_CAMERA, "birthRate"]);
+ let st = simulate_rates(&SimCache::new(1), key, &pw_phys(&ctx), &rate_history(&ctx, "birthRate", PW_RATE));
+ st.parts.iter().map(|q| crate::psim::SimParticle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect()
+}
+
+/// Particles born in the first `t` seconds of `rate`.
+fn born_by(rate: fn(f64) -> f64, t: f64) -> usize {
+ crate::psim::births_at(steps_at(t), 40_000, |i| rate(i as f64 / SPS) * PW_RATE).unwrap().len()
+}
+
+/// #1373: Birth Rate only decides the births of its own time. After a burst stops, the
+/// particles born in it live on (moving and ageing) until their Longevity runs out, rather
+/// than vanishing because the rate is now 0.
+#[test]
+fn a_burst_lives_on_after_birth_rate_drops_to_zero() {
+ let host = Rate { index: 2, rate: burst };
+ // During the burst: as a constant Birth Rate of 3.
+ assert_eq!(pw_at(101.0, 3.0, 0.4, Some(&host)), pw_at(101.0, 3.0, 0.4, None));
+ // After it: the burst's particles, none newer.
+ let late = pw_at(101.0, 0.0, 0.9, Some(&host));
+ let n = born_by(burst, 0.5);
+ assert!(late.len() > n / 2 && late.iter().all(|q| (q.id as usize) < n), "{} live of {n} born", late.len());
+ assert!(pw_at(101.0, 0.0, 0.9, None).is_empty(), "a constant Birth Rate of 0 never bore any");
+ // They keep moving.
+ let early = pw_at(101.0, 0.0, 0.7, Some(&host));
+ let q0 = early.iter().find(|q| q.id == late[0].id).unwrap();
+ assert!(q0.p != late[0].p && late[0].age > q0.age + 0.19, "{q0:?} -> {:?}", late[0]);
+ // Longevity 1 s (± 20 %): all gone by 1.8 s.
+ assert!(pw_at(101.0, 0.0, 1.8, Some(&host)).is_empty());
+ // The effect asks for its own index: another effect's history isn't its own.
+ let p = pw_params(101.0, 0.0);
+ assert!(particle_state("ec.sim.ccparticleworld", &pw_ctx(&p, 0.9, Some(&host), 0)).unwrap().is_empty());
+}
+
+/// A Birth Rate ramping down isn't simulated as if its current value had always applied.
+#[test]
+fn a_ramped_birth_rate_bears_what_each_moment_asked_for() {
+ let host = Rate { index: 0, rate: falling };
+ let ramped = pw_at(102.0, falling(0.25), 0.25, Some(&host));
+ let constant = pw_at(102.0, falling(0.25), 0.25, None);
+ assert_eq!(ramped.len(), born_by(falling, 0.25), "every particle born so far is alive");
+ assert_eq!(constant.len(), born_by(|_| 1.5, 0.25));
+ assert!(ramped.len() > constant.len() + 10, "{} vs {}", ramped.len(), constant.len());
+}
+
+/// Seeking backward, forward and to the same time again gives the state simulated from 0.
+#[test]
+fn seeking_through_a_birth_rate_history_matches_simulating_from_zero() {
+ let host = Rate { index: 0, rate: burst };
+ for t in [1.4, 0.6, 0.9, 0.3, 0.9, 1.1] {
+ let now = burst(t);
+ assert_eq!(pw_at(103.0, now, t, Some(&host)), pw_fresh(103.0, now, t, &host), "at {t}");
+ }
+}
+
+/// The simulation cache key leaves Birth Rate out (so an animated rate keeps its checkpoints),
+/// but a checkpoint is only resumed with the history that reached it: after an earlier key
+/// moves, the same current rate (0) gives the new history's particles.
+#[test]
+fn an_earlier_birth_rate_change_is_not_served_from_stale_checkpoints() {
+ let (long, short) = (Rate { index: 0, rate: burst }, Rate { index: 0, rate: short_burst });
+ let a = pw_at(104.0, 0.0, 0.9, Some(&long));
+ let b = pw_at(104.0, 0.0, 0.9, Some(&short));
+ assert_eq!(b, pw_fresh(104.0, 0.0, 0.9, &short));
+ assert!(b.len() < a.len() && !b.is_empty(), "{} vs {}", b.len(), a.len());
+ assert_eq!(pw_at(104.0, 0.0, 1.0, Some(&long)), pw_fresh(104.0, 0.0, 1.0, &long));
+}
+
+/// A constant Birth Rate gives exactly the particles it did before the history was sampled.
+#[test]
+fn a_constant_birth_rate_is_unchanged() {
+ let host = Rate { index: 0, rate: |_| 2.0 };
+ let p = pw_params(105.0, 2.0);
+ let ctx = pw_ctx(&p, 1.3, None, 0);
+ let phys = pw_phys(&ctx);
+ let before = simulate(&SimCache::new(1), sim_key(&ctx, 1), 1.3, &phys);
+ let now = pw_at(105.0, 2.0, 1.3, Some(&host));
+ assert_eq!(now.len(), before.parts.len());
+ assert!(now.iter().zip(&before.parts).all(|(a, b)| a.id == b.id && a.p == b.p && a.v == b.v && a.age == b.age));
+ assert_eq!(crate::psim::births_at(78, 40_000, |_| phys.rate), crate::psim::births(phys.rate, 78, 40_000));
+}
+
+/// The GPU's birth schedule for a rate history is the CPU's: the ids born are the ids the
+/// CPU simulation numbers.
+#[test]
+fn the_birth_schedule_follows_the_rate_history() {
+ let host = Rate { index: 0, rate: falling };
+ let p = pw_params(106.0, 0.0);
+ let ctx = pw_ctx(&p, 0.7, Some(&host), 0);
+ let rates = rate_history(&ctx, "birthRate", PW_RATE);
+ let st = simulate_rates(&SimCache::new(1), 9, &pw_phys(&ctx), &rates);
+ let births = crate::psim::births_at(rates.len() as u64, 40_000, |i| rates[i as usize]).unwrap();
+ assert_eq!(st.next_id as usize, births.len());
+ assert!(births.iter().all(|&b| f64::from(b) / SPS < 0.5), "nothing born once the ramp reaches 0");
+}
+
+// ------------------------- CC Particle Systems II and CC Mr. Mercury's Birth Rate over time
+
+/// A stepped particle effect: its id, physics and simulation.
+struct System {
+ id: &'static str,
+ phys: fn(&EffectCtx) -> Phys,
+ stepped: Stepped<'static>,
+}
+
+const SYSTEMS: [System; 2] =
+ [System { id: "ec.sim.ccparticlesystems2", phys: ps2_phys, stepped: PS2 }, System { id: "ec.sim.ccmrmercury", phys: mercury_phys, stepped: MERCURY }];
+
+/// `id`'s parameters with Birth Rate `now` and Longevity 1 s.
+fn sys_params(id: &str, now: f64) -> crate::Params {
+ let spec = crate::find(id).unwrap();
+ let mut p = crate::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), crate::default_value(ps, [100.0, 100.0]))).collect() };
+ for (k, v) in [("birthRate", num(now)), ("longevity", num(1.0))] {
+ assert!(p.values.contains_key(k), "{id}: no {k}");
+ p.values.insert(k.to_string(), v);
+ }
+ p
+}
+
+/// The context at layer time `t` (`seed` keeps each test's simulations apart).
+fn sys_ctx<'a>(p: &'a crate::Params, t: f64, host: Option<&'a Rate>, seed: u32) -> EffectCtx<'a> {
+ let env = EffectEnv { host: host.map(|h| h as &dyn crate::EffectHost), effect_index: host.map_or(0, |h| h.index), ..Default::default() };
+ EffectCtx { params: p, time: t, layer_size: [100.0, 100.0], seed, adjustment: false, env }
+}
+
+/// The live particles (id, position, age) of `sys` at `t` through its simulation.
+fn sys_at(sys: &System, seed: u32, now: f64, t: f64, host: Option<&Rate>) -> Vec<(u32, [f32; 3], f32)> {
+ let p = sys_params(sys.id, now);
+ let ctx = sys_ctx(&p, t, host, seed);
+ sys.stepped.state(&ctx, &(sys.phys)(&ctx)).parts.iter().map(|q| (q.id, q.p, q.age)).collect()
+}
+
+/// The same, simulated from layer time 0 with a cache of its own (the reference).
+fn sys_fresh(sys: &System, seed: u32, now: f64, t: f64, host: &Rate) -> Vec<(u32, [f32; 3], f32)> {
+ let p = sys_params(sys.id, now);
+ let ctx = sys_ctx(&p, t, Some(host), seed);
+ let mut skip = sys.stepped.skip.to_vec();
+ skip.push("birthRate");
+ let key = params_key_except(&ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, sys.stepped.salt, &skip);
+ let st = simulate_rates(&SimCache::new(1), key, &(sys.phys)(&ctx), &rate_history(&ctx, "birthRate", sys.stepped.k));
+ st.parts.iter().map(|q| (q.id, q.p, q.age)).collect()
+}
+
+/// What `sys` draws at `t`: CC Particle Systems II's particles, CC Mr. Mercury's blobs.
+fn sys_drawn(sys: &System, seed: u32, now: f64, t: f64, host: Option<&Rate>) -> usize {
+ let p = sys_params(sys.id, now);
+ let ctx = sys_ctx(&p, t, host, seed);
+ match sys.id {
+ "ec.sim.ccmrmercury" => mercury_blobs(&ctx, &Buf { img: Image::new(100, 100), offset: [0.0; 2], scale: 1.0 }).len(),
+ id => particle_state(id, &ctx).unwrap().len(),
+ }
+}
+
+/// #1373 for CC Particle Systems II and CC Mr. Mercury: a burst lives on after Birth Rate
+/// drops to 0 (what they draw too), moving and ageing, and is gone after its Longevity.
+#[test]
+fn particle_systems_ii_and_mr_mercury_keep_a_burst_after_birth_rate_drops() {
+ let host = Rate { index: 1, rate: burst };
+ for sys in &SYSTEMS {
+ let born = crate::psim::births_at(steps_at(0.5), 40_000, |i| burst(i as f64 / SPS) * sys.stepped.k).unwrap().len();
+ assert_eq!(sys_at(sys, 201, 3.0, 0.4, Some(&host)), sys_at(sys, 201, 3.0, 0.4, None), "{}: during the burst", sys.id);
+ let late = sys_at(sys, 201, 0.0, 0.9, Some(&host));
+ assert!(late.len() > born / 2 && late.iter().all(|q| (q.0 as usize) < born), "{}: {} live of {born}", sys.id, late.len());
+ assert!(sys_drawn(sys, 201, 0.0, 0.9, Some(&host)) > 0, "{}: drawn", sys.id);
+ assert_eq!(sys_drawn(sys, 201, 0.0, 0.9, None), 0, "{}: a constant 0 bore none", sys.id);
+ let early = sys_at(sys, 201, 0.0, 0.7, Some(&host));
+ let q0 = early.iter().find(|q| q.0 == late[0].0).unwrap();
+ assert!(q0.1 != late[0].1 && late[0].2 > q0.2 + 0.19, "{}: {q0:?} -> {:?}", sys.id, late[0]);
+ assert!(sys_at(sys, 201, 0.0, 1.8, Some(&host)).is_empty(), "{}: gone after Longevity", sys.id);
+ }
+}
+
+/// Seeking matches a simulation from 0, and moving an earlier key (same current rate, same
+/// cache key) isn't served from the old history's checkpoints.
+#[test]
+fn particle_systems_ii_and_mr_mercury_seek_and_drop_stale_checkpoints() {
+ let (long, short) = (Rate { index: 0, rate: burst }, Rate { index: 0, rate: short_burst });
+ for sys in &SYSTEMS {
+ for t in [1.4, 0.6, 0.9, 0.3, 0.9, 1.1] {
+ let now = burst(t);
+ assert_eq!(sys_at(sys, 202, now, t, Some(&long)), sys_fresh(sys, 202, now, t, &long), "{} at {t}", sys.id);
+ }
+ let b = sys_at(sys, 202, 0.0, 0.9, Some(&short));
+ assert_eq!(b, sys_fresh(sys, 202, 0.0, 0.9, &short), "{}: the moved key", sys.id);
+ assert!(!b.is_empty() && b.len() < sys_at(sys, 202, 0.0, 0.9, Some(&long)).len(), "{}", sys.id);
+ }
+}
+
+/// A constant Birth Rate gives exactly the particles the constant-rate simulation did.
+#[test]
+fn particle_systems_ii_and_mr_mercury_are_unchanged_at_a_constant_rate() {
+ let host = Rate { index: 0, rate: |_| 2.0 };
+ for sys in &SYSTEMS {
+ let p = sys_params(sys.id, 2.0);
+ let ctx = sys_ctx(&p, 1.3, None, 203);
+ let before = simulate(&SimCache::new(1), 1, 1.3, &(sys.phys)(&ctx));
+ let before: Vec<_> = before.parts.iter().map(|q| (q.id, q.p, q.age)).collect();
+ assert!(!before.is_empty());
+ assert_eq!(sys_at(sys, 203, 2.0, 1.3, Some(&host)), before, "{}: keyed to a constant", sys.id);
+ assert_eq!(sys_at(sys, 203, 2.0, 1.3, None), before, "{}: no host", sys.id);
+ }
+}
diff --git a/crates/effects/src/sim/weather.rs b/crates/effects/src/sim/weather.rs
index cac0b023c..347ab4939 100644
--- a/crates/effects/src/sim/weather.rs
+++ b/crates/effects/src/sim/weather.rs
@@ -1,6 +1,7 @@
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};
+use crate::util::layer_rect_at;
// ------------------------------------------------------------------ CC Rainfall / CC Snowfall
@@ -160,11 +161,21 @@ pub fn bubble_list(ctx: &EffectCtx, b: &Buf) -> Vec {
.collect()
}
+/// CC Bubbles' refraction sample bounds in the pixels of a buffer with `offset` and `scale`: the
+/// layer rectangle's first and last pixel centres (x0, y0, x1, y1). Samples clamped to them repeat
+/// the layer's edge pixels instead of reaching transparent padding around it (#2024).
+pub fn bubble_sample_bounds(ctx: &EffectCtx, offset: [f64; 2], scale: f64) -> [f32; 4] {
+ let (lx, ly, lw, lh) = layer_rect_at(ctx, offset, scale);
+ let (x0, y0) = (lx + 0.5, ly + 0.5);
+ [x0 as f32, y0 as f32, (lx + lw - 0.5).max(x0) as f32, (ly + lh - 0.5).max(y0) as f32]
+}
+
pub(super) fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let metal = pr.e("reflectionType") == 1;
let shading = pr.e("shadingType");
let list = bubble_list(ctx, &b);
+ let [x0, y0, x1, y1] = bubble_sample_bounds(ctx, b.offset, b.scale);
let src = b.img.clone();
let mut out = Image::new(src.width, src.height);
raster(
@@ -182,7 +193,8 @@ pub(super) fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf {
// Liquid refracts (inverted, magnified), metal reflects (wide-angle mirror).
let k = if metal { 1.6 } else { -0.7 };
let nz = (1.0 - d2).max(0.0).sqrt();
- let p = src.sample_bilinear((q.sx + dx * q.r * k) as f64, (q.sy + dy * q.r * k) as f64);
+ let (sx, sy) = ((q.sx + dx * q.r * k).max(x0).min(x1), (q.sy + dy * q.r * k).max(y0).min(y1));
+ let p = src.sample_bilinear_clamped(sx as f64, sy as f64);
let (c, sa) = unpremul(p);
let mut c = if sa <= 0.0 { [0.6, 0.7, 0.8] } else { c };
let mut a = cov;
diff --git a/crates/effects/src/sim2/foam.rs b/crates/effects/src/sim2/foam.rs
index cf9009492..fc187d191 100644
--- a/crates/effects/src/sim2/foam.rs
+++ b/crates/effects/src/sim2/foam.rs
@@ -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
diff --git a/crates/effects/src/sim2/pieces.rs b/crates/effects/src/sim2/pieces.rs
index 3aa3a68f6..bff5943c0 100644
--- a/crates/effects/src/sim2/pieces.rs
+++ b/crates/effects/src/sim2/pieces.rs
@@ -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
diff --git a/crates/effects/src/sim2/pixels.rs b/crates/effects/src/sim2/pixels.rs
index 2079f03c3..2cda428db 100644
--- a/crates/effects/src/sim2/pixels.rs
+++ b/crates/effects/src/sim2/pixels.rs
@@ -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
diff --git a/crates/effects/src/sim2/registry.rs b/crates/effects/src/sim2/registry.rs
index 93508e7b5..333916a16 100644
--- a/crates/effects/src/sim2/registry.rs
+++ b/crates/effects/src/sim2/registry.rs
@@ -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
diff --git a/crates/effects/src/sim2/shatter.rs b/crates/effects/src/sim2/shatter.rs
index 90b5163f1..844fbb4b3 100644
--- a/crates/effects/src/sim2/shatter.rs
+++ b/crates/effects/src/sim2/shatter.rs
@@ -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
diff --git a/crates/effects/src/sim2/surfaces.rs b/crates/effects/src/sim2/surfaces.rs
index 0bcc639c4..7c3c032f0 100644
--- a/crates/effects/src/sim2/surfaces.rs
+++ b/crates/effects/src/sim2/surfaces.rs
@@ -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
diff --git a/crates/effects/src/sim3.rs b/crates/effects/src/sim3.rs
index 688b45950..88a16d981 100644
--- a/crates/effects/src/sim3.rs
+++ b/crates/effects/src/sim3.rs
@@ -575,7 +575,8 @@ fn mapper_from(ctx: &EffectCtx, group: &str, ephemeral: bool) -> Option
/// 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);
diff --git a/crates/effects/src/stylize3.rs b/crates/effects/src/stylize3.rs
index 403c325cd..e28f9a4fa 100644
--- a/crates/effects/src/stylize3.rs
+++ b/crates/effects/src/stylize3.rs
@@ -351,7 +351,7 @@ fn hex_cell(x: f64, y: f64, r: f64) -> ((f64, f64), (i64, i64)) {
let rr = (2.0 / 3.0 * y) / r;
// Cube rounding.
let (cx, cz) = (q, rr);
- let cy = -cx - cz;
+ let cy = (-3f64.sqrt() / 3.0 * x - y / 3.0) / r;
let (mut rx, ry, mut rz) = (cx.round(), cy.round(), cz.round());
let (dx, dy, dz) = ((rx - cx).abs(), (ry - cy).abs(), (rz - cz).abs());
if dx > dy && dx > dz {
diff --git a/crates/effects/src/util.rs b/crates/effects/src/util.rs
index ae55d095d..9c81dab10 100644
--- a/crates/effects/src/util.rs
+++ b/crates/effects/src/util.rs
@@ -6,6 +6,10 @@ use effectcraft_color::{luminance, rgb_to_hsl};
use effectcraft_raster::{Image, Px};
use rayon::prelude::*;
+/// Below this many pixels, `Plane::transpose` and `Plane::map_rows` run on the calling thread:
+/// the rayon dispatch costs more than the work on small crops (the Match Grain sample boxes).
+const PAR_MIN: usize = 4096;
+
/// A single-channel `f32` image (row-major).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Plane {
@@ -62,6 +66,15 @@ impl Plane {
pub fn transpose(&self) -> Plane {
let (w, h) = (self.w, self.h);
let mut out = Plane::new(h, w);
+ if w.saturating_mul(h) < PAR_MIN {
+ // Small planes: the rayon dispatch costs more than the copy itself.
+ for (x, col) in out.data.chunks_mut(h.max(1)).enumerate() {
+ for y in 0..h {
+ col[y] = self.data[y * w + x];
+ }
+ }
+ return out;
+ }
out.data.par_chunks_mut(h.max(1)).enumerate().for_each(|(x, col)| {
for y in 0..h {
col[y] = self.data[y * w + x];
@@ -75,6 +88,11 @@ impl Plane {
if self.w == 0 {
return out;
}
+ if self.w.saturating_mul(self.h) < PAR_MIN {
+ // Small planes: the rayon dispatch costs more than the rows themselves.
+ out.data.chunks_mut(self.w).zip(self.data.chunks(self.w)).for_each(|(o, s)| f(s, o));
+ return out;
+ }
out.data.par_chunks_mut(self.w).zip(self.data.par_chunks(self.w)).for_each(|(o, s)| f(s, o));
out
}
@@ -534,14 +552,29 @@ impl SimCache {
/// State after `target` steps. `init` builds step 0; `step(state, i)` advances from step
/// `i` to `i + 1`.
- pub fn run(&self, key: u64, target: u64, init: impl FnOnce() -> S, mut step: impl FnMut(&mut S, u64)) -> std::sync::Arc {
+ pub fn run(&self, key: u64, target: u64, init: impl FnOnce() -> S, step: impl FnMut(&mut S, u64)) -> std::sync::Arc {
+ self.run_where(key, target, init, step, |_, _| true)
+ }
+
+ /// [`SimCache::run`] for a simulation whose steps also depend on inputs outside `key` (a
+ /// parameter's values over time): a checkpoint after `s` steps is only resumed from when
+ /// `valid(s, state)` says it was reached with the same inputs. Fresh checkpoints replace
+ /// those at the same step.
+ pub fn run_where(
+ &self,
+ key: u64,
+ target: u64,
+ init: impl FnOnce() -> S,
+ mut step: impl FnMut(&mut S, u64),
+ valid: impl Fn(u64, &S) -> bool,
+ ) -> std::sync::Arc {
use std::sync::Arc;
let start = {
let mut g = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let tick = g.iter().map(|e| e.used).max().unwrap_or(0) + 1;
g.iter_mut().find(|e| e.key == key).and_then(|e| {
e.used = tick;
- e.checkpoints.iter().filter(|(s, _)| *s <= target).max_by_key(|(s, _)| *s).map(|(s, st)| (*s, st.clone()))
+ e.checkpoints.iter().filter(|(s, st)| *s <= target && valid(*s, st)).max_by_key(|(s, _)| *s).map(|(s, st)| (*s, st.clone()))
})
};
let had_start = start.is_some();
@@ -579,9 +612,8 @@ impl SimCache {
let e = &mut g[idx];
e.used = tick;
for (s, st) in fresh {
- if !e.checkpoints.iter().any(|(c, _)| *c == s) {
- e.checkpoints.push((s, st));
- }
+ e.checkpoints.retain(|(c, _)| *c != s);
+ e.checkpoints.push((s, st));
}
// Keep memory bounded: drop unaligned (exact-target) checkpoints first, then thin.
if e.checkpoints.len() > 256 {
diff --git a/crates/effects/src/utility.rs b/crates/effects/src/utility.rs
index 1d3b3ee4a..e41b42e99 100644
--- a/crates/effects/src/utility.rs
+++ b/crates/effects/src/utility.rs
@@ -100,17 +100,11 @@ impl Cineon {
fn knee(&self) -> f32 {
1.0 - (self.rolloff / 100.0).clamp(0.0, 0.99)
}
- /// Normalised code value (code / 1023) → linear.
+ /// Normalized code value (code / 1023) → linear, raised to `1.7 / gamma`
+ /// ([`effectcraft_project::cineon_log_to_lin`], which Interpret Footage ▸ Cineon Settings
+ /// also uses with `1 / gamma`).
pub fn log_to_lin(&self, v: f32) -> f32 {
- let off = self.offset();
- let code = v * 1023.0;
- let mut lin = (10f32.powf((code - self.white) * 0.002 / 0.6) - off) / (1.0 - off).max(1e-6);
- lin = lin.max(0.0).powf(1.7 / self.gamma.max(0.01));
- let k = self.knee();
- if k < 1.0 && lin > k {
- let w = 1.0 - k;
- lin = k + w * (1.0 - (-(lin - k) / w).exp());
- }
+ let lin = effectcraft_project::cineon_log_to_lin(v, self.black, self.white, 1.7, self.gamma, self.rolloff);
self.ib + (self.iw - self.ib) * lin
}
/// Linear → normalised code value.
@@ -495,6 +489,87 @@ mod tests {
assert!(c.log_to_lin(95.0 / 1023.0).abs() < 1e-4);
}
+ /// Interpret Footage ▸ Cineon Settings and the Cineon Converter's Log to Linear give the same
+ /// values for the same points and Highlight Rolloff when the converter's Gamma is 1.7 times
+ /// Cineon Settings' Gamma: the converter's default 1.7 and Cineon Settings' Gamma 1 both give
+ /// linear light (code 400 is 0.10251).
+ #[test]
+ fn cineon_converter_matches_interpret_footage() {
+ let codes = [0.0f32, 95.0, 250.0, 400.0, 560.0, 685.0, 800.0, 1023.0];
+ for (gamma, rolloff) in [(1.0, 20.0), (1.0, 0.0), (1.7, 50.0), (2.4, 50.0)] {
+ let mut img = Image::new(codes.len() as u32, 1);
+ for (x, code) in codes.iter().enumerate() {
+ let v = code / 1023.0;
+ img.set(x as u32, 0, [v, v, v, 1.0]);
+ }
+ let vals = [("conversionType", Value::Enum(1)), ("gamma", num(gamma * 1.7)), ("highlightRolloff", num(rolloff))];
+ let out = run("ec.utility.cineon", &vals, img);
+ let settings = effectcraft_project::CineonSettings { log_conversion: true, gamma: gamma as f32, rolloff: rolloff as f32, ..Default::default() };
+ for (x, code) in codes.iter().enumerate() {
+ let footage = settings.convert(code / 1023.0);
+ let effect = out.img.data[x][0];
+ assert!((footage - effect).abs() < 1e-6, "gamma {gamma}, rolloff {rolloff}, code {code}: {footage} vs {effect}");
+ }
+ }
+ let linear = effectcraft_project::CineonSettings { gamma: 1.0, rolloff: 0.0, ..Default::default() };
+ assert!((linear.convert(400.0 / 1023.0) - 0.10251).abs() < 1e-4);
+ }
+
+ /// The Cineon Converter gives the same values as before Cineon Settings shared its
+ /// log-to-linear function, at the default Gamma (1.7) and at Gamma 2.4, for all three
+ /// conversion types.
+ #[test]
+ fn cineon_converter_output_is_unchanged() {
+ fn log_to_lin(v: f32, gamma: f32, rolloff: f32, black: f32, white: f32) -> f32 {
+ let off = 10f32.powf((black - white) * 0.002 / 0.6);
+ let mut lin = (10f32.powf((v * 1023.0 - white) * 0.002 / 0.6) - off) / (1.0 - off).max(1e-6);
+ lin = lin.max(0.0).powf(1.7 / gamma.max(0.01));
+ let k = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99);
+ if k < 1.0 && lin > k {
+ let w = 1.0 - k;
+ lin = k + w * (1.0 - (-(lin - k) / w).exp());
+ }
+ lin
+ }
+ fn lin_to_log(v: f32, gamma: f32, rolloff: f32, black: f32, white: f32) -> f32 {
+ let off = 10f32.powf((black - white) * 0.002 / 0.6);
+ let mut lin = v;
+ let k = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99);
+ if k < 1.0 && lin > k {
+ let w = 1.0 - k;
+ lin = k - w * (1.0 - (lin - k) / w).max(1e-6).ln();
+ }
+ lin = lin.max(0.0).powf(gamma.max(0.01) / 1.7);
+ (white + (lin * (1.0 - off) + off).max(1e-9).log10() * 0.6 / 0.002) / 1023.0
+ }
+ let codes = [0.0f32, 95.0, 250.0, 400.0, 560.0, 685.0, 800.0, 1023.0];
+ for gamma in [None, Some(2.4)] {
+ for kind in 0..3 {
+ let mut img = Image::new(codes.len() as u32, 1);
+ for (x, code) in codes.iter().enumerate() {
+ let v = code / 1023.0;
+ img.set(x as u32, 0, [v, v, v, 1.0]);
+ }
+ let mut vals = vec![("conversionType", Value::Enum(kind))];
+ if let Some(g) = gamma {
+ vals.push(("gamma", num(g)));
+ }
+ let out = run("ec.utility.cineon", &vals, img);
+ let g = gamma.unwrap_or(1.7) as f32;
+ for (x, code) in codes.iter().enumerate() {
+ let v = code / 1023.0;
+ let want = match kind {
+ 0 => lin_to_log(v, g, 20.0, 95.0, 685.0),
+ 1 => log_to_lin(v, g, 20.0, 95.0, 685.0),
+ _ => lin_to_log(log_to_lin(v, g, 20.0, 95.0, 685.0), 1.7, 20.0, 95.0, 685.0),
+ };
+ let got = out.img.data[x][0];
+ assert!((want - got).abs() < 1e-6, "gamma {g}, type {kind}, code {code}: {want} vs {got}");
+ }
+ }
+ }
+ }
+
#[test]
fn hdr_compression_behaviour() {
let mut img = Image::new(2, 1);
diff --git a/crates/effects/src/warp_stab.rs b/crates/effects/src/warp_stab.rs
index f850bdcf3..8d9fe6f0e 100644
--- a/crates/effects/src/warp_stab.rs
+++ b/crates/effects/src/warp_stab.rs
@@ -152,15 +152,6 @@ pub fn settings(params: &Params, fps: f64) -> StabSettings {
}
}
-fn fnv(bytes: &[u8]) -> u64 {
- let mut h: u64 = 0xcbf2_9ce4_8422_2325;
- for b in bytes {
- h ^= *b as u64;
- h = h.wrapping_mul(0x0100_0000_01b3);
- }
- h
-}
-
struct KeyHasher(u64);
impl Hasher for KeyHasher {
fn finish(&self) -> u64 {
@@ -183,24 +174,12 @@ fn settings_key(s: &StabSettings) -> u64 {
h.finish()
}
-type AnalysisCache = Mutex>>;
type PlanCache = Mutex>>;
-/// Parsed analyses by content hash (parsing a long clip's JSON every frame would dominate).
+/// Parsed analyses and their content keys; cache hits compare source text without rehashing.
fn analysis_cached(json: &str) -> Option<(u64, Arc)> {
- static C: OnceLock = OnceLock::new();
- let key = fnv(json.as_bytes());
- let c = C.get_or_init(Default::default);
- if let Some(a) = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
- return Some((key, a.clone()));
- }
- let a = Arc::new(WarpAnalysis::from_json(json)?);
- let mut m = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
- if m.len() > 8 {
- m.clear();
- }
- m.insert(key, a.clone());
- Some((key, a))
+ static C: crate::analysis_cache::AnalysisCache = crate::analysis_cache::AnalysisCache::new();
+ C.get(json, WarpAnalysis::from_json)
}
fn plans() -> &'static PlanCache {
@@ -223,6 +202,19 @@ fn plan_cached(akey: u64, a: &WarpAnalysis, s: &StabSettings) -> Arc {
p
}
+/// Validate and cache the default stabilization plan after analysis. Use the serialized
+/// analysis so the cached plan has exactly the same inputs as the first render, including
+/// any floating-point round trip through JSON. Other settings still get their own plan.
+pub fn prepare_default_plan(json: &str) -> bool {
+ let Some((key, a)) = analysis_cached(json) else { return false };
+ if a.is_empty() {
+ return false;
+ }
+ let fps = if a.frame_duration > 0.0 { 1.0 / a.frame_duration } else { 30.0 };
+ let p = plan_cached(key, &a, &StabSettings { fps, ..Default::default() });
+ p.warps.len() == a.frames.len() && p.warps.iter().all(|h| h.0.iter().flatten().all(|v| v.is_finite()))
+}
+
/// The stored analysis of an instance (`None` when not analysed).
pub fn analysis(params: &Params) -> Option> {
let j = s(params, ANALYSIS);
@@ -453,7 +445,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 +457,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);
@@ -534,6 +523,7 @@ fn dot(img: &mut Image, c: [f64; 2], r: f64, col: Px) {
mod tests {
use super::*;
use crate::EffectEnv;
+ use crate::analysis_cache::fnv;
use effectcraft_track::stabilize::FrameMotion;
fn analysis_json(n: usize, w: f64, h: f64, shift: impl Fn(usize) -> [f64; 2]) -> String {
@@ -604,6 +594,61 @@ mod tests {
assert_eq!(render(&ctx, Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 }).img.data, img.data);
}
+ #[test]
+ fn prepared_default_plan_preserves_first_render_and_is_reused() {
+ let json = analysis_json(25, 64.0, 48.0, |k| [((k * 7) % 5) as f64 - 2.0, k as f64 * 0.1]);
+ let mut a = WarpAnalysis::from_json(&json).unwrap();
+ a.start = 0.012345678901234567;
+ a.tracks = (0..24)
+ .map(|id| effectcraft_track::camtrack::Track2D {
+ id,
+ start: 0,
+ pts: (0..25).map(|k| [8.0 + (id % 6) as f32 * 8.0 + ((k * 7) % 5) as f32, 8.0 + (id / 6) as f32 * 8.0 + k as f32 * 0.1]).collect(),
+ })
+ .collect();
+ let json = a.to_json();
+ let ps = params(&json, 2, 0);
+ let ctx = EffectCtx { params: &ps, time: a.start, layer_size: a.size, seed: 1, adjustment: false, env: EffectEnv::default() };
+ let input = Buf { img: frame([30.0, 22.0]), offset: [0.0; 2], scale: 1.0 };
+ // Original flow: validate, discard the plan, then render from the stored analysis.
+ let _discarded = plan(&a, &StabSettings { fps: 25.0, ..Default::default() });
+ let before = render(&ctx, input.clone());
+ let key = (fnv(json.as_bytes()), settings_key(&settings(&ps, 25.0)));
+ plans().lock().unwrap().remove(&key);
+ assert!(prepare_default_plan(&json));
+ let prepared = plans().lock().unwrap().get(&key).unwrap().clone();
+ assert!(!prepared.meshes.is_empty(), "exercise the Subspace Warp plan");
+ let after = render(&ctx, input);
+ let (_, _, rendered) = plan_at(&ps, a.start).unwrap();
+ assert!(Arc::ptr_eq(&prepared, &rendered));
+ assert_eq!(before.offset, after.offset);
+ assert_eq!(before.scale.to_bits(), after.scale.to_bits());
+ for (a, b) in before.img.data.iter().flatten().zip(after.img.data.iter().flatten()) {
+ assert_eq!(a.to_bits(), b.to_bits());
+ }
+ assert!(!prepare_default_plan("invalid"));
+ assert!(!prepare_default_plan(&WarpAnalysis::default().to_json()));
+ }
+
+ #[test]
+ fn changing_analysis_changes_the_plan_key() {
+ let json = analysis_json(10, 64.0, 48.0, |k| [k as f64 * 0.1, 0.0]);
+ let mut ps = params(&json, 2, 0);
+ let before = summary_key(&ps).unwrap();
+ assert_eq!(before[0], fnv(json.as_bytes()));
+ let mut a = WarpAnalysis::from_json(&json).unwrap();
+ a.start = 1.0;
+ ps.values.insert(ANALYSIS.into(), Value::Str(a.to_json()));
+ let after = summary_key(&ps).unwrap();
+ assert_ne!(before[0], after[0]);
+ assert_eq!(before[1], after[1]);
+ assert_eq!(*analysis(&ps).unwrap(), a);
+ ps.values.insert(ANALYSIS.into(), Value::Str(json));
+ assert_eq!(summary_key(&ps).unwrap(), before);
+ ps.values.insert(ANALYSIS.into(), Value::Str(String::new()));
+ assert!(analysis(&ps).is_none());
+ }
+
#[test]
fn nested_param_lookup() {
let mut p = Params::default();
diff --git a/crates/effects/tests/it/sim_golden.rs b/crates/effects/tests/it/sim_golden.rs
index 2a3a7c724..388790901 100644
--- a/crates/effects/tests/it/sim_golden.rs
+++ b/crates/effects/tests/it/sim_golden.rs
@@ -433,26 +433,26 @@ const GOLDEN: &[(&str, u64)] = &[
("ec.sim.ccstarburst/1/32bpc/t1.5", 0x5c39f8b1bd0e9e43),
("ec.sim.ccstarburst/1/32bpc/t0.8", 0xdf20aa9dcf4b3e22),
("ec.sim.ccstarburst/1/32bpc/t2.2", 0x4fa441737bfcb64d),
- ("ec.sim.ccbubbles/0/8bpc/t0.5", 0xdcd187e75eeb2fae),
- ("ec.sim.ccbubbles/0/8bpc/t1.5", 0x835ca088747736ed),
- ("ec.sim.ccbubbles/0/8bpc/t0.8", 0x5cbb1a6fbb77b41d),
- ("ec.sim.ccbubbles/0/8bpc/t2.2", 0x3b168068f5bfa533),
- ("ec.sim.ccbubbles/0/32bpc/t0.5", 0x524b84d14edd77e2),
- ("ec.sim.ccbubbles/0/32bpc/t1.5", 0xfd7a17f0d40f2a27),
- ("ec.sim.ccbubbles/0/32bpc/t0.8", 0x70dd2b2b4ff0f7ce),
- ("ec.sim.ccbubbles/0/32bpc/t2.2", 0xae21dd5cc2c2c07c),
- ("ec.sim.ccbubbles/0/8bpc/half/t0.5", 0x931b4ca707604220),
- ("ec.sim.ccbubbles/0/8bpc/half/t1.5", 0x21c25d4555abebe9),
- ("ec.sim.ccbubbles/0/8bpc/half/t0.8", 0x7e05972683359632),
- ("ec.sim.ccbubbles/0/8bpc/half/t2.2", 0xadc4f7219dab4921),
- ("ec.sim.ccbubbles/1/8bpc/t0.5", 0x89d27a2fe2011067),
- ("ec.sim.ccbubbles/1/8bpc/t1.5", 0x2bbfee897bf5bf6e),
- ("ec.sim.ccbubbles/1/8bpc/t0.8", 0x26cc704e7b06fffc),
- ("ec.sim.ccbubbles/1/8bpc/t2.2", 0x959d331545d96479),
- ("ec.sim.ccbubbles/1/32bpc/t0.5", 0xaf486cebfcbf4641),
- ("ec.sim.ccbubbles/1/32bpc/t1.5", 0xe150e9fdb558036f),
- ("ec.sim.ccbubbles/1/32bpc/t0.8", 0x6395f3967cb24284),
- ("ec.sim.ccbubbles/1/32bpc/t2.2", 0x68a55894d86ffb9e),
+ ("ec.sim.ccbubbles/0/8bpc/t0.5", 0x6cb63df0c062fdf4),
+ ("ec.sim.ccbubbles/0/8bpc/t1.5", 0x5bb1350114234ac3),
+ ("ec.sim.ccbubbles/0/8bpc/t0.8", 0xaa824d49f12c4156),
+ ("ec.sim.ccbubbles/0/8bpc/t2.2", 0x3528577358c4fc90),
+ ("ec.sim.ccbubbles/0/32bpc/t0.5", 0xdcd8e71222c13a9a),
+ ("ec.sim.ccbubbles/0/32bpc/t1.5", 0xd09565e124d8f5db),
+ ("ec.sim.ccbubbles/0/32bpc/t0.8", 0x91242f8e1fd0ad84),
+ ("ec.sim.ccbubbles/0/32bpc/t2.2", 0x1f9782131e62149d),
+ ("ec.sim.ccbubbles/0/8bpc/half/t0.5", 0x020755e48bdf8f43),
+ ("ec.sim.ccbubbles/0/8bpc/half/t1.5", 0x4aac0f1332e6001e),
+ ("ec.sim.ccbubbles/0/8bpc/half/t0.8", 0xdc2ab5e04c4a95a2),
+ ("ec.sim.ccbubbles/0/8bpc/half/t2.2", 0x30f78adc5d1dd715),
+ ("ec.sim.ccbubbles/1/8bpc/t0.5", 0x2f5415c679ff0980),
+ ("ec.sim.ccbubbles/1/8bpc/t1.5", 0xfcfc354dcd22e6d0),
+ ("ec.sim.ccbubbles/1/8bpc/t0.8", 0xdf90fd57c217019c),
+ ("ec.sim.ccbubbles/1/8bpc/t2.2", 0xd365c88c12219802),
+ ("ec.sim.ccbubbles/1/32bpc/t0.5", 0x50f20497f40bb46d),
+ ("ec.sim.ccbubbles/1/32bpc/t1.5", 0x74760599ffba220a),
+ ("ec.sim.ccbubbles/1/32bpc/t0.8", 0x028de52900ed8618),
+ ("ec.sim.ccbubbles/1/32bpc/t2.2", 0xaf0e21d9a7f625fe),
("ec.sim.ccdrizzle/0/8bpc/t0.5", 0x2bdfbc17d92b65b5),
("ec.sim.ccdrizzle/0/8bpc/t1.5", 0x8c354c46f99e35d3),
("ec.sim.ccdrizzle/0/8bpc/t0.8", 0x891f331ba47ee557),
diff --git a/crates/engine/presets/behaviors.json b/crates/engine/presets/behaviors.json
new file mode 100644
index 000000000..71e87db52
--- /dev/null
+++ b/crates/engine/presets/behaviors.json
@@ -0,0 +1,248 @@
+{
+ "format": "effectcraft.animationPresets/1",
+ "notes": "Original EffectCraft behavior presets (MIT OR Apache-2.0), written from scratch. Each one drives a transform property or opacity with a time-based expression built on the property's own value, so the motion runs for the layer's whole duration and still follows the property's keyframes. Apply to any layer; no effects are added.",
+ "presets": [
+ {
+ "id": "wiggleSoft",
+ "name": "Soft Wiggle",
+ "category": "Behaviors",
+ "description": "The layer wanders gently around its position.",
+ "layer": {
+ "expr": {
+ "transform/position": "var w = wiggle(1.2, 30); [w[0], w[1]]"
+ }
+ }
+ },
+ {
+ "id": "shake",
+ "name": "Shake",
+ "category": "Behaviors",
+ "description": "A fast, tight shake of the layer's position.",
+ "layer": {
+ "expr": {
+ "transform/position": "var w = wiggle(14, 12); [w[0], w[1]]"
+ }
+ }
+ },
+ {
+ "id": "floatUpDown",
+ "name": "Float",
+ "category": "Behaviors",
+ "description": "The layer bobs up and down like it floats on water.",
+ "layer": {
+ "expr": {
+ "transform/position": "[value[0]+(0), value[1]+(Math.sin(time*2)*18)]"
+ }
+ }
+ },
+ {
+ "id": "sway",
+ "name": "Sway",
+ "category": "Behaviors",
+ "description": "The layer sways left and right.",
+ "layer": {
+ "expr": {
+ "transform/position": "[value[0]+(Math.sin(time*1.6)*40), value[1]+(0)]"
+ }
+ }
+ },
+ {
+ "id": "orbit",
+ "name": "Orbit",
+ "category": "Behaviors",
+ "description": "The layer travels round a circle around its position.",
+ "layer": {
+ "expr": {
+ "transform/position": "[value[0]+(Math.cos(time*2)*80), value[1]+(Math.sin(time*2)*80)]"
+ }
+ }
+ },
+ {
+ "id": "figureEight",
+ "name": "Figure Eight",
+ "category": "Behaviors",
+ "description": "The layer traces a figure eight around its position.",
+ "layer": {
+ "expr": {
+ "transform/position": "[value[0]+(Math.sin(time*1.5)*100), value[1]+(Math.sin(time*3)*45)]"
+ }
+ }
+ },
+ {
+ "id": "bouncingBall",
+ "name": "Bouncing Ball",
+ "category": "Behaviors",
+ "description": "The layer bounces up from its position again and again.",
+ "layer": {
+ "expr": {
+ "transform/position": "[value[0]+(0), value[1]+(-Math.abs(Math.sin(time*3.2))*140)]"
+ }
+ }
+ },
+ {
+ "id": "driftRight",
+ "name": "Drift Right",
+ "category": "Behaviors",
+ "description": "The layer drifts steadily to the right.",
+ "layer": {
+ "expr": {
+ "transform/position": "[value[0]+(time*40), value[1]+(0)]"
+ }
+ }
+ },
+ {
+ "id": "driftDiagonal",
+ "name": "Drift Diagonal",
+ "category": "Behaviors",
+ "description": "The layer drifts slowly down and to the right.",
+ "layer": {
+ "expr": {
+ "transform/position": "[value[0]+(time*30), value[1]+(time*18)]"
+ }
+ }
+ },
+ {
+ "id": "pendulum",
+ "name": "Pendulum",
+ "category": "Behaviors",
+ "description": "The layer swings like a pendulum.",
+ "layer": {
+ "expr": {
+ "transform/rotation": "value + Math.sin(time*2.4)*25"
+ }
+ }
+ },
+ {
+ "id": "wobble",
+ "name": "Wobble",
+ "category": "Behaviors",
+ "description": "The layer rotates with an irregular wobble.",
+ "layer": {
+ "expr": {
+ "transform/rotation": "wiggle(2, 10)"
+ }
+ }
+ },
+ {
+ "id": "spinSlow",
+ "name": "Spin Slow",
+ "category": "Behaviors",
+ "description": "The layer rotates once every ten seconds.",
+ "layer": {
+ "expr": {
+ "transform/rotation": "value + time*36"
+ }
+ }
+ },
+ {
+ "id": "spinFast",
+ "name": "Spin Fast",
+ "category": "Behaviors",
+ "description": "The layer rotates twice a second.",
+ "layer": {
+ "expr": {
+ "transform/rotation": "value + time*720"
+ }
+ }
+ },
+ {
+ "id": "pulse",
+ "name": "Pulse",
+ "category": "Behaviors",
+ "description": "The layer swells and shrinks smoothly.",
+ "layer": {
+ "expr": {
+ "transform/scale": "[value[0]*(1+0.12*Math.sin(time*4)), value[1]*(1+0.12*Math.sin(time*4))]"
+ }
+ }
+ },
+ {
+ "id": "heartbeat",
+ "name": "Heartbeat",
+ "category": "Behaviors",
+ "description": "Two quick beats, then a rest, repeating every second.",
+ "layer": {
+ "expr": {
+ "transform/scale": "var t = time % 1; var b = t < 0.3 ? Math.sin(t/0.3*Math.PI) : (t < 0.6 ? 0.7*Math.sin((t-0.3)/0.3*Math.PI) : 0); var k = 1 + 0.2*b; [value[0]*k, value[1]*k]"
+ }
+ }
+ },
+ {
+ "id": "breathe",
+ "name": "Breathe",
+ "category": "Behaviors",
+ "description": "The layer slowly grows and settles back.",
+ "layer": {
+ "expr": {
+ "transform/scale": "[value[0]*(1+0.06*Math.sin(time*1.1)), value[1]*(1+0.06*Math.sin(time*1.1))]"
+ }
+ }
+ },
+ {
+ "id": "rubberBand",
+ "name": "Rubber Band",
+ "category": "Behaviors",
+ "description": "Width and height stretch against each other.",
+ "layer": {
+ "expr": {
+ "transform/scale": "[value[0]*(1+0.15*Math.sin(time*5)), value[1]*(1-0.15*Math.sin(time*5))]"
+ }
+ }
+ },
+ {
+ "id": "jitterScale",
+ "name": "Jitter Scale",
+ "category": "Behaviors",
+ "description": "The layer's size jitters randomly.",
+ "layer": {
+ "expr": {
+ "transform/scale": "var w = wiggle(8, 6); [w[0], w[1]]"
+ }
+ }
+ },
+ {
+ "id": "blink",
+ "name": "Blink",
+ "category": "Behaviors",
+ "description": "The layer switches on and off twice a second.",
+ "layer": {
+ "expr": {
+ "transform/opacity": "(Math.floor(time*4) % 2 === 0) ? value : 0"
+ }
+ }
+ },
+ {
+ "id": "strobe",
+ "name": "Strobe",
+ "category": "Behaviors",
+ "description": "The layer flashes on for a few frames each beat.",
+ "layer": {
+ "expr": {
+ "transform/opacity": "((time*6) % 1 < 0.25) ? value : 0"
+ }
+ }
+ },
+ {
+ "id": "flicker",
+ "name": "Flicker",
+ "category": "Behaviors",
+ "description": "The layer's opacity flickers like a failing lamp.",
+ "layer": {
+ "expr": {
+ "transform/opacity": "var w = wiggle(14, 45); Math.max(0, Math.min(100, w))"
+ }
+ }
+ },
+ {
+ "id": "fadePulse",
+ "name": "Fade Pulse",
+ "category": "Behaviors",
+ "description": "Opacity breathes between full and a third.",
+ "layer": {
+ "expr": {
+ "transform/opacity": "value*(0.67+0.33*Math.sin(time*2.5))"
+ }
+ }
+ }
+ ]
+}
diff --git a/crates/engine/presets/behaviors.json.attribution b/crates/engine/presets/behaviors.json.attribution
new file mode 100644
index 000000000..7c71c62e8
--- /dev/null
+++ b/crates/engine/presets/behaviors.json.attribution
@@ -0,0 +1,7 @@
+asset: behaviors.json
+title: EffectCraft Behaviors animation presets (Soft Wiggle, Shake, Float, Sway, Orbit, Figure Eight, Bouncing Ball, Drift Right, Drift Diagonal, Pendulum, Wobble, Spin Slow, Spin Fast, Pulse, Heartbeat, Breathe, Rubber Band, Jitter Scale, Blink, Strobe, Flicker, Fade Pulse)
+author: EffectCraft contributors
+source: original work
+license: MIT OR Apache-2.0
+added: 2026-10-15 by vangel76 (Claude Code agent, gap 7, #1510)
+notes: Expressions and values written from scratch for EffectCraft on its own transform properties and expression engine; not derived from any Adobe preset (.ffx) or its values.
diff --git a/crates/engine/presets/text_animators.json b/crates/engine/presets/text_animators.json
index 20e142268..e82707a52 100644
--- a/crates/engine/presets/text_animators.json
+++ b/crates/engine/presets/text_animators.json
@@ -77,7 +77,7 @@
"name": "Tracking In",
"properties": { "tracking": 0, "opacity": 100 },
"keys": {
- "tracking": [[0, 400], [1.6, 0]],
+ "tracking": [[0, 40], [1.6, 0]],
"opacity": [[0, 0], [0.8, 100]]
},
"ease": true,
diff --git a/crates/engine/src/analysis/roto.rs b/crates/engine/src/analysis/roto.rs
index d9ce6e5af..813561969 100644
--- a/crates/engine/src/analysis/roto.rs
+++ b/crates/engine/src/analysis/roto.rs
@@ -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, &[]));
diff --git a/crates/engine/src/analysis/tracking.rs b/crates/engine/src/analysis/tracking.rs
index 434cdd8af..221b28602 100644
--- a/crates/engine/src/analysis/tracking.rs
+++ b/crates/engine/src/analysis/tracking.rs
@@ -197,7 +197,8 @@ pub(crate) struct Work {
}
/// The layer's source frame at comp time `t` at 100 % (layer pixels + offset = image pixels).
-/// Footage at its native size is used straight from the footage source (no copy).
+/// Footage at its native size is used straight from the footage source (no copy unless its
+/// Cineon Settings convert it).
pub(crate) fn source_frame(
r: &Renderer,
project: &Project,
@@ -207,7 +208,7 @@ pub(crate) fn source_frame(
t: Tick,
expr: Option<&dyn ExprHost>,
) -> Option<(Arc, [f64; 2])> {
- let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None };
+ let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None, property_overrides: &[], media_overrides: &[] };
if let effectcraft_project::LayerSource::Footage { item } = &layer.source
&& let Some(effectcraft_project::ItemKind::Footage(f)) = project.item(*item).map(|i| &i.kind)
&& f.has_video
@@ -215,7 +216,7 @@ pub(crate) fn source_frame(
&& img.width == f.width
&& img.height == f.height
{
- return Some((img, [0.0; 2]));
+ return Some((effectcraft_render::cineon_frame(img, f), [0.0; 2]));
}
let buf = r.layer_source(&ctx, layer)?;
if (buf.scale - 1.0).abs() > 1e-9 && buf.scale > 0.0 {
diff --git a/crates/engine/src/analysis/warp.rs b/crates/engine/src/analysis/warp.rs
index 5472d4e8c..71ee45ee6 100644
--- a/crates/engine/src/analysis/warp.rs
+++ b/crates/engine/src/analysis/warp.rs
@@ -4,7 +4,7 @@
//! the Warp Stabilizer, `Renderer::layer_input`) for every frame between the layer's In and Out
//! points on a background thread, and feeds the frames to `effectcraft_track::stabilize`'s
//! analyzer (step 1 of 2, "Analyzing in background"); step 2 ("Stabilizing") derives the
-//! stabilization plan once to validate the result. When the job finishes, the analysis is
+//! stabilization plan once to validate and cache the result. When the job finishes, the analysis is
//! written into the effect's hidden Analysis parameter as one undo step, together with a key of
//! what it was made from (the layer's source, In/Out, start, stretch and Time Remap). Edits that
//! change those clear the analysis (`invalidate`, run after every edit) and queue the effect
@@ -21,7 +21,7 @@ use effectcraft_project::{GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSour
use effectcraft_raster::Image;
use effectcraft_render::{EvalCtx, ExprHost, FootageSource, LayerCache, Renderer};
use effectcraft_time::Tick;
-use effectcraft_track::stabilize::{AnalyzeOpts, Analyzer, StabSettings, WarpAnalysis};
+use effectcraft_track::stabilize::{AnalyzeOpts, Analyzer, WarpAnalysis};
use serde::Serialize;
use crate::tracking::lock;
@@ -245,9 +245,8 @@ async fn run_work(w: WarpWork, shared: &WarpShared) {
let lt0 = layer.layer_time(w.times[0]).seconds();
let fd = if w.times.len() > 1 { layer.layer_time(w.times[1]).seconds() - lt0 } else { comp.frame_duration().seconds() };
let a = an.finish(lt0, fd);
- // Step 2: derive a plan once (validates the motion and warms nothing else up).
- let plan = effectcraft_track::stabilize::plan(&a, &StabSettings { fps: if fd > 0.0 { 1.0 / fd } else { 30.0 }, ..Default::default() });
- if plan.warps.len() != a.frames.len() || plan.warps.iter().any(|h| h.0.iter().flatten().any(|v| !v.is_finite())) {
+ // Plan from the stored JSON, exactly as rendering does, and retain it for the first render.
+ if !warp_stab::prepare_default_plan(&a.to_json()) {
return fail("the stabilization could not be computed");
}
*lock(&shared.result) = Some(a);
diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs
index 47b3cbde9..bd089b52b 100644
--- a/crates/engine/src/cmd.rs
+++ b/crates/engine/src/cmd.rs
@@ -10,6 +10,7 @@ mod animation;
pub(crate) mod app_more;
mod autotrace;
mod batch;
+mod blender;
mod camera_cmds;
mod comp;
pub(crate) mod comp_more;
@@ -17,6 +18,8 @@ pub mod content_fill;
mod create;
mod edit;
mod effect;
+pub use effect::CryptoProbeJob;
+pub(crate) use effect::{CryptoProbeSlot, crypto_probe_job, crypto_probe_ready, keep_crypto_probe, kept_crypto_probe};
pub mod essential;
pub mod expr_tools;
mod file;
@@ -156,7 +159,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;
diff --git a/crates/engine/src/cmd/align_data.rs b/crates/engine/src/cmd/align_data.rs
index 38d9e7841..99ed3d113 100644
--- a/crates/engine/src/cmd/align_data.rs
+++ b/crates/engine/src/cmd/align_data.rs
@@ -72,9 +72,9 @@ pub fn parse_stamp(v: &Value, fps: f64) -> Option {
}
// 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::().ok()?, s[5..7].parse::().ok()?, s[8..10].parse::().ok()?);
+ let (y, m, d) = (s.get(0..4)?.parse::().ok()?, s.get(5..7)?.parse::().ok()?, s.get(8..10)?.parse::().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 {
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();
diff --git a/crates/engine/src/cmd/anim.rs b/crates/engine/src/cmd/anim.rs
index 325845115..e711392e0 100644
--- a/crates/engine/src/cmd/anim.rs
+++ b/crates/engine/src/cmd/anim.rs
@@ -45,6 +45,7 @@ fn copy(s: &mut Session, _: &Value) -> Result {
clip.push(KeyClip { layer: lid, path, keys });
}
let n: usize = clip.iter().map(|c| c.keys.len()).sum();
+ s.state.keyframe_text = crate::keyframe_text::text(&s.project, comp, &s.state.selected_keys, &[]);
s.state.key_clipboard = clip;
s.state.clip_is_keys = true;
Ok(json!(n))
diff --git a/crates/engine/src/cmd/anim_presets.rs b/crates/engine/src/cmd/anim_presets.rs
index fc3f95143..88022a0e3 100644
--- a/crates/engine/src/cmd/anim_presets.rs
+++ b/crates/engine/src/cmd/anim_presets.rs
@@ -1,6 +1,7 @@
//! EffectCraft's own animation preset library (Effects & Presets ▸ * Animation Presets): the
//! Transitions category (original presets built on our Transition effects and layer opacity) and
-//! the Backgrounds category (generators, noise and colour effects driven by expressions).
+//! the Backgrounds category (generators, noise and colour effects driven by expressions) and the
+//! Behaviors category (transform and opacity driven by expressions, no effects).
//!
//! Each preset adds effects (with values and keyframes) and/or keys layer properties, starting
//! at the current time, on every selected layer. The library is data
@@ -26,13 +27,17 @@ pub const TRANSITIONS_JSON: &str = include_str!("../../presets/transitions.json"
/// The original background presets (`presets/backgrounds.json`).
pub const BACKGROUNDS_JSON: &str = include_str!("../../presets/backgrounds.json");
+/// The original behavior presets (`presets/behaviors.json`): expression-driven motion on a layer's
+/// transform and opacity, no effects.
+pub const BEHAVIORS_JSON: &str = include_str!("../../presets/behaviors.json");
+
/// Keys per animated property, so a hostile inline preset can't allocate without bound.
const MAX_KEYS: usize = 256;
/// Longest expression text an inline preset may carry.
const MAX_EXPR: usize = 4096;
fn presets() -> Vec {
- [TRANSITIONS_JSON, BACKGROUNDS_JSON]
+ [TRANSITIONS_JSON, BACKGROUNDS_JSON, BEHAVIORS_JSON]
.iter()
.flat_map(|src| serde_json::from_str::(src).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default())
.collect()
diff --git a/crates/engine/src/cmd/animation.rs b/crates/engine/src/cmd/animation.rs
index 1bdf3fed7..3f9c195c2 100644
--- a/crates/engine/src/cmd/animation.rs
+++ b/crates/engine/src/cmd/animation.rs
@@ -10,11 +10,16 @@ use effectcraft_project::{GroupKind, Layer, LayerId, LayerSource, Node, Project,
use effectcraft_time::Tick;
use serde_json::{Value, json};
+use super::effect::{effect_time_error, retime_effect};
use super::{CommandSpec, bad, frontend, has_comp, has_keys, has_layers, layer_mut, layers_p, match_path_of, resolve_effect_path, selected_leaf_props, str_p};
use crate::{EngineError, KeyRef, Result, Session, cmd};
/// Preset file format version tag (`*.ecpreset`, JSON).
pub const PRESET_TAG: &str = "effectcraftAnimationPreset";
+/// The preset format version that `anim.savePreset` writes.
+pub const PRESET_VERSION: u64 = 2;
+/// The first preset version that stores effect keys relative to the earliest key (#1971).
+const RELATIVE_EFFECT_KEYS: u64 = 2;
fn has_selected_props(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?;
@@ -45,13 +50,26 @@ fn selected_effects(s: &Session) -> Vec<(LayerId, PropGroup)> {
.collect()
}
+/// The earliest key anywhere in `groups`, nested controls included.
+fn earliest_key<'a>(groups: impl IntoIterator- ) -> Option
{
+ let mut first: Option = None;
+ for g in groups {
+ g.walk("", &mut |_, pr| {
+ if let Some(t) = pr.keys.iter().map(|k| k.time).min() {
+ first = Some(first.map_or(t, |f| f.min(t)));
+ }
+ });
+ }
+ first
+}
+
// ---------------------------------------------------------------- presets
fn save_preset(s: &mut Session, p: &Value) -> Result {
let path = str_p(p, "path").ok_or_else(|| bad("anim.savePreset", "missing `path`"))?.to_string();
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
let t = s.time();
- let effects: Vec<(LayerId, PropGroup)> = selected_effects(s);
+ let mut effects: Vec<(LayerId, PropGroup)> = selected_effects(s);
let effect_uids: Vec = effects.iter().map(|(_, g)| g.uid).collect();
let mut props = vec![];
for (lid, uid) in selected_leaf_props(s) {
@@ -67,7 +85,11 @@ fn save_preset(s: &mut Session, p: &Value) -> Result {
return Err(bad("anim.savePreset", "select properties or effects to save"));
}
// Key times are stored relative to the earliest key so the preset applies at the CTI.
- let first = props.iter().flat_map(|(_, _, pr)| pr.keys.iter().map(|k| k.time)).min().unwrap_or(Tick::ZERO);
+ let first =
+ props.iter().flat_map(|(_, _, pr)| pr.keys.iter().map(|k| k.time)).chain(earliest_key(effects.iter().map(|(_, g)| g))).min().unwrap_or(Tick::ZERO);
+ for (_, g) in &mut effects {
+ retime_effect(g, 1.0, "anim.savePreset", |t| Ok(Tick(t.0.checked_sub(first.0).ok_or_else(|| effect_time_error("anim.savePreset"))?)))?;
+ }
let props_json: Vec = props
.iter()
.map(|(_, mp, pr)| {
@@ -82,12 +104,27 @@ fn save_preset(s: &mut Session, p: &Value) -> Result {
let name = str_p(p, "name")
.map(str::to_string)
.unwrap_or_else(|| std::path::Path::new(&path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default());
- let doc = json!({PRESET_TAG: 1, "name": name, "properties": props_json, "effects": fx_json});
+ let doc = json!({PRESET_TAG: PRESET_VERSION, "name": name, "properties": props_json, "effects": fx_json});
let text = serde_json::to_string_pretty(&doc).unwrap_or_default();
s.services.write_file(&path, text.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
Ok(json!({"path": path, "properties": props.len(), "effects": effects.len()}))
}
+/// Rewrite an effect property path in a preset (`effects/[#n]/ `) to the current
+/// parameter id when it names a parameter that the effect has since renamed or moved into a
+/// twirl-down group. Any other path comes back unchanged.
+fn upgrade_effect_path(path: &str) -> String {
+ let Some((seg, tail)) = path.strip_prefix("effects/").and_then(|r| r.split_once('/')) else { return path.to_string() };
+ let effect = match seg.rsplit_once('#') {
+ Some((m, n)) if !m.is_empty() && n.parse::().is_ok() => m,
+ _ => seg,
+ };
+ match effectcraft_effects::find(effect).and_then(|spec| effectcraft_effects::migrate::current_param_id(spec, tail)) {
+ Some(id) if id != tail => format!("effects/{seg}/{id}"),
+ _ => path.to_string(),
+ }
+}
+
fn apply_preset(s: &mut Session, p: &Value) -> Result {
let doc: Value = match (p.get("preset"), str_p(p, "path")) {
(Some(v), _) => v.clone(),
@@ -110,14 +147,22 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result {
s.save_prefs();
s.prefs_revision += 1;
}
- let effects: Vec =
+ let mut effects: Vec =
doc["effects"].as_array().map(|a| a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect()).unwrap_or_default();
+ // Older presets stored effect keys at the source layer's times.
+ if doc[PRESET_TAG].as_u64().unwrap_or(1) < RELATIVE_EFFECT_KEYS
+ && let Some(first) = earliest_key(&effects)
+ {
+ for g in &mut effects {
+ retime_effect(g, 1.0, "anim.applyPreset", |t| Ok(Tick(t.0.checked_sub(first.0).ok_or_else(|| effect_time_error("anim.applyPreset"))?)))?;
+ }
+ }
let props: Vec<(String, Property)> = doc["properties"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| {
- let path = v["path"].as_str()?.to_string();
+ let path = upgrade_effect_path(v["path"].as_str()?);
let value: KV = serde_json::from_value(v["value"].clone()).ok()?;
let keys: Vec = serde_json::from_value(v["keys"].clone()).unwrap_or_default();
let expr = serde_json::from_value(v["expression"].clone()).ok().flatten();
@@ -130,16 +175,30 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result {
})
.unwrap_or_default();
let t = s.time();
+ // Effect bounds per layer size the point defaults of parameters added since the preset was saved.
+ let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
+ let sizes: BTreeMap =
+ ids.iter().filter_map(|lid| comp.layer(*lid).map(|l| (*lid, effectcraft_render::effect_bounds(&s.project, comp, l).0))).collect();
let mut applied = 0;
let mut skipped = vec![];
s.edit("Apply Animation Preset", None, |proj, _| {
let mut next = proj.next_id;
for lid in &ids {
+ let size = sizes.get(lid).copied().unwrap_or([100.0, 100.0]);
let l = layer_mut(proj, cid, *lid)?;
let lt = l.layer_time(t);
if let Some(fx) = l.props.sub_mut("effects") {
for g in &effects {
let mut g = g.clone();
+ // Effects saved by an earlier version get the upgrade a project gets on open.
+ let spec = match &g.kind {
+ GroupKind::Effect { effect } => effectcraft_effects::find(effect),
+ _ => None,
+ };
+ if let Some(spec) = spec {
+ effectcraft_effects::migrate::upgrade_instance(spec, &mut g, &mut Ids(&mut next), size);
+ }
+ retime_effect(&mut g, 1.0, "anim.applyPreset", |t| Ok(Tick(t.0.checked_add(lt.0).ok_or_else(|| effect_time_error("anim.applyPreset"))?)))?;
g.reassign_uids(&mut next);
next += 1;
fx.children.push(Node::Group(g));
diff --git a/crates/engine/src/cmd/app_more.rs b/crates/engine/src/cmd/app_more.rs
index f607c8288..29597a3bc 100644
--- a/crates/engine/src/cmd/app_more.rs
+++ b/crates/engine/src/cmd/app_more.rs
@@ -152,7 +152,7 @@ fn clear_recent_presets(s: &mut Session, _: &Value) -> Result {
fn save_copy_xml(s: &mut Session, p: &Value) -> Result {
let path = str_p(p, "path").ok_or_else(|| bad("file.saveCopyAsXml", "missing `path` (.ecprojx)"))?;
- let xml = crate::xml_project::to_xml(&s.project.to_file_json_with_view(&s.saved_view())?).map_err(EngineError::Other)?;
+ let xml = crate::xml_project::to_xml(&s.project.to_file_json_with_view(&s.saved_view(), false)?).map_err(EngineError::Other)?;
s.services.write_file(path, xml.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
s.toast(format!("Saved an XML copy to {path}"));
Ok(json!({"path": path, "bytes": xml.len()}))
@@ -240,7 +240,7 @@ fn replace_with_precomp(s: &mut Session, p: &Value) -> Result {
let ids: Vec = proj.items.keys().copied().filter(|c| *c != comp).collect();
for cid in ids {
if let Some(c) = proj.comp_mut(cid) {
- for l in c.layers.iter_mut().filter(|l| is_user(l)) {
+ for l in c.layers.iter_mut_where(|l| is_user(l)) {
l.source = LayerSource::Comp { item: comp };
n += 1;
}
diff --git a/crates/engine/src/cmd/batch.rs b/crates/engine/src/cmd/batch.rs
index f6e6901cc..5931fe09d 100644
--- a/crates/engine/src/cmd/batch.rs
+++ b/crates/engine/src/cmd/batch.rs
@@ -9,8 +9,9 @@ use serde_json::{Value, json};
use super::{CommandSpec, always, bad, str_p};
use crate::{EngineError, Result, Session};
-/// Commands that can't run inside a batch (they replace the project or nest batches).
-const NOT_IN_BATCH: &[&str] = &["engine.batch", "file.runScript", "script.run"];
+/// Commands that can't run inside a batch (they nest batches), besides the commands that run
+/// scripts ([`super::scripts::RUNS_SCRIPTS`]).
+const NOT_IN_BATCH: &[&str] = &["engine.batch"];
/// Resolve a `$N` / `$N.key.0.key` reference against the results so far.
fn reference(s: &str, results: &[Value]) -> Option> {
@@ -78,7 +79,7 @@ fn batch(s: &mut Session, p: &Value) -> Result {
.or_else(|| step.get("id"))
.and_then(Value::as_str)
.ok_or_else(|| bad("engine.batch", "each step needs `command` (a command id)"))?;
- if NOT_IN_BATCH.contains(&id) {
+ if NOT_IN_BATCH.contains(&id) || super::scripts::RUNS_SCRIPTS.contains(&id) {
return Err(bad("engine.batch", format!("`{id}` can't run inside a batch")));
}
let params = substitute(step.get("params").unwrap_or(&json!({})), results)?;
diff --git a/crates/engine/src/cmd/blender.rs b/crates/engine/src/cmd/blender.rs
new file mode 100644
index 000000000..e6c678101
--- /dev/null
+++ b/crates/engine/src/cmd/blender.rs
@@ -0,0 +1,295 @@
+//! Camera-solve interchange through Blender's public Python API. The generated script is
+//! original MIT OR Apache-2.0 code; no Blender implementation is read or embedded.
+
+use std::path::Path;
+
+use effectcraft_effects::camera_tracker as ct;
+use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Uid};
+use effectcraft_render::EvalCtx;
+use effectcraft_time::Tick;
+use effectcraft_track::camtrack::{CameraSolve, linalg};
+use serde_json::{Value, json};
+
+mod validate;
+
+use super::{b_p, bad, f_p, str_p};
+use crate::{EngineError, Result, Session};
+
+const CMD: &str = "camera.exportBlender";
+const MAX_FRAMES: usize = 100_000;
+const MAX_POINTS: usize = 100_000;
+const MAX_JSON: usize = 64 * 1024 * 1024;
+const MAX_FRAME: i64 = 1_048_574;
+
+fn number(p: &Value, key: &str, default: f64, min: f64, max: f64) -> Result {
+ let v = match p.get(key) {
+ None => default,
+ Some(_) => f_p(p, key).ok_or_else(|| bad(CMD, format!("{key}: expected a number")))?,
+ };
+ if !v.is_finite() || !(min..=max).contains(&v) {
+ return Err(bad(CMD, format!("{key}: expected {min}..{max}")));
+ }
+ Ok(v)
+}
+
+fn finite(values: impl IntoIterator- ) -> Result<()> {
+ if values.into_iter().any(|v| !v.is_finite() || v.abs() > 1e12) {
+ return Err(bad(CMD, "invalid or excessive numeric camera/scene data; re-analyze the tracker"));
+ }
+ Ok(())
+}
+
+/// Right-handed EffectCraft world (right, down, forward) → Blender (right, forward, up).
+fn position(p: [f64; 3], scale: f64) -> [f64; 3] {
+ [p[0] * scale, p[2] * scale, -p[1] * scale]
+}
+
+fn media_path(s: &Session, path: &str) -> Result
{
+ if path.is_empty() || path.contains('\0') {
+ return Err(bad(CMD, "the source footage has no valid file path; relink it before exporting"));
+ }
+ let path = Path::new(path);
+ if path.is_absolute() || cfg!(target_arch = "wasm32") {
+ return Ok(path.to_string_lossy().into_owned());
+ }
+ let cwd = std::env::current_dir().map_err(|e| bad(CMD, format!("cannot resolve footage path: {e}")))?;
+ let base = s.path.as_deref().and_then(|p| Path::new(p).parent()).map(|p| cwd.join(p)).unwrap_or(cwd);
+ Ok(base.join(path).to_string_lossy().into_owned())
+}
+
+pub(super) fn export(s: &mut Session, p: &Value, cid: ItemId, lid: LayerId, uid: Uid) -> Result {
+ if s.running_for_script() && !s.prefs.scripting.allow_scripts_write_files {
+ return Err(bad(CMD, "scripts can't export files unless Settings ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network is on"));
+ }
+ let options = Options::parse(p)?;
+ let Options { path, sensor, units, start, include_points } = options;
+ if s.camera_target().is_some_and(|(c, l, u)| (c, l, u) == (cid, lid, uid)) && s.is_camera_analyzing() || s.camera_pending.contains(&(cid, lid, uid)) {
+ return Err(bad(CMD, "wait for this tracker's analysis to finish before exporting"));
+ }
+ let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
+ let layer = comp.layer(lid).ok_or_else(|| bad(CMD, "tracked layer missing"))?;
+ let g = layer.props.find_group(uid).ok_or_else(|| bad(CMD, "tracker missing"))?;
+ for name in [ct::SOLVE, ct::TRACKS] {
+ if g.prop(name).is_some_and(|p| matches!(&p.value, effectcraft_keyframe::Value::Str(v) if v.len() > MAX_JSON)) {
+ return Err(bad(CMD, "tracking data exceeds the 64 MiB export limit"));
+ }
+ }
+ let params = crate::camera_track::static_params(g);
+ let solve = ct::solve(¶ms).ok_or_else(|| bad(CMD, "the camera is not solved yet; analyze the tracker first"))?;
+ let (lo, end) = validate::timing(comp, layer, &solve, start)?;
+ let Source { footage, path: source_path, fps: source_fps, count: source_count } = source(s, layer, &solve, path)?;
+ validate::geometry(&solve, sensor)?;
+ let camera = params
+ .get(ct::CAMERA)
+ .map(|v| v.as_f64())
+ .filter(|v| v.is_finite() && *v > 0.0 && *v <= u64::MAX as f64)
+ .and_then(|id| comp.layer(LayerId(id as u64)))
+ .filter(|l| l.is_camera());
+ let warnings = scene_warnings(camera, &solve, footage);
+ let ctx = EvalCtx { expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, lo) };
+ let (frames, world) = bake_frames(&ctx, layer, &solve, camera, &options, footage, source_count)?;
+ let mut points = Vec::new();
+ for point in &solve.points {
+ finite(point.pos.into_iter().chain([point.error as f64]))?;
+ if point.first > point.last || point.last as usize >= solve.frames.len() {
+ return Err(bad(CMD, "invalid solved point frame range; re-analyze the tracker"));
+ }
+ let location = position(world.apply(point.pos), units);
+ finite(location)?;
+ if include_points {
+ points.push(json!({"id": point.id, "location": location, "error": point.error,
+ "first": start.saturating_add(point.first as i64), "last": start.saturating_add(point.last as i64)}));
+ }
+ }
+ let tracks = ct::tracks(¶ms);
+ if tracks.as_ref().is_some_and(|t| t.tracks.len() > MAX_POINTS || t.tracks.iter().any(|t| t.pts.len() > MAX_FRAMES)) {
+ return Err(bad(CMD, "2D tracks exceed export limits"));
+ }
+ let markers: Vec = comp
+ .markers
+ .iter()
+ .filter_map(|m| {
+ let frame = start.saturating_add(comp.frame_rate.frame_at(m.time).saturating_sub(comp.frame_rate.frame_at(lo)));
+ if (start..=end).contains(&frame) { Some(json!({"name": m.comment, "frame": frame})) } else { None }
+ })
+ .collect();
+ let payload = json!({"version": 1, "name": format!("{} — {}", s.project.item(cid).map(|i| i.name.as_str()).unwrap_or("Shot"), layer.name),
+ "resolution": solve.size, "fpsNumerator": comp.frame_rate.num, "fpsDenominator": comp.frame_rate.den,
+ "startFrame": start, "endFrame": end, "sensorWidth": sensor, "unitScale": units,
+ "media": {"path": source_path, "kind": if footage.kind == FootageKind::Still {"FILE"} else {"MOVIE"},
+ "fps": source_fps, "nativeRate": footage.native_rate, "pixelAspect": footage.pixel_aspect, "timecode": footage.start_timecode,
+ "frameCount": source_count, "loopCount": footage.loop_count},
+ "frames": frames, "points": points, "markers": markers, "ground": solve.ground.is_some(),
+ "groundSize": world.s * units, "shutter": comp.shutter_angle / 360.0, "shutterPhase": comp.shutter_phase,
+ "solve": solve, "tracks": if include_points { tracks.as_deref() } else { None },
+ "warnings": warnings});
+ let data = serde_json::to_string(&payload).map_err(|e| bad(CMD, format!("cannot serialize scene: {e}")))?;
+ if data.len() > MAX_JSON {
+ return Err(bad(CMD, "scene exceeds the 64 MiB script limit; disable includePoints or split the shot"));
+ }
+ let quoted = serde_json::to_string(&data).map_err(|e| bad(CMD, format!("cannot encode scene: {e}")))?;
+ let script = include_str!("blender_scene.py").replace("__EFFECTCRAFT_DATA__", "ed);
+ s.services.write_file(path, script.as_bytes()).map_err(|e| bad(CMD, format!("cannot write {path}: {e}")))?;
+ Ok(json!({"path": path, "bytes": script.len(), "frames": frames.len(), "points": points.len(), "warnings": warnings}))
+}
+
+struct Options<'a> {
+ path: &'a str,
+ sensor: f64,
+ units: f64,
+ start: i64,
+ include_points: bool,
+}
+
+impl<'a> Options<'a> {
+ fn parse(p: &'a Value) -> Result {
+ let path = str_p(p, "path").filter(|p| !p.is_empty() && !p.contains('\0')).ok_or_else(|| bad(CMD, "missing `path` (.py)"))?;
+ if !Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("py")) {
+ return Err(bad(CMD, "the export path must end in .py"));
+ }
+ let sensor = number(p, "sensorWidth", 36.0, 1.0, 1000.0)?;
+ let units = number(p, "unitScale", 0.01, 0.000_001, 1000.0)?;
+ let start = match p.get("startFrame") {
+ None => 1,
+ Some(v) => v.as_i64().filter(|n| (1..=MAX_FRAME).contains(n)).ok_or_else(|| bad(CMD, "startFrame: expected a positive Blender frame number"))?,
+ };
+ let include_points = match p.get("includePoints") {
+ Some(v) if !v.is_boolean() => return Err(bad(CMD, "includePoints: expected a boolean")),
+ _ => b_p(p, "includePoints").unwrap_or(true),
+ };
+ Ok(Self { path, sensor, units, start, include_points })
+ }
+}
+
+struct Source<'a> {
+ footage: &'a Footage,
+ path: String,
+ fps: f64,
+ count: i64,
+}
+
+fn source<'a>(s: &'a Session, layer: &Layer, solve: &CameraSolve, path: &str) -> Result> {
+ let footage = match layer.source {
+ LayerSource::Footage { item } => s.project.item(item).and_then(|i| match &i.kind {
+ ItemKind::Footage(f) => Some(f),
+ _ => None,
+ }),
+ _ => None,
+ }
+ .filter(|f| f.has_video && matches!(f.kind, FootageKind::Video | FootageKind::Still));
+ let footage = footage
+ .ok_or_else(|| bad(CMD, "Blender background export needs a video or still-footage layer; render precomps/sequences to a video and track that video"))?;
+ let source_path = media_path(s, &footage.path)?;
+ if Path::new(path) == Path::new(&source_path) {
+ return Err(bad(CMD, "the export path must differ from the source footage"));
+ }
+ if footage.width as f64 != solve.size[0] || footage.height as f64 != solve.size[1] {
+ return Err(bad(CMD, "the analysis image differs from the original footage dimensions; render the tracker's input to a video and track that video"));
+ }
+ let source_fps = footage.frame_rate.as_f64();
+ if footage.frame_rate.num <= 0 || footage.frame_rate.den <= 0 || !source_fps.is_finite() || source_fps <= 0.0 {
+ return Err(bad(CMD, "invalid source footage frame rate"));
+ }
+ // Same frame selection as the media pool: interpreted FPS, rounded per-loop frame count,
+ // clamp to the total loop range, then wrap. Native FPS does not change the frame index.
+ let divisor = (effectcraft_time::TICKS_PER_SECOND as i128).saturating_mul(footage.frame_rate.den as i128);
+ let source_count = (footage.duration.0 as i128).saturating_mul(footage.frame_rate.num as i128).saturating_add(divisor / 2) / divisor;
+ let source_count = if footage.kind == FootageKind::Still { 1 } else { source_count };
+ if !(1..=MAX_FRAME as i128).contains(&source_count) {
+ return Err(bad(CMD, "invalid footage duration or footage exceeds Blender's frame range; trim/render the source video first"));
+ }
+ let source_count = source_count as i64;
+ Ok(Source { footage, path: source_path, fps: source_fps, count: source_count })
+}
+
+fn scene_warnings(camera: Option<&Layer>, solve: &CameraSolve, footage: &Footage) -> Vec {
+ let mut warnings = vec![
+ "Sensor width is assumed (36 mm by default); monocular tracking cannot recover physical sensor size or absolute scene scale.".to_string(),
+ "The original footage is referenced, not embedded or rendered: masks and effects before the tracker are not baked. Relink the video if moving this script.".to_string(),
+ ];
+ if camera.is_none() {
+ warnings
+ .push("No linked tracker camera: focus uses the default scene depth, DOF is off; optical focus cannot be recovered from feature tracks.".into());
+ } else {
+ warnings.push("Focus/iris settings are sampled from the linked camera; pose and lens come from the tracker solve. Blender's physical DOF is an approximation of EffectCraft's blur model.".into());
+ }
+ if solve.distortion.is_some_and(|d| !d.is_none()) {
+ warnings.push("Radial distortion is preserved as metadata (Brown k1/k2, half-diagonal normalization), not applied to Blender's pinhole camera. Use an undistorted plate for exact alignment.".into());
+ }
+ if (footage.pixel_aspect - 1.0).abs() > 1e-6 {
+ warnings.push("The tracker solves square pixels: export uses square pixels; original footage pixel aspect is retained in metadata.".into());
+ }
+ warnings
+}
+
+/// Bake the solved camera and the linked camera's optical settings at exact composition ticks.
+fn bake_frames(
+ ctx0: &EvalCtx<'_>,
+ layer: &Layer,
+ solve: &CameraSolve,
+ camera: Option<&Layer>,
+ options: &Options<'_>,
+ footage: &Footage,
+ source_count: i64,
+) -> Result<(Vec, linalg::Similarity)> {
+ let comp = ctx0.comp;
+ let lo = ctx0.time;
+ let fd = comp.frame_duration();
+ let sensor = options.sensor;
+ let units = options.units;
+ let start = options.start;
+ let first = solve.frames.first().ok_or_else(|| bad(CMD, "the camera solve is empty"))?;
+ let m = ctx0.world_matrix(layer).0;
+ let layer_scale = m[0][0].hypot(m[1][0]);
+ finite([layer_scale])?;
+ if layer_scale <= 1e-9 {
+ return Err(bad(CMD, "the tracked layer has zero scale"));
+ }
+ let world = solve.world([comp.width as f64, comp.height as f64], first.focal * layer_scale);
+ finite([world.s * units])?;
+ let mut frames = Vec::with_capacity(solve.frames.len());
+ for (i, frame) in solve.frames.iter().enumerate() {
+ let time = Tick(lo.0.saturating_add(fd.0.saturating_mul(i as i64)));
+ let ctx = ctx0.at(time);
+ let pose = linalg::mm(&world.r, &linalg::mt(&frame.r()));
+ let right = position(linalg::mv(&pose, [1.0, 0.0, 0.0]), 1.0);
+ let up = position(linalg::mv(&pose, [0.0, -1.0, 0.0]), 1.0);
+ let back = position(linalg::mv(&pose, [0.0, 0.0, -1.0]), 1.0);
+ let location = position(world.apply(frame.center), units);
+ let lens = frame.focal * sensor / solve.size[0];
+ let opts = camera.and_then(|l| l.props.sub("cameraOptions").map(|g| (l, g)));
+ let value = |key: &str, fallback: f64| opts.map(|(l, g)| ctx.f(l, g, key, fallback)).unwrap_or(fallback);
+ let focus = value("focusDistance", world.s) * units;
+ let aperture = value("aperture", 0.0);
+ let zoom = value("zoom", frame.focal * layer_scale);
+ // Match Camera Settings' f-stop conversion: focal_for_zoom uses a 36 mm film width,
+ // and the aperture UI is mm scaled by comp_width / 1920 (not a world-space diameter).
+ let aperture_mm = aperture * 1920.0 / comp.width as f64;
+ let fstop = if aperture_mm > 0.0 { (zoom * 36.0 / comp.width as f64) / aperture_mm } else { 2.8 };
+ let rotation = value("irisRotation", 0.0).to_radians();
+ let ratio = value("irisAspectRatio", 1.0);
+ let shape = opts.map(|(l, g)| ctx.e(l, g, "irisShape")).unwrap_or(0);
+ let blades = match shape {
+ 0 => 4,
+ 1..=8 => shape.saturating_add(2),
+ _ => return Err(bad(CMD, "invalid iris shape")),
+ };
+ let dof = opts.is_some_and(|(l, g)| ctx.b(l, g, "dof")) && aperture > 0.0 && value("blurLevel", 100.0) > 0.0;
+ let source_time = ctx.source_time(layer);
+ let source_frame = if footage.kind == FootageKind::Still {
+ 0
+ } else {
+ footage.frame_rate.frame_at(source_time).clamp(0, source_count.saturating_mul(footage.loop_count.max(1) as i64).saturating_sub(1)) % source_count
+ };
+ finite(location.into_iter().chain([focus, aperture, zoom, fstop, rotation, ratio, value("blurLevel", 100.0), value("irisRoundness", 0.0)]))?;
+ if focus <= 0.0 || !(0.01..=100.0).contains(&ratio) || !(0.01..=1e6).contains(&fstop) {
+ return Err(bad(CMD, "focus, f-stop or iris aspect is outside Blender's supported range"));
+ }
+ frames.push(json!({"frame": start + i as i64, "timeTicks": time.0, "sourceFrame": source_frame,
+ "location": location, "rotation": [right, up, back], "lens": lens, "focus": focus, "dof": dof,
+ "fstop": fstop, "blades": blades, "irisRotation": rotation, "irisRatio": ratio,
+ "sourceAperture": aperture, "sourceZoom": zoom,
+ "blurLevel": value("blurLevel", 100.0), "irisRoundness": value("irisRoundness", 0.0)}));
+ }
+ Ok((frames, world))
+}
diff --git a/crates/engine/src/cmd/blender/validate.rs b/crates/engine/src/cmd/blender/validate.rs
new file mode 100644
index 000000000..a9454861f
--- /dev/null
+++ b/crates/engine/src/cmd/blender/validate.rs
@@ -0,0 +1,71 @@
+//! Validate solved geometry and its correspondence to the current layer timing.
+
+use effectcraft_project::{Comp, Layer};
+use effectcraft_time::Tick;
+use effectcraft_track::camtrack::{CameraSolve, linalg};
+
+use super::{CMD, MAX_FRAME, MAX_FRAMES, MAX_POINTS, bad, finite};
+use crate::Result;
+
+pub(super) fn timing(comp: &Comp, layer: &Layer, solve: &CameraSolve, start: i64) -> Result<(Tick, i64)> {
+ if solve.frames.len() > MAX_FRAMES || solve.points.len() > MAX_POINTS {
+ return Err(bad(CMD, "export is limited to 100,000 frames and 100,000 points; split the shot"));
+ }
+ if solve.size.iter().any(|v| !v.is_finite() || *v < 4.0 || *v > 65_536.0 || v.fract() != 0.0) {
+ return Err(bad(CMD, "invalid tracking resolution (expected 4..65536 whole pixels)"));
+ }
+ let fps = comp.frame_rate.as_f64();
+ if comp.frame_rate.num <= 0 || comp.frame_rate.den <= 0 || !fps.is_finite() || !(1.0..=1000.0).contains(&fps) {
+ return Err(bad(CMD, "invalid composition frame rate (expected 1..1000 fps)"));
+ }
+ let end = start
+ .checked_add(solve.frames.len() as i64)
+ .and_then(|n| n.checked_sub(1))
+ .filter(|n| *n <= MAX_FRAME)
+ .ok_or_else(|| bad(CMD, "the baked animation exceeds Blender's frame range"))?;
+ finite([solve.start, solve.frame_duration, solve.average_error, layer.stretch, comp.shutter_angle, comp.shutter_phase])?;
+ if layer.stretch == 0.0 || comp.width == 0 || comp.height == 0 {
+ return Err(bad(CMD, "invalid composition dimensions or zero layer stretch"));
+ }
+ let lo = comp.frame_rate.snap_nearest(layer.in_point.max(Tick::ZERO));
+ let hi = layer.out_point.min(comp.duration);
+ let fd = comp.frame_duration();
+ if fd.0 <= 0 || lo >= hi {
+ return Err(bad(CMD, "the tracked layer has no active frames"));
+ }
+ let expected = ((hi.0 as i128 - lo.0 as i128 + fd.0 as i128 - 1) / fd.0 as i128) as u128;
+ if expected != solve.frames.len() as u128 {
+ return Err(bad(CMD, "the solve no longer matches the layer's frame range; re-analyze the tracker"));
+ }
+ let dt = (layer.layer_time(lo + fd) - layer.layer_time(lo)).seconds();
+ if (solve.start - layer.layer_time(lo).seconds()).abs() > 1e-6 || solve.frames.len() > 1 && (solve.frame_duration - dt).abs() > 1e-6 {
+ return Err(bad(CMD, "the solve no longer matches the layer's timing; re-analyze the tracker"));
+ }
+ Ok((lo, end))
+}
+
+pub(super) fn geometry(solve: &CameraSolve, sensor: f64) -> Result<()> {
+ for frame in &solve.frames {
+ finite(frame.rot.into_iter().chain(frame.center).chain([frame.focal]))?;
+ if !frame.solved || frame.focal <= 0.0 {
+ return Err(bad(CMD, "the solve contains unsolved frames or invalid focal lengths; re-analyze the tracker"));
+ }
+ let mm = sensor * frame.focal / solve.size[0];
+ if !(1.0..=5000.0).contains(&mm) {
+ return Err(bad(CMD, "lens outside Blender's 1..5000 mm range; choose another sensorWidth"));
+ }
+ }
+ if let Some(ground) = &solve.ground {
+ finite(ground.origin.into_iter().chain(ground.normal).chain(ground.x_axis))?;
+ if (linalg::norm(ground.normal) - 1.0).abs() > 1e-4
+ || (linalg::norm(ground.x_axis) - 1.0).abs() > 1e-4
+ || linalg::dot(ground.normal, ground.x_axis).abs() > 1e-4
+ {
+ return Err(bad(CMD, "invalid ground-plane basis; set the ground plane again"));
+ }
+ }
+ if let Some(d) = &solve.distortion {
+ finite([d.k1, d.k2, d.radius])?;
+ }
+ Ok(())
+}
diff --git a/crates/engine/src/cmd/blender_scene.py b/crates/engine/src/cmd/blender_scene.py
new file mode 100644
index 000000000..0a9700a00
--- /dev/null
+++ b/crates/engine/src/cmd/blender_scene.py
@@ -0,0 +1,204 @@
+# EffectCraft camera tracker → Blender, original code under MIT OR Apache-2.0.
+# Run in Blender's Scripting workspace (Open, Run Script), or:
+# blender --background --python "Tracked Scene.py"
+# Creates a NEW scene; existing scenes and objects are preserved. Save the .blend yourself.
+# Public API types: bpy.types.Camera, bpy.types.ImageUser
+import json
+import math
+import os
+
+import bpy
+from mathutils import Matrix
+
+DATA = json.loads(__EFFECTCRAFT_DATA__)
+
+
+def _curves(owner):
+ """Blender 4.0–4.3 legacy actions and 4.4+/5.x layered actions."""
+ anim = owner.animation_data
+ if anim is None or anim.action is None:
+ return
+ action = anim.action
+ if getattr(action, "is_action_layered", False):
+ for layer in action.layers:
+ for strip in layer.strips:
+ if strip.type == 'KEYFRAME':
+ bag = strip.channelbag(anim.action_slot)
+ if bag is not None:
+ yield from bag.fcurves
+ else:
+ yield from action.fcurves
+
+
+def _interpolation(owner):
+ for curve in _curves(owner):
+ # Footage indices and discrete switches must never interpolate between frames.
+ stepped = ("frame_offset" in curve.data_path or "use_dof" in curve.data_path
+ or "aperture_blades" in curve.data_path)
+ for key in curve.keyframe_points:
+ key.interpolation = 'CONSTANT' if stepped else 'LINEAR'
+
+
+def _media_path():
+ original = DATA["media"]["path"]
+ candidates = [original]
+ # When sharing, place the video alongside the script or the .blend and run again.
+ script_path = globals().get("__file__", "")
+ if script_path:
+ candidates.append(os.path.join(os.path.dirname(os.path.abspath(script_path)),
+ os.path.basename(original)))
+ if bpy.data.filepath:
+ candidates.append(os.path.join(os.path.dirname(bpy.data.filepath),
+ os.path.basename(original)))
+ return next((p for p in candidates if os.path.isfile(p)), None)
+
+
+def import_scene():
+ if bpy.app.version < (4, 0, 0):
+ raise RuntimeError("EffectCraft scene import requires Blender 4.0 or newer")
+ scene = bpy.data.scenes.new(DATA["name"])
+ scene.frame_start = DATA["startFrame"]
+ scene.frame_end = DATA["endFrame"]
+ fps = DATA["fpsNumerator"] / DATA["fpsDenominator"]
+ scene.render.fps = max(1, round(fps))
+ scene.render.fps_base = scene.render.fps / fps
+ scene.render.resolution_x, scene.render.resolution_y = map(int, DATA["resolution"])
+ scene.render.resolution_percentage = 100
+ # The tracker assumes square pixels, regardless of the source's interpretation metadata.
+ scene.render.pixel_aspect_x = scene.render.pixel_aspect_y = 1.0
+ scene.render.film_transparent = True
+ scene.render.motion_blur_shutter = max(0.0, min(2.0, DATA["shutter"]))
+ scene.unit_settings.system = 'METRIC'
+ scene.unit_settings.scale_length = 1.0
+ scene["effectcraft_unit_scale"] = DATA["unitScale"]
+ scene["effectcraft_scale_is_arbitrary"] = True
+ scene["effectcraft_shutter_phase_degrees"] = DATA["shutterPhase"]
+
+ collection = bpy.data.collections.new("EffectCraft Tracking")
+ scene.collection.children.link(collection)
+ camera_data = bpy.data.cameras.new("3D Tracker Camera")
+ camera = bpy.data.objects.new("3D Tracker Camera", camera_data)
+ collection.objects.link(camera)
+ scene.camera = camera
+ camera.rotation_mode = 'QUATERNION'
+ camera_data.type = 'PERSP'
+ camera_data.sensor_fit = 'HORIZONTAL'
+ camera_data.sensor_width = DATA["sensorWidth"]
+ camera_data.sensor_height = (DATA["sensorWidth"] * DATA["resolution"][1]
+ / DATA["resolution"][0])
+ camera_data.show_background_images = True
+ camera_data.show_limits = True
+ extent = max([DATA["groundSize"], 1.0]
+ + [math.dist(p["location"], f["location"])
+ for p in DATA["points"][::max(1, len(DATA["points"]) // 100)]
+ for f in DATA["frames"][::max(1, len(DATA["frames"]) // 10)]])
+ camera_data.clip_start = max(0.00001, min(0.1, extent / 10000.0))
+ camera_data.clip_end = max(1000.0, extent * 10.0)
+
+ previous = None
+ for sample in DATA["frames"]:
+ frame = sample["frame"]
+ camera.location = sample["location"]
+ # Exported right/up/back vectors are COLUMNS of Blender's local → world basis.
+ quaternion = Matrix(sample["rotation"]).transposed().to_quaternion()
+ if previous is not None and previous.dot(quaternion) < 0:
+ quaternion.negate()
+ camera.rotation_quaternion = quaternion
+ previous = quaternion.copy()
+ camera.keyframe_insert(data_path="location", frame=frame)
+ camera.keyframe_insert(data_path="rotation_quaternion", frame=frame)
+ camera_data.lens = sample["lens"]
+ camera_data.keyframe_insert(data_path="lens", frame=frame)
+ camera_data.dof.use_dof = sample["dof"]
+ camera_data.dof.focus_distance = sample["focus"]
+ camera_data.dof.aperture_fstop = sample["fstop"]
+ camera_data.dof.aperture_blades = sample["blades"]
+ camera_data.dof.aperture_rotation = (sample["irisRotation"] + math.pi) % math.tau - math.pi
+ camera_data.dof.aperture_ratio = sample["irisRatio"]
+ for prop in ("use_dof", "focus_distance", "aperture_fstop", "aperture_blades",
+ "aperture_rotation", "aperture_ratio"):
+ camera_data.keyframe_insert(data_path="dof." + prop, frame=frame)
+
+ path = _media_path()
+ if path is not None:
+ try:
+ image = bpy.data.images.load(path, check_existing=False)
+ image.source = DATA["media"]["kind"]
+ background = camera_data.background_images.new()
+ background.source = 'IMAGE'
+ background.image = image
+ background.alpha = 1.0
+ background.display_depth = 'BACK'
+ background.frame_method = 'STRETCH'
+ user = background.image_user
+ user.use_auto_refresh = True
+ user.use_cyclic = False
+ user.frame_start = DATA["startFrame"]
+ user.frame_duration = max(1, min(1048574, image.frame_duration))
+ if image.source == 'MOVIE':
+ for sample in DATA["frames"]:
+ # Blender uses 1-based image frames; sourceFrame is 0-based.
+ user.frame_offset = sample["sourceFrame"] - (sample["frame"] - user.frame_start)
+ camera_data.keyframe_insert(
+ data_path="background_images[0].image_user.frame_offset",
+ frame=sample["frame"])
+ except (RuntimeError, ValueError, OSError) as error:
+ DATA["warnings"].append("Blender could not load the background video: " + str(error))
+ else:
+ DATA["warnings"].append("Video not found: " + DATA["media"]["path"]
+ + ". Place it beside this script or relink the camera background.")
+
+ if DATA["points"]:
+ mesh = bpy.data.meshes.new("Solved Track Points")
+ mesh.from_pydata([p["location"] for p in DATA["points"]], [], [])
+ mesh.update()
+ cloud = bpy.data.objects.new("Solved Track Points", mesh)
+ collection.objects.link(cloud)
+ cloud.show_in_front = True
+ cloud.hide_render = True
+ for name, kind, key in (("track_id", 'INT', "id"), ("reprojection_error", 'FLOAT', "error"),
+ ("first_frame", 'INT', "first"), ("last_frame", 'INT', "last")):
+ attr = mesh.attributes.new(name=name, type=kind, domain='POINT')
+ for dest, point in zip(attr.data, DATA["points"]):
+ # Track IDs are u32 in EffectCraft; keep the full IDs in the JSON text too.
+ dest.value = point[key] if kind == 'FLOAT' else min(2147483647, point[key])
+
+ if DATA["ground"]:
+ size = DATA["groundSize"]
+ mesh = bpy.data.meshes.new("Solved Ground Plane")
+ mesh.from_pydata([(-size, -size, 0), (size, -size, 0),
+ (size, size, 0), (-size, size, 0)], [], [(0, 1, 2, 3)])
+ mesh.update()
+ ground = bpy.data.objects.new("Solved Ground Plane", mesh)
+ collection.objects.link(ground)
+ ground.display_type = 'WIRE'
+ ground.hide_render = True
+ for marker in DATA["markers"]:
+ scene.timeline_markers.new(marker["name"], frame=marker["frame"])
+
+ _interpolation(camera)
+ _interpolation(camera_data)
+ metadata = bpy.data.texts.new("EffectCraft Tracking Metadata.json")
+ metadata.write(json.dumps(DATA, ensure_ascii=False, indent=2))
+ scene["effectcraft_metadata_text"] = metadata.name
+ camera_data["effectcraft_average_error_pixels"] = DATA["solve"]["averageError"]
+ camera_data["effectcraft_solve_method"] = DATA["solve"]["methodUsed"]
+ camera_data["effectcraft_focus_source"] = "See warnings in " + metadata.name
+ distortion = DATA["solve"].get("distortion")
+ if distortion:
+ for key in ("k1", "k2", "radius"):
+ camera_data["effectcraft_distortion_" + key] = distortion[key]
+ if bpy.context.window is not None:
+ bpy.context.window.scene = scene
+ for area in bpy.context.window.screen.areas:
+ if area.type == 'VIEW_3D':
+ area.spaces.active.region_3d.view_perspective = 'CAMERA'
+ scene.frame_set(scene.frame_start)
+ for warning in DATA["warnings"]:
+ print("EffectCraft: " + warning)
+ print("EffectCraft: imported", len(DATA["frames"]), "baked camera frames into", scene.name)
+ return scene
+
+
+if __name__ == "__main__":
+ EFFECTCRAFT_SCENE = import_scene()
diff --git a/crates/engine/src/cmd/camera_cmds.rs b/crates/engine/src/cmd/camera_cmds.rs
index 68cd14fb9..e717d51cc 100644
--- a/crates/engine/src/cmd/camera_cmds.rs
+++ b/crates/engine/src/cmd/camera_cmds.rs
@@ -42,11 +42,25 @@ pub fn specs() -> Vec {
create_from_solve
),
cmd!("camera.create", "Create Camera", [], None, "{layer?, effect?}", has_comp, |s, p| create_from_solve(s, &with_kind(p, "camera"))),
+ cmd!(
+ "camera.exportBlender",
+ "Export Scene to Blender...",
+ [],
+ None,
+ "{path: .py, comp?, layer?, effect?, sensorWidth?: mm (default 36), unitScale?: Blender units per comp pixel (default 0.01), startFrame?: positive integer (default 1), includePoints?: true}",
+ has_comp,
+ export_blender
+ ),
cmd!("camera.setGroundPlane", "Set Ground Plane and Origin", [], None, "{points?: [id] (default: selected), layer?, effect?}", has_comp, set_ground),
cmd!("camera.deletePoints", "Delete Selected Points", [], None, "{points?: [id] (default: selected), layer?, effect?, wait?}", has_comp, delete_points),
]
}
+fn export_blender(s: &mut Session, p: &Value) -> Result {
+ let (cid, lid, uid) = ct_p(s, p, "camera.exportBlender")?;
+ super::blender::export(s, p, cid, lid, uid)
+}
+
fn with_kind(p: &Value, kind: &str) -> Value {
let mut q = p.clone();
if !q.is_object() {
diff --git a/crates/engine/src/cmd/comp.rs b/crates/engine/src/cmd/comp.rs
index 6648a7426..8bc0931f8 100644
--- a/crates/engine/src/cmd/comp.rs
+++ b/crates/engine/src/cmd/comp.rs
@@ -35,7 +35,7 @@ fn apply_settings(c: &mut Comp, p: &Value) {
let dx = (c.width as f64 - ow as f64) * (a % 3) as f64 / 2.0;
let dy = (c.height as f64 - oh as f64) * (a / 3) as f64 / 2.0;
if dx != 0.0 || dy != 0.0 {
- for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) {
+ for l in c.layers.iter_mut_where(|l| l.parent.is_none()) {
if let Some(tr) = l.transform_mut() {
super::layer::offset_position(tr, dx, dy);
}
diff --git a/crates/engine/src/cmd/comp_more.rs b/crates/engine/src/cmd/comp_more.rs
index d6de320ec..83c49bad2 100644
--- a/crates/engine/src/cmd/comp_more.rs
+++ b/crates/engine/src/cmd/comp_more.rs
@@ -5,7 +5,7 @@ use effectcraft_project::Marker;
use effectcraft_time::Tick;
use serde_json::{Value, json};
-use super::{CommandSpec, always, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p};
+use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p};
use crate::prefs::CompPreset;
use crate::{EngineError, Result, Session, cmd, query};
@@ -25,7 +25,7 @@ fn crop(s: &mut Session, cid: effectcraft_project::ItemId, r: [f64; 4], label: &
let (old_w, old_h) = (c.width, c.height);
c.width = w;
c.height = h;
- for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) {
+ for l in c.layers.iter_mut_where(|l| l.parent.is_none()) {
if let Some(tr) = l.transform_mut() {
super::layer::offset_position(tr, -x, -y);
}
@@ -82,7 +82,7 @@ pub(crate) fn encode_png(rgba: &[u8], w: u32, h: u32) -> std::result::Result Result {
- use effectcraft_project::render_queue::{RenderQueueItem, TimeSpan};
+ use effectcraft_project::render_queue::{Channels, RenderQueueItem, TimeSpan};
use effectcraft_project::render_templates::TemplateSlot;
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
@@ -91,6 +91,9 @@ fn queue_frame(s: &mut Session, p: &Value) -> Result {
it.settings = s.project.render_templates.default_render_settings(TemplateSlot::Still);
it.settings.time_span = TimeSpan::Custom { start: t, end: t + comp.frame_duration() };
it.output = s.project.render_templates.default_output_module(TemplateSlot::Still);
+ if b_p(p, "transparent") == Some(false) {
+ it.output.channels = Channels::Rgb;
+ }
if let Some(path) = str_p(p, "path") {
it.output.output = path.to_string();
} else if !it.output.format.is_sequence() {
@@ -107,7 +110,7 @@ fn queue_frame(s: &mut Session, p: &Value) -> Result {
}
fn save_frame(s: &mut Session, p: &Value) -> Result {
- if p.get("queue").and_then(Value::as_bool) == Some(true) {
+ if b_p(p, "queue") == Some(true) {
return queue_frame(s, p);
}
let path = str_p(p, "path").ok_or_else(|| bad("comp.saveFrameAs", "missing `path` (.png)"))?.to_string();
@@ -117,7 +120,7 @@ fn save_frame(s: &mut Session, p: &Value) -> Result {
let scale = f_p(p, "scale").unwrap_or(1.0).clamp(0.01, 4.0);
let bg = comp.background;
let img = s.render(cid, t, effectcraft_render::RenderOpts { scale, backend: effectcraft_render::Backend::Auto, ..Default::default() });
- let rgba = img.to_rgba8_over(bg);
+ let rgba = if b_p(p, "transparent") == Some(false) { img.to_rgba8_over(bg) } else { img.to_rgba8() };
let png = encode_png(&rgba, img.width, img.height).map_err(|e| EngineError::Other(format!("PNG encoding failed: {e}")))?;
s.services.write_file(&path, &png).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
s.toast(format!("Saved frame to {path}"));
@@ -235,7 +238,7 @@ pub fn specs() -> Vec {
"File...",
["Composition", "Save Frame As"],
Some("Cmd+Alt+S"),
- "{path (.png; with queue: an output path or template), time?, scale?, queue?: bool (add a Render Queue item from the Frame Default templates instead of writing now)}",
+ "{path (.png; with queue: an output path or template), time?, scale?, queue?: bool (add a Render Queue item from the Frame Default templates instead of writing now), transparent?: bool (default true)}",
has_comp,
save_frame
),
diff --git a/crates/engine/src/cmd/content_fill.rs b/crates/engine/src/cmd/content_fill.rs
index 650df5e73..d96be073d 100644
--- a/crates/engine/src/cmd/content_fill.rs
+++ b/crates/engine/src/cmd/content_fill.rs
@@ -170,6 +170,21 @@ pub struct FillPlan {
pub dir: String,
}
+/// The fill holds every frame as float RGBA (16 bytes a pixel), its hole mask (1) and the filled
+/// copy (16) until it finishes (#1789): when even that is more than `available`, the error to
+/// report up front, instead of running out of memory partway through.
+fn memory_shortfall(frames: usize, w: u32, h: u32, available: u64) -> Option {
+ let need = (frames as u64).saturating_mul(u64::from(w)).saturating_mul(u64::from(h)).saturating_mul(33);
+ (need > available).then(|| {
+ let gb = |b: u64| b as f64 / 1e9;
+ format!(
+ "Content-Aware Fill of {frames} frames at {w}×{h} needs at least {:.1} GB of memory, and {:.1} GB is free: fill a shorter range (set the work area and choose Work Area) or close other applications",
+ gb(need),
+ gb(available)
+ )
+ })
+}
+
/// Run a fill: the layer's frames (rendered in passes with a job worker's GPU), the fill, the
/// PNG sequence written into `plan.dir` (`Services::store_file`). Returns the files.
#[allow(clippy::too_many_arguments)]
@@ -275,6 +290,9 @@ fn generate(s: &mut Session, p: &Value) -> Result {
}
let (fa, fb) = (fr.frame_at(fr.snap_nearest(a)), fr.frame_at(b - Tick(1)));
let times: Vec