mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-03 00:59:00 +00:00
Merge main into #2520; fold the PR's DefaultOutput into main's Lookup::QueueDefault (#2636) for renderQueue.add, render.preRender and file.createProxy, keep #2521's RUNS_SCRIPTS refusal with Vec<Lookup>, keep both tests, blender test expects no lookups, docs/mcp.md names all three
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
b6d4090e4e
684 files changed
+51180
-8870
No files matched your search
@@ -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')
|
||||
@@ -33,3 +33,5 @@ jobs:
|
||||
with:
|
||||
app-name: effectcraft
|
||||
app-dir: EffectCraft
|
||||
install-relative-path: Programs/ArtCraft/EffectCraft
|
||||
install-scope: perUser
|
||||
@@ -144,3 +144,8 @@ as PhotoCraft does with `cargo xtask corpus`.
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
@@ -29,3 +30,8 @@ EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual e
|
||||
- For UI work, **look at the result**. Headless (no window, works while the user is busy on the machine): `cargo run -p effectcraft-ui-egui --example snapshot -- --out ui.png --step '{"method":"engine.execute","params":{"command":"layer.select","params":{"layers":["#2"]}}}'` (steps are control-channel requests; `--script steps.jsonl`; `{"method":"snap","params":{"path":…}}` for intermediate shots). Or drive the live app via the control channel and take `ui.screenshot` (needs the window visible on screen).
|
||||
- After Effects reference: drive AE via ExtendScript (`osascript -e 'tell application "Adobe After Effects 2026" to DoScriptFile "x.jsx"'`) and capture only AE's window (`screencapture -l <windowid>`); keep captures in `plan/aftereffects/ref/` (gitignored), never commit them, never capture the Home screen.
|
||||
- Parallel agents: separate git worktrees and `CARGO_TARGET_DIR=target/agent-<name>`; keep every `Cargo.toml` valid (the `crates/*` glob).
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
@@ -7,12 +7,75 @@ 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
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
The MSVC toolchain, rustup's default on Windows, is what the releases use: with the Visual Studio
|
||||
C++ build tools installed, the commands above work as they are.
|
||||
|
||||
The GNU toolchain (`x86_64-pc-windows-gnu`) needs a current mingw-w64 GCC and binutils first on
|
||||
`PATH`. They provide the linker and the Windows import libraries, such as `libshlwapi.a` for
|
||||
opening links from the app. With MSYS2:
|
||||
|
||||
```powershell
|
||||
C:\msys64\usr\bin\pacman.exe -S --needed mingw-w64-x86_64-gcc
|
||||
$env:PATH = "C:\msys64\mingw64\bin;$env:PATH"
|
||||
cargo +stable-x86_64-pc-windows-gnu run -p effectcraft -- --demo
|
||||
```
|
||||
|
||||
An older MinGW earlier on `PATH` can break the build: CodeBlocks' GCC 8.1 fails to link
|
||||
`boa_engine` ([#1882](https://github.com/storytold/effectcraft/issues/1882)).
|
||||
|
||||
Rust's bundled LLD and self-contained libraries don't include every import library EffectCraft
|
||||
needs, so on their own they stop with `unable to find library` for `-lshlwapi`, `-lktmw32`,
|
||||
`-lpropsys` and `-lversion` ([#1886](https://github.com/storytold/effectcraft/issues/1886)). To
|
||||
link that way, copy those four from mingw-w64 into a folder of its own and add that folder to the
|
||||
search path in your own Cargo config (`%USERPROFILE%\.cargo\config.toml`). Don't add all of
|
||||
mingw-w64's `lib` folder: its C runtime clashes with Rust's bundled one
|
||||
(`undefined symbol: DllEntryPoint`).
|
||||
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force C:\gnu-import-libs
|
||||
foreach ($lib in "shlwapi", "ktmw32", "propsys", "version") {
|
||||
Copy-Item "C:\msys64\mingw64\lib\lib$lib.a" C:\gnu-import-libs\
|
||||
}
|
||||
```
|
||||
|
||||
```toml
|
||||
[target.x86_64-pc-windows-gnu]
|
||||
linker = "rust-lld"
|
||||
rustflags = ["-C", "link-self-contained=yes", "-L", "native=C:/gnu-import-libs"]
|
||||
```
|
||||
|
||||
## Ground rules
|
||||
|
||||
- **Clean room.** Work from After Effects' public documentation and its observable behaviour only.
|
||||
|
||||
Generated
+160
-95
@@ -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",
|
||||
@@ -1443,6 +1454,7 @@ dependencies = [
|
||||
"kurbo",
|
||||
"log",
|
||||
"rayon",
|
||||
"same-file",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1451,7 +1463,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-export"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-av1enc",
|
||||
"effectcraft-color",
|
||||
@@ -1481,7 +1493,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-expr"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-color",
|
||||
@@ -1498,7 +1510,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-geom"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1506,7 +1518,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-gpu"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"effectcraft-color",
|
||||
@@ -1528,14 +1540,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 +1572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-interchange"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-keyframe",
|
||||
@@ -1579,7 +1591,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-keyframe"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
@@ -1591,7 +1603,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-lottie"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"effectcraft-color",
|
||||
@@ -1609,9 +1621,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 +1634,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 +1666,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 +1685,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-pdf"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"effectcraft-raster",
|
||||
@@ -1685,7 +1701,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-plugin"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-effects",
|
||||
"effectcraft-keyframe",
|
||||
@@ -1697,7 +1713,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-project"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
@@ -1710,14 +1726,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 +1742,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-render"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
@@ -1748,7 +1764,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-script"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-engine",
|
||||
@@ -1766,7 +1782,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-segment"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.9.1",
|
||||
"ndarray",
|
||||
@@ -1775,7 +1791,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-svg"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"effectcraft-path",
|
||||
@@ -1787,7 +1803,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-text"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-keyframe",
|
||||
"harfrust",
|
||||
@@ -1799,7 +1815,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-time"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1807,7 +1823,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-track"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-raster",
|
||||
"effectcraft-segment",
|
||||
@@ -1819,9 +1835,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 +1870,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 +2249,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 +2258,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 +2318,7 @@ dependencies = [
|
||||
"filmcraft-isobmff",
|
||||
"filmcraft-matroska",
|
||||
"filmcraft-media",
|
||||
"filmcraft-mpa",
|
||||
"filmcraft-mpeg2v",
|
||||
"filmcraft-mpegts",
|
||||
"filmcraft-mxf",
|
||||
@@ -2291,9 +2326,11 @@ dependencies = [
|
||||
"filmcraft-opus",
|
||||
"filmcraft-prores",
|
||||
"filmcraft-time",
|
||||
"filmcraft-vp8",
|
||||
"filmcraft-vp9",
|
||||
"image",
|
||||
"log",
|
||||
"rayon",
|
||||
"symphonia",
|
||||
"thiserror 2.0.21",
|
||||
"web-time",
|
||||
@@ -2301,16 +2338,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 +2365,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 +2376,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 +2394,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 +2404,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 +2414,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 +2431,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 +2440,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 +2458,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 +2478,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 +2515,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 +2525,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",
|
||||
@@ -4229,7 +4293,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.45.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4821,9 +4885,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.21.1"
|
||||
version = "0.21.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
|
||||
checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
@@ -7183,9 +7247,10 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"pkg-config",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
@@ -7405,9 +7470,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "9.1.0"
|
||||
version = "9.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704"
|
||||
checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
|
||||
+34
-21
@@ -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" }
|
||||
@@ -73,32 +74,33 @@ skrifa = { version = "0.44", default-features = false, features = ["std"] }
|
||||
harfrust = { version = "0.12", default-features = false, features = ["std"] }
|
||||
exr = "1.74"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
same-file = "1.0"
|
||||
|
||||
# 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 +132,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 +156,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
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
<p align="center">
|
||||
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.
|
||||
</p>
|
||||
|
||||
@@ -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-<ver>-windows-arm64.msi` | `effectcraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86 (32-bit) | `effectcraft-<ver>-windows-x86.msi` | `effectcraft-<ver>-windows-x86-portable.zip` |
|
||||
|
||||
Installers and executables are code-signed. The installer asks where to install EffectCraft
|
||||
(Program Files by default), and later versions install into the same folder. To install silently
|
||||
Installers and executables are code-signed. The installer asks who to install EffectCraft for and where. By default it installs for your
|
||||
account only, into `%LOCALAPPDATA%\Programs\ArtCraft\EffectCraft`, without an administrator prompt;
|
||||
"Everyone on this computer" installs into `C:\Program Files\ArtCraft\EffectCraft` (`Program Files (x86)` for the 32-bit build). Later versions
|
||||
install into the same folder. To install silently
|
||||
into a folder of your choice: `msiexec /i effectcraft-<ver>-windows-x64.msi /qn INSTALLFOLDER="D:\Apps\EffectCraft"`.
|
||||
A silent install is per-user by default. `INSTALLFOLDER` chooses the folder, not who it is installed for;
|
||||
add `ALLUSERS=1`, from an elevated terminal or deployment tool, for an all-users install.
|
||||
|
||||
### macOS
|
||||
|
||||
|
||||
+35
-11
@@ -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 |
|
||||
@@ -7,11 +7,12 @@
|
||||
//! effectcraft-cli exec --list [--filter TEXT] [--schemas] list engine commands (as `commands`)
|
||||
//! effectcraft-cli run <id> <json> [<id> <json> …] 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 <comp> <layer> [--flat] [--time S] a layer's property tree (with paths)
|
||||
//! effectcraft-cli get <comp> <layer> <path> [--time S] read a property
|
||||
//! effectcraft-cli set <comp> <layer> <path> <value> [--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 <info|commands|exec|run|props|get|se
|
||||
exec <command-id> [--params JSON] run one engine command (exec --list: list them, as `commands`)
|
||||
run <id> <json> [<id> <json> ...] 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 <id> <json> pairs and --args-file
|
||||
props <comp> <layer> [--flat] [--time S] a layer's property tree with paths
|
||||
get <comp> <layer> <path> [--time S] read a property
|
||||
set <comp> <layer> <path> <value> [--time S] [--expression E]
|
||||
@@ -107,7 +112,7 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
|
||||
[--jpeg-quality N] [--bitrate KBPS] [--prores PROFILE] [--audio auto|on|off]
|
||||
[--profile main|main10] [--level auto|4.1] [--rate-control bitrate|quality] [--video-quality N]
|
||||
[--keyint FRAMES] [--webm-codec vp9|av1] [--audio-bitrate KBPS] [--opus-app audio|voice] | --queue
|
||||
(formats h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff;
|
||||
(formats h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff;
|
||||
--profile..--keyint: HEVC / AV1, --audio-bitrate/--opus-app: WebM Opus)
|
||||
a relative --out is relative to the working directory
|
||||
--progress: progress lines on stderr when it is not a terminal
|
||||
@@ -151,6 +156,7 @@ const VALUED: &[&str] = &[
|
||||
"--depth",
|
||||
"--eval",
|
||||
"--args-file",
|
||||
"--commands",
|
||||
// render
|
||||
"--template",
|
||||
"--values",
|
||||
@@ -279,6 +285,9 @@ fn utf8_args(raw: impl IntoIterator<Item = std::ffi::OsString>) -> Result<Vec<St
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Warnings and errors to standard error (`RUST_LOG` overrides), so a GPU that declines
|
||||
// frames or runs out of memory says so instead of silently rendering on the CPU.
|
||||
effectcraft_engine::logging::install_with("warn");
|
||||
let raw = match utf8_args(std::env::args_os().skip(1)) {
|
||||
Ok(raw) => raw,
|
||||
Err(e) => fail_usage(&e),
|
||||
@@ -334,6 +343,87 @@ impl From<String> 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<String, String> {
|
||||
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<Vec<(String, Value)>, 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<usize>, 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<T>(m: &str) -> Result<T, Failure> {
|
||||
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 <command-id> [<json>] pairs");
|
||||
let sources = args.opts("--commands");
|
||||
if pairs.is_empty() && sources.is_empty() {
|
||||
return usage_err("run needs <command-id> [<json>] 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 `<id>: `.
|
||||
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);
|
||||
@@ -783,6 +890,7 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
r["outputModuleSummary"].as_str().unwrap_or("")
|
||||
);
|
||||
}
|
||||
let attempted: std::collections::HashSet<u64> = s.project.render_queue.iter().filter(|i| i.is_queued()).map(|i| i.id).collect();
|
||||
s.execute("renderQueue.render", json!({"wait": false})).map_err(err)?;
|
||||
let tty = std::io::IsTerminal::is_terminal(&std::io::stderr());
|
||||
// `--progress`: one line per update for a program reading stderr (a terminal gets the live line).
|
||||
@@ -821,7 +929,7 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
}
|
||||
let mut results = vec![];
|
||||
let mut failed = vec![];
|
||||
for it in s.project.render_queue.iter().filter(|i| i.render && i.started.is_some()) {
|
||||
for it in s.project.render_queue.iter().filter(|i| attempted.contains(&i.id)) {
|
||||
let name = s.project.item(it.comp).map(|i| i.name.clone()).unwrap_or_else(|| "?".into());
|
||||
match &it.status {
|
||||
RenderStatus::Done => {
|
||||
@@ -1189,7 +1297,7 @@ fn adv3d_bench_comp(p: &mut effectcraft_engine::project::Project) -> Option<Item
|
||||
set(l, "cameraOptions/highlightGain", Value::Scalar(20.0));
|
||||
set(l, "cameraOptions/highlightThreshold", Value::Scalar(200.0));
|
||||
});
|
||||
comp.layers = layers;
|
||||
comp.layers = layers.into();
|
||||
Some(p.add_item("Advanced 3D (bench)", effectcraft_engine::color::Label::None, None, ItemKind::Comp(comp.into())))
|
||||
}
|
||||
|
||||
|
||||
@@ -133,6 +133,78 @@ fn run_reads_arguments_from_a_file() {
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("--args-file no/such/file.txt"));
|
||||
}
|
||||
|
||||
/// Run with `stdin` piped in; returns (exit code, stdout, stderr).
|
||||
fn run_with_stdin(args: &[&str], stdin: &str) -> (i32, String, String) {
|
||||
let mut child = bin().args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
child.stdin.take().unwrap().write_all(stdin.as_bytes()).unwrap();
|
||||
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<String> = 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<Value> = 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::<Value>(&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::<String>()).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::<Value>(&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");
|
||||
@@ -533,6 +605,49 @@ fn a_failed_render_under_run_and_exec_exits_1() {
|
||||
assert!(v["error"].as_str().is_some_and(|e| e.starts_with("T: ")), "{v}");
|
||||
}
|
||||
|
||||
/// A source refusal happens before encoding and has no started timestamp. It must still
|
||||
/// fail the CLI, while the valid queue item finishes and the imported frame stays intact.
|
||||
#[test]
|
||||
fn a_partly_protected_queue_exits_1_under_render_run_and_exec() {
|
||||
let root = tmp("protected-mixed-queue");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let source = root.join("source_00000.png");
|
||||
image::RgbaImage::from_pixel(4, 4, image::Rgba([40, 80, 120, 255])).save(&source).unwrap();
|
||||
let original = std::fs::read(&source).unwrap();
|
||||
let project = root.join("q.ecproj");
|
||||
let good = root.join("good.gif");
|
||||
let import = json!({"paths": [source]}).to_string();
|
||||
let blocked = json!({"format": "png", "output": root.join("source_[#####].png")}).to_string();
|
||||
let valid = json!({"format": "gif", "output": good}).to_string();
|
||||
ok_json(&[
|
||||
"run",
|
||||
"--empty",
|
||||
"--save-as",
|
||||
project.to_str().unwrap(),
|
||||
"comp.new",
|
||||
r#"{"name":"Main","width":16,"height":16,"frameRate":10,"duration":0.1}"#,
|
||||
"layer.newSolid",
|
||||
"{}",
|
||||
"file.import",
|
||||
&import,
|
||||
"renderQueue.add",
|
||||
&blocked,
|
||||
"renderQueue.add",
|
||||
&valid,
|
||||
]);
|
||||
let p = project.to_str().unwrap();
|
||||
for args in [vec!["render", "--queue", "--project", p], vec!["run", p, "renderQueue.render", "{}"], vec!["exec", "renderQueue.render", "--project", p]] {
|
||||
std::fs::write(&good, b"previous output").unwrap();
|
||||
let (code, result) = run_json(&args);
|
||||
assert_eq!(code, 1, "{args:?}: {result}");
|
||||
assert!(result["error"].as_str().is_some_and(|e| e.contains("footage") && e.contains("source_00000.png")), "{result}");
|
||||
assert_eq!(std::fs::read(&source).unwrap(), original);
|
||||
assert!(std::fs::read(&good).unwrap().starts_with(b"GIF8"), "the valid item still rendered");
|
||||
}
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
/// `render --bitrate` (#440) targets the bitrate in VP9 WebM.
|
||||
#[test]
|
||||
fn render_bitrate_applies_to_vp9_webm() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<String>) {
|
||||
/// 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?;
|
||||
|
||||
@@ -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<Option<Option<Vec<u8>>>> = 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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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
|
||||
}
|
||||
@@ -151,6 +151,10 @@ impl Services for WebServices {
|
||||
fn is_dir(&self, _path: &str) -> bool {
|
||||
false
|
||||
}
|
||||
/// The table's keys are the paths themselves.
|
||||
fn full_path(&self, path: &str) -> Option<String> {
|
||||
Some(path.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Media import from the table (the bytes are also handed to the media pool, which decodes them).
|
||||
|
||||
@@ -311,10 +311,11 @@ pub fn stats() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
type PendingFrameCheck = (js_sys::Function, js_sys::Function, (u32, u32, Vec<u8>), f64);
|
||||
|
||||
thread_local! {
|
||||
/// `workerFrameCheck` promises waiting for their frame: id → (resolve, reject, local frame).
|
||||
#[allow(clippy::type_complexity)]
|
||||
static CHECKS: RefCell<std::collections::HashMap<u64, (js_sys::Function, js_sys::Function, (u32, u32, Vec<u8>), f64)>> = RefCell::new(Default::default());
|
||||
static CHECKS: RefCell<std::collections::HashMap<u64, PendingFrameCheck>> = RefCell::new(Default::default());
|
||||
}
|
||||
|
||||
/// `effectcraft.workerFrameCheck({time?, scale?, backend?})`: render the active comp's frame in
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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::<Value>(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
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
let pre = format!("{CONFIG}{}/", dir.trim_end_matches('/'));
|
||||
let mut v: Vec<String> = 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<PathBuf> {
|
||||
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();
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
mod startup_tests;
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
//!
|
||||
//! `--control <port>` (or `EFFECTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server;
|
||||
//! see `effectcraft_ui_egui::control` for the methods.
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
|
||||
// Built everywhere so its tests run on every platform; only Linux AppImages use it.
|
||||
mod appearance;
|
||||
@@ -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::wgpu::Backends>) -> eframe::WgpuConfiguration {
|
||||
use eframe::egui_wgpu::WgpuSetup;
|
||||
let mut config = eframe::WgpuConfiguration::default();
|
||||
let mut config = eframe::WgpuConfiguration::default().with_surface_config(eframe::SurfaceConfig::LOW_LATENCY);
|
||||
if let WgpuSetup::CreateNew(setup) = &mut config.wgpu_setup {
|
||||
if let Some(b) = backends {
|
||||
setup.instance_descriptor.backends = b;
|
||||
@@ -455,7 +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() {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -65,7 +65,7 @@ impl Backend {
|
||||
if let Some(c) = self.confinement.clone() {
|
||||
for lookup in c.check_command(id, ¶ms)? {
|
||||
let found = match lookup {
|
||||
Lookup::ProjectFile | Lookup::DefaultOutput => self.run("project.summary", json!({}))?,
|
||||
Lookup::ProjectFile | Lookup::QueueDefault => self.run("project.summary", json!({}))?,
|
||||
Lookup::RenderOutputs => self.run("renderQueue.list", json!({}))?,
|
||||
};
|
||||
c.check_lookup(&lookup, &found)?;
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
//! 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. Queueing a render without naming
|
||||
//! its output needs a saved project, because the default output of an unsaved one is the home
|
||||
//! folder. Commands that run code or open other applications (scripts, Execute File, Reveal in
|
||||
//! Finder…) are refused while confined.
|
||||
//! folder. Commands that run code or open 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};
|
||||
|
||||
@@ -30,13 +31,21 @@ pub struct Confinement {
|
||||
write: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// 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",
|
||||
@@ -66,10 +75,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"]),
|
||||
@@ -86,6 +114,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.
|
||||
@@ -99,11 +130,12 @@ const QUEUES_AN_OUTPUT: &[&str] = &["renderQueue.add", "render.preRender", "file
|
||||
pub enum Lookup {
|
||||
/// The open project's file (`project.summary` → `path`).
|
||||
ProjectFile,
|
||||
/// That the project has a file at all (`project.summary` → `path`), for a queued render whose
|
||||
/// output the agent did not name: that default is derived from where the project sits.
|
||||
DefaultOutput,
|
||||
/// Every output of the queued Render Queue items (`renderQueue.list`).
|
||||
RenderOutputs,
|
||||
/// Whether the open project has a file (`project.summary` → `path`): a render queued without
|
||||
/// `output` (Render Queue, Pre-render, Create Proxy) writes beside it, or to the home folder
|
||||
/// before the first save.
|
||||
QueueDefault,
|
||||
}
|
||||
|
||||
impl Confinement {
|
||||
@@ -143,16 +175,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<Vec<Lookup>> {
|
||||
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_empty() {
|
||||
@@ -182,7 +224,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) => {
|
||||
gave_a_file |= v.is_string();
|
||||
self.check(s, access)?;
|
||||
@@ -191,9 +233,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)?;
|
||||
}
|
||||
@@ -208,7 +259,7 @@ impl Confinement {
|
||||
// that last case is certainly outside, and reading the preference here would duplicate
|
||||
// the engine's precedence, so an unsaved project is told to name its output instead.
|
||||
if QUEUES_AN_OUTPUT.contains(&id) && !gave_a_file {
|
||||
lookups.push(Lookup::DefaultOutput);
|
||||
lookups.push(Lookup::QueueDefault);
|
||||
}
|
||||
if IMPLICIT_WRITES.contains(&id) && !(given && id == "file.save") {
|
||||
lookups.push(if id == "file.save" || id == "file.incrementAndSave" { Lookup::ProjectFile } else { Lookup::RenderOutputs });
|
||||
@@ -224,12 +275,11 @@ impl Confinement {
|
||||
Some(p) => self.check(p, Access::Write).map(drop),
|
||||
None => Err(Error::BadArgs("the project has no file yet: give `path` (Save As) inside an automation write root".into())),
|
||||
},
|
||||
Lookup::DefaultOutput => match found.get("path").and_then(Value::as_str) {
|
||||
// Saved: the default goes beside the project file, inside the roots, and what it
|
||||
// writes is checked when the queue is rendered.
|
||||
// A saved project's default output sits beside it, and rendering checks it then.
|
||||
Lookup::QueueDefault => match found.get("path").and_then(Value::as_str) {
|
||||
Some(_) => Ok(()),
|
||||
None => Err(Error::BadArgs(
|
||||
"the project has no file yet, so this would queue its default output (the home folder) outside the automation write roots: give `output`, or save the project inside a write root first".into(),
|
||||
"the project has no file yet, so the default output would go to the home folder: give `output` inside an automation write root, or save the project inside one first".into(),
|
||||
)),
|
||||
},
|
||||
Lookup::RenderOutputs => {
|
||||
@@ -248,6 +298,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)
|
||||
}
|
||||
@@ -304,3 +370,115 @@ fn resolve(p: &Path) -> Option<PathBuf> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,50 @@ fn property_group_error_points_to_layer_inspection() {
|
||||
assert!(call_json(&mut s, "get_property", json!({"layer": l, "path": "geometryOptions/extrusionDepth"}))["value"].is_number());
|
||||
}
|
||||
|
||||
/// Enum properties list their values and labels, so a client can set one without guessing (#2085).
|
||||
#[test]
|
||||
fn enum_properties_list_their_options() {
|
||||
let mut s = server();
|
||||
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "probe"}}));
|
||||
call_json(&mut s, "execute_command", json!({"command": "layer.newText", "params": {"name": "T", "text": "Hello"}}));
|
||||
call_json(&mut s, "execute_command", json!({"command": "layer.addTextAnimator", "params": {"layer": "T", "properties": ["opacity"], "name": "A"}}));
|
||||
let path = "text/animators/animator/selectors/rangeSelector/advanced/shape";
|
||||
let shapes = ["Square", "Ramp Up", "Ramp Down", "Triangle", "Round", "Smooth"];
|
||||
let expected = json!(shapes.iter().enumerate().map(|(value, label)| json!({"value": value, "label": label})).collect::<Vec<_>>());
|
||||
|
||||
let p = call_json(&mut s, "get_property", json!({"layer": "T", "path": path}));
|
||||
assert_eq!(p["type"], "enum");
|
||||
assert_eq!(p["options"], expected, "{p}");
|
||||
let opacity = call_json(&mut s, "get_property", json!({"layer": "T", "path": "transform/opacity"}));
|
||||
assert!(opacity.get("options").is_none(), "{opacity}");
|
||||
|
||||
fn find<'a>(node: &'a Value, path: &str) -> Option<&'a Value> {
|
||||
if node["path"] == path {
|
||||
return Some(node);
|
||||
}
|
||||
node["children"].as_array()?.iter().find_map(|c| find(c, path))
|
||||
}
|
||||
let tree = call_json(&mut s, "get_layer", json!({"layer": "T"}));
|
||||
let node = find(&tree["properties"], path).unwrap_or_else(|| panic!("{tree}"));
|
||||
assert_eq!(node["options"], expected);
|
||||
assert!(find(&tree["properties"], "transform/opacity").unwrap()["options"].is_null());
|
||||
|
||||
let flat = call_json(&mut s, "get_layer", json!({"layer": "T", "flat": true}));
|
||||
let props = flat["properties"].as_array().unwrap();
|
||||
assert_eq!(props.iter().find(|p| p["path"] == path).unwrap()["options"], expected);
|
||||
assert!(props.iter().find(|p| p["path"] == "transform/opacity").unwrap().get("options").is_none());
|
||||
|
||||
// Path Options ▸ Path offers None and the layer's masks, as the Timeline does.
|
||||
for _ in 0..2 {
|
||||
call_json(&mut s, "execute_command", json!({"command": "mask.new", "params": {"layer": "T", "vertices": [[0, 0], [40, 0], [40, 40]], "closed": true}}));
|
||||
}
|
||||
let masks = json!([{"value": 0, "label": "None"}, {"value": 1, "label": "Mask 1"}, {"value": 2, "label": "Mask 2"}]);
|
||||
let p = call_json(&mut s, "get_property", json!({"layer": "T", "path": "text/pathOptions/path"}));
|
||||
assert_eq!(p["options"], masks, "{p}");
|
||||
let tree = call_json(&mut s, "get_layer", json!({"layer": "T"}));
|
||||
assert_eq!(find(&tree["properties"], "text/pathOptions/path").unwrap_or_else(|| panic!("{tree}"))["options"], masks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initialize_and_list_tools() {
|
||||
let mut s = server();
|
||||
@@ -491,6 +535,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 +784,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();
|
||||
@@ -1336,6 +1416,12 @@ fn automation_roots_confine_file_access() {
|
||||
assert!(err, "{name} {args} should be refused: {c:?}");
|
||||
c[0]["text"].as_str().unwrap_or_default().to_string()
|
||||
};
|
||||
// #2479: an unsaved project's queue item without `output` would write to the home folder.
|
||||
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "Unsaved", "width": 64, "height": 64}}));
|
||||
let msg = denied(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"comp": "Unsaved"}}));
|
||||
assert!(msg.contains("home folder") && msg.contains("give `output`"), "{msg}");
|
||||
call_json(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"comp": "Unsaved", "output": p(&write, "unsaved.mp4")}}));
|
||||
call_json(&mut s, "execute_command", json!({"command": "renderQueue.remove"}));
|
||||
// Reading: inside a read root, not outside.
|
||||
call_json(&mut s, "open_project", json!({"path": p(&read, "source.ecproj")}));
|
||||
let msg = denied(&mut s, "open_project", json!({"path": p(&outside, "free.ecproj")}));
|
||||
@@ -1376,6 +1462,18 @@ 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);
|
||||
@@ -1433,3 +1531,87 @@ fn automation_roots_refuse_a_queued_output_from_an_unsaved_project() {
|
||||
assert!(out.starts_with(&*write.to_string_lossy()), "{queue}");
|
||||
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!(check(&write.join("scene.py")).unwrap().is_empty());
|
||||
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();
|
||||
}
|
||||
@@ -175,16 +175,21 @@ fn params_of(v: Option<&Value>) -> Result<Value> {
|
||||
|
||||
// ------------------------------------------------------------------ engine tools
|
||||
|
||||
/// `list_commands` tool: the engine command list, minus empty fields; with `compact`, only `id` and `label`.
|
||||
fn list_commands(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
let schemas = get(a, "schemas").and_then(Value::as_bool).unwrap_or(false).then_some(json!(true));
|
||||
let compact = get(a, "compact").and_then(Value::as_bool).unwrap_or(false);
|
||||
let schemas = (!compact && get(a, "schemas").and_then(Value::as_bool).unwrap_or(false)).then_some(json!(true));
|
||||
let r = b.exec("command.list", obj(&[("filter", get(a, "filter")), ("enabledOnly", get(a, "enabled_only")), ("schemas", schemas.as_ref())]))?;
|
||||
// Drop empty fields to keep the listing compact.
|
||||
let list: Vec<Value> = r
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|mut c| {
|
||||
if compact {
|
||||
return json!({"id": c["id"], "label": c["label"]});
|
||||
}
|
||||
// Drop empty fields.
|
||||
if let Some(o) = c.as_object_mut() {
|
||||
o.retain(|_, v| !(v.is_null() || v.as_array().is_some_and(Vec::is_empty) || v.as_str() == Some("{}")));
|
||||
}
|
||||
@@ -205,8 +210,13 @@ fn describe_command(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
json_reply(b.exec("command.describe", json!({"command": id}))?)
|
||||
}
|
||||
|
||||
/// `list_effects` tool: the effect registry; with `compact`, only `id`, `name` and `category`.
|
||||
fn list_effects(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
json_reply(b.exec("effect.list", obj(&[("filter", get(a, "filter"))]))?)
|
||||
let mut r = b.exec("effect.list", obj(&[("filter", get(a, "filter"))]))?;
|
||||
if get(a, "compact").and_then(Value::as_bool).unwrap_or(false) {
|
||||
r = r.as_array().into_iter().flatten().map(|e| json!({"id": e["id"], "name": e["name"], "category": e["category"]})).collect();
|
||||
}
|
||||
json_reply(r)
|
||||
}
|
||||
|
||||
fn list_fonts(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
@@ -275,7 +285,7 @@ fn get_comp(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
json_reply(b.exec("comp.info", obj(&[("comp", get(a, "comp"))]))?)
|
||||
}
|
||||
|
||||
/// Flatten a `layer.tree` property tree into `[{path, type, value, keys?, expression?}]`.
|
||||
/// Flatten a `layer.tree` property tree into `[{path, type, value, keys?, expression?, options?}]`.
|
||||
fn flatten(node: &Value, out: &mut Vec<Value>) {
|
||||
// A group cut off by `depth` stays visible (instead of vanishing from the flat list).
|
||||
if node.get("truncated").and_then(Value::as_bool) == Some(true) {
|
||||
@@ -292,6 +302,9 @@ fn flatten(node: &Value, out: &mut Vec<Value>) {
|
||||
if let Some(e) = node.get("expression") {
|
||||
o["expression"] = e.clone();
|
||||
}
|
||||
if let Some(opts) = node.get("options") {
|
||||
o["options"] = opts.clone();
|
||||
}
|
||||
out.push(o);
|
||||
}
|
||||
}
|
||||
@@ -542,14 +555,15 @@ fn control(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
static TOOLS: &[ToolDef] = &[
|
||||
ToolDef {
|
||||
name: "list_commands",
|
||||
description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`).",
|
||||
description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`); `compact: true` lists only ids and labels.",
|
||||
bridge_only: false,
|
||||
schema: || {
|
||||
schema(
|
||||
json!({
|
||||
"filter": {"type": "string", "description": "Only commands whose id or label contains this text (case-insensitive)."},
|
||||
"enabled_only": {"type": "boolean", "description": "Only commands that can run right now."},
|
||||
"schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."}
|
||||
"schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."},
|
||||
"compact": {"type": "boolean", "description": "Return only each command's `id` and `label` (overrides `schemas`)."}
|
||||
}),
|
||||
&[],
|
||||
)
|
||||
@@ -615,7 +629,7 @@ static TOOLS: &[ToolDef] = &[
|
||||
},
|
||||
ToolDef {
|
||||
name: "get_layer",
|
||||
description: "A layer's property tree (transform, masks, effects, text, shape contents, ...): leaf properties have a `path` usable with get_property/set_property/add_keyframe, plus uid, match id, name, type, value at `time`, keyframes and expression. Groups contain children; inspect them here, then select a leaf path. `flat: true` returns just a list of {path, type, value, keys, expression} (compact).",
|
||||
description: "A layer's property tree (transform, masks, effects, text, shape contents, ...): leaf properties have a `path` usable with get_property/set_property/add_keyframe, plus uid, match id, name, type, value at `time`, keyframes and expression; popup (enum) properties add `options` ([{value, label}]). Groups contain children; inspect them here, then select a leaf path. `flat: true` returns just a list of {path, type, value, keys, expression, options} (compact).",
|
||||
bridge_only: false,
|
||||
schema: || {
|
||||
schema(
|
||||
@@ -632,7 +646,7 @@ static TOOLS: &[ToolDef] = &[
|
||||
},
|
||||
ToolDef {
|
||||
name: "get_property",
|
||||
description: "Read one property: type, value at `time` (comp seconds; default the current time), whether it is animated, its keyframes (time, value, in/out interpolation) and expression.",
|
||||
description: "Read one property: type, value at `time` (comp seconds; default the current time), whether it is animated, its keyframes (time, value, in/out interpolation) and expression. Popup (enum) properties add `options`: [{value, label}], the values set_property takes.",
|
||||
bridge_only: false,
|
||||
schema: || {
|
||||
schema(
|
||||
@@ -696,9 +710,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 +933,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`)."}
|
||||
}),
|
||||
&[],
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+228
-54
@@ -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<i32>,
|
||||
nonzero: bool,
|
||||
@@ -107,6 +122,38 @@ struct TxResult {
|
||||
bits: f64,
|
||||
}
|
||||
|
||||
/// The output and scratch buffers of one reconstruction, each `n * n` entries long.
|
||||
struct ReconBuffers<'a> {
|
||||
deq: &'a mut [i32],
|
||||
resid: &'a mut [i32],
|
||||
recon: &'a mut [u16],
|
||||
}
|
||||
|
||||
/// Outcome of trying one transform block; the levels and the reconstruction stay in `TxScratch`.
|
||||
#[derive(Clone, Copy)]
|
||||
struct TxEval {
|
||||
nonzero: bool,
|
||||
dist: f64,
|
||||
bits: f64,
|
||||
}
|
||||
|
||||
/// Buffers of the transform path, sized for the largest coded transform block (32x32). Only the
|
||||
/// first `n * n` entries are used; they are reused across candidates instead of being allocated.
|
||||
struct TxScratch {
|
||||
res: [i32; 1024],
|
||||
coef: [f32; 1024],
|
||||
levels: [i32; 1024],
|
||||
deq: [i32; 1024],
|
||||
resid: [i32; 1024],
|
||||
recon: [u16; 1024],
|
||||
}
|
||||
|
||||
impl Default for TxScratch {
|
||||
fn default() -> Self {
|
||||
TxScratch { res: [0; 1024], coef: [0.0; 1024], levels: [0; 1024], deq: [0; 1024], resid: [0; 1024], recon: [0; 1024] }
|
||||
}
|
||||
}
|
||||
|
||||
/// Saved encoder state of a block area (reconstruction, BlockDecoded flags).
|
||||
struct Snapshot {
|
||||
planes: [Vec<u16>; 3],
|
||||
@@ -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<u16>; 3],
|
||||
/// Transform buffers, taken out while a block is being tried (see `code_tx`).
|
||||
scratch: Option<Box<TxScratch>>,
|
||||
}
|
||||
|
||||
const MODE_BITS_INTRA: f64 = 3.0;
|
||||
/// 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<Vec<u16>>)> = None;
|
||||
let mut best: Option<(f64, usize, usize, TxEval, bool)> = None;
|
||||
let mut sc = self.scratch.take().unwrap_or_default();
|
||||
for &(_, mode) in ranked.iter().take(3) {
|
||||
predict_intra(&ed, &e, mode, &mut pred);
|
||||
let mut types = vec![DCT_DCT];
|
||||
let mut types = [DCT_DCT; 2];
|
||||
let mut n_types = 1;
|
||||
let alt = MODE_TO_TXFM[mode] as usize;
|
||||
if alt != DCT_DCT && tx_in_set(tx_set(tx_y, false), false, alt) {
|
||||
types.push(alt);
|
||||
types[1] = alt;
|
||||
n_types = 2;
|
||||
}
|
||||
for &t in &types {
|
||||
let (res, recon) = self.try_tx(0, c * 4, r * 4, log2, t, &pred[..n * n], true);
|
||||
for &t in &types[..n_types] {
|
||||
let (res, has_recon) = self.try_tx(&mut sc, TransformBlock { plane: 0, x: c * 4, y: r * 4, log2, tx_type: t, intra: true }, &pred[..n * n]);
|
||||
let type_bits = if !res.nonzero || tx_set(tx_y, false) == TX_SET_DCTONLY {
|
||||
0.0
|
||||
} else if t == DCT_DCT {
|
||||
@@ -374,18 +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<Vec<u16>>) {
|
||||
/// Transform and quantise one block without writing it. The levels are left in `sc.levels`;
|
||||
/// the flag tells whether `sc.recon` holds the reconstruction (false: it is the prediction).
|
||||
fn try_tx(&self, sc: &mut TxScratch, block: TransformBlock, pred: &[u16]) -> (TxEval, bool) {
|
||||
let TransformBlock { plane: p, x, y, log2, tx_type, intra } = block;
|
||||
let n = 1usize << log2;
|
||||
let nn = n * n;
|
||||
let tx_sz = tx_size_for(log2);
|
||||
let bd = self.bd;
|
||||
let src = &self.src[p];
|
||||
let mut res = vec![0i32; n * n];
|
||||
let TxScratch { res, coef, levels, deq, resid, recon } = sc;
|
||||
let (res, coef, levels) = (&mut res[..nn], &mut coef[..nn], &mut levels[..nn]);
|
||||
for i in 0..n {
|
||||
let row = &src.row(y + i)[x..x + n];
|
||||
for j in 0..n {
|
||||
res[i * n + j] = row[j] as i32 - pred[i * n + j] as i32;
|
||||
}
|
||||
}
|
||||
let mut coef = vec![0f32; n * n];
|
||||
forward_2d(&res, &mut coef, n, tx_type);
|
||||
forward_2d(res, coef, n, tx_type);
|
||||
// dead-zone quantiser: rounding offset below one half
|
||||
let rnd = if intra { 0.38f32 } else { 0.30f32 };
|
||||
let mut levels = vec![0i32; n * n];
|
||||
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<u16> {
|
||||
/// Dequantisation (7.12.3) and the inverse transform, added to the prediction; the result is
|
||||
/// written to `recon`. `deq` and `resid` are scratch buffers of `n * n` entries.
|
||||
fn reconstruct(&self, levels: &[i32], bufs: ReconBuffers, tx_sz: usize, tx_type: usize, pred: &[u16], bd: u32) {
|
||||
let ReconBuffers { deq, resid, recon } = bufs;
|
||||
let denom: i64 = if tx_sz == TX_32X32 { 2 } else { 1 };
|
||||
let lim = 1i64 << (7 + bd);
|
||||
let mut deq = vec![0i32; n * n];
|
||||
deq.fill(0);
|
||||
for (k, (&l, d)) in levels.iter().zip(deq.iter_mut()).enumerate() {
|
||||
if l == 0 {
|
||||
continue;
|
||||
@@ -834,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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+150
-25
@@ -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<u8> {
|
||||
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<u8>, 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<u8>, 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<u8>, 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+99
-16
@@ -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<u16> {
|
||||
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<u16>; 3] {
|
||||
let k = &SUBPEL_FILTERS[EIGHTTAP][8];
|
||||
let max = (1i32 << bit_depth) - 1;
|
||||
let (lx, ly) = (w as i32 - 1, h as i32 - 1);
|
||||
// horizontal pass at phase 8: Round2( sum, 3 )
|
||||
let mut hp = vec![0i32; w * h];
|
||||
for y in 0..h {
|
||||
let row = &refp.row(y)[..w];
|
||||
let out = &mut hp[y * w..y * w + w];
|
||||
let tap = |x: i32| -> i32 {
|
||||
let mut s = 0i32;
|
||||
for t in 0..8 {
|
||||
s += k[t] as i32 * row[(x + t as i32 - 3).clamp(0, lx) as usize] as i32;
|
||||
}
|
||||
round2(s, 3)
|
||||
};
|
||||
if w >= 8 {
|
||||
for x in 0..3 {
|
||||
out[x] = tap(x as i32);
|
||||
}
|
||||
for (x, o) in out.iter_mut().enumerate().take(w - 4).skip(3) {
|
||||
let seg = &row[x - 3..x + 5];
|
||||
let mut s = 0i32;
|
||||
for t in 0..8 {
|
||||
s += k[t] as i32 * seg[t] as i32;
|
||||
}
|
||||
*o = round2(s, 3);
|
||||
}
|
||||
for x in w - 4..w {
|
||||
out[x] = tap(x as i32);
|
||||
}
|
||||
} else {
|
||||
for (x, o) in out.iter_mut().enumerate() {
|
||||
*o = tap(x as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
let p80: Vec<u16> = hp.iter().map(|&v| round2(v, 4).clamp(0, max) as u16).collect();
|
||||
let mut p08 = vec![0u16; w * h];
|
||||
let mut p88 = vec![0u16; w * h];
|
||||
for y in 0..h {
|
||||
let rows: [usize; 8] = std::array::from_fn(|t| (y as i32 + t as i32 - 3).clamp(0, ly) as usize);
|
||||
let (o08, o88) = (&mut p08[y * w..y * w + w], &mut p88[y * w..y * w + w]);
|
||||
for x in 0..w {
|
||||
let (mut a, mut b) = (0i32, 0i32);
|
||||
for t in 0..8 {
|
||||
a += k[t] as i32 * refp.data[rows[t] * refp.stride + x] as i32;
|
||||
b += k[t] as i32 * hp[rows[t] * w + x];
|
||||
}
|
||||
o08[x] = round2(a, 7).clamp(0, max) as u16;
|
||||
o88[x] = round2(b, 11).clamp(0, max) as u16;
|
||||
}
|
||||
}
|
||||
[p80, p08, p88]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -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() {
|
||||
|
||||
+100
-10
@@ -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<const N: u32>(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<const N: u32>(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<const N: u32>(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<const LOG2: u32>(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<const N: usize, const NN: usize>(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.
|
||||
|
||||
+41
-23
@@ -8,7 +8,15 @@ use crate::cdf::Cdfs;
|
||||
use crate::decide::{FrameGeom, Leaf, Node, intra_uv_tx_type, scan_for, tx_in_set, tx_set, tx_size_for};
|
||||
use crate::loopfilter::LfInfo;
|
||||
use crate::symbol::SymbolWriter;
|
||||
use crate::tables::*;
|
||||
use crate::tables::{
|
||||
ADST_ADST, ADST_DCT, ALTREF_FRAME, ALTREF2_FRAME, BLOCK_8X8, BLOCK_64X64, BR_CDF_SIZE, BWDREF_FRAME, CLASS0_SIZE, COEFF_BASE_CTX_OFFSET, COEFF_BASE_RANGE,
|
||||
D67_PRED, DC_PRED, DCT_ADST, DCT_DCT, DEFAULT_INTRA_FRAME_Y_MODE_CDF, GLOBALMV, GOLDEN_FRAME, INTRA_FRAME, INTRA_MODE_CONTEXT, LAST_FRAME, LAST2_FRAME,
|
||||
LAST3_FRAME, MAG_REF_OFFSET_WITH_TX_CLASS, MAX_ANGLE_DELTA, MAX_REF_MV_STACK_SIZE, MI_HEIGHT_LOG2, MI_WIDTH_LOG2, MV_BORDER, MV_CLASS_0, NEARESTMV, NEARMV,
|
||||
NEWMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, NUM_BASE_LEVELS, PARTITION_HORZ, PARTITION_HORZ_4, PARTITION_HORZ_A, PARTITION_HORZ_B, PARTITION_NONE,
|
||||
PARTITION_SPLIT, PARTITION_SUBSIZE, PARTITION_VERT, PARTITION_VERT_4, PARTITION_VERT_A, PARTITION_VERT_B, REF_CAT_LEVEL, SIG_REF_DIFF_OFFSET, SIZE_GROUP,
|
||||
SUBSAMPLED_SIZE, TX_32X32, TX_CLASS_2D, TX_HEIGHT, TX_HEIGHT_LOG2, TX_SIZE_SQR, TX_SIZE_SQR_UP, TX_TYPE_INTER_INV_SET3, TX_TYPE_INTRA_INV_SET2, TX_WIDTH,
|
||||
TX_WIDTH_LOG2, V_PRED,
|
||||
};
|
||||
|
||||
type MvP = [i32; 2];
|
||||
|
||||
@@ -62,6 +70,8 @@ pub(crate) struct TileWriter<'a> {
|
||||
left_dc: [Vec<u8>; 3],
|
||||
/// LoopfilterTxSizes per plane (row stride mi_cols + 32).
|
||||
lf_tx: [Vec<u8>; 3],
|
||||
/// Scratch for the levels coded so far in `coeffs`.
|
||||
quant: Vec<i32>,
|
||||
// current block
|
||||
r: usize,
|
||||
c: usize,
|
||||
@@ -114,6 +124,7 @@ impl<'a> TileWriter<'a> {
|
||||
left_level: std::array::from_fn(|_| vec![0; g.mi_rows + 64]),
|
||||
left_dc: std::array::from_fn(|_| vec![0; g.mi_rows + 64]),
|
||||
lf_tx: std::array::from_fn(|_| vec![0; (g.mi_cols + 32) * (g.mi_rows + 32)]),
|
||||
quant: Vec::new(),
|
||||
r: 0,
|
||||
c: 0,
|
||||
bsize: 0,
|
||||
@@ -181,10 +192,10 @@ impl<'a> TileWriter<'a> {
|
||||
self.sw.symbol(partition, cdf);
|
||||
} else if has_cols || has_rows {
|
||||
assert!(split, "blocks crossing the frame edge are split");
|
||||
let pcdf: Vec<u16> = match bsl {
|
||||
2 => self.cdf.partition_w16[ctx].to_vec(),
|
||||
3 => self.cdf.partition_w32[ctx].to_vec(),
|
||||
_ => self.cdf.partition_w64[ctx].to_vec(),
|
||||
let pcdf: &[u16] = match bsl {
|
||||
2 => &self.cdf.partition_w16[ctx],
|
||||
3 => &self.cdf.partition_w32[ctx],
|
||||
_ => &self.cdf.partition_w64[ctx],
|
||||
};
|
||||
let pr = |k: usize| pcdf[k] as i32 - if k == 0 { 0 } else { pcdf[k - 1] as i32 };
|
||||
let psum = if has_cols {
|
||||
@@ -455,23 +466,7 @@ impl<'a> TileWriter<'a> {
|
||||
}
|
||||
} else {
|
||||
if plane == 0 {
|
||||
// transform_type( )
|
||||
let set = tx_set(tx_sz, b.is_inter);
|
||||
let t = b.tx_type_y;
|
||||
if set > 0 && self.qidx > 0 {
|
||||
let sqr = TX_SIZE_SQR[tx_sz] as usize;
|
||||
if b.is_inter {
|
||||
// The decision only picks types of this set (DCT_DCT otherwise).
|
||||
let s = TX_TYPE_INTER_INV_SET3.iter().position(|&v| v as usize == t).unwrap_or_default();
|
||||
self.sw.symbol(s, &mut self.cdf.inter_tx_type_set3[sqr]);
|
||||
} else {
|
||||
let s = TX_TYPE_INTRA_INV_SET2.iter().position(|&v| v as usize == t).unwrap_or_default();
|
||||
self.sw.symbol(s, &mut self.cdf.intra_tx_type_set2[sqr][b.y_mode]);
|
||||
}
|
||||
} else {
|
||||
assert_eq!(t, DCT_DCT);
|
||||
}
|
||||
self.set_tx_types(x4, y4, tx_sz, t);
|
||||
self.write_transform_type(x4, y4, tx_sz, b);
|
||||
}
|
||||
let plane_tx_type = self.compute_tx_type(plane, tx_sz, x4, y4, b);
|
||||
debug_assert!(plane_tx_type == DCT_DCT || matches!(plane_tx_type, ADST_DCT | DCT_ADST | ADST_ADST));
|
||||
@@ -501,7 +496,9 @@ impl<'a> TileWriter<'a> {
|
||||
let height = TX_HEIGHT[tx_sz] as usize;
|
||||
let width = 1usize << bwl;
|
||||
// Quant[] as the decoder sees it while parsing: the levels coded so far (capped)
|
||||
let mut quant = vec![0i32; width * height];
|
||||
let mut quant = std::mem::take(&mut self.quant);
|
||||
quant.clear();
|
||||
quant.resize(width * height, 0);
|
||||
for c in (0..eob).rev() {
|
||||
let pos = scan[c] as usize;
|
||||
let a = levels[pos].unsigned_abs().min(15) as i32;
|
||||
@@ -526,6 +523,7 @@ impl<'a> TileWriter<'a> {
|
||||
}
|
||||
quant[pos] = a;
|
||||
}
|
||||
self.quant = quant;
|
||||
for c in 0..eob {
|
||||
let pos = scan[c] as usize;
|
||||
let v = levels[pos];
|
||||
@@ -571,6 +569,26 @@ impl<'a> TileWriter<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn write_transform_type(&mut self, x4: usize, y4: usize, tx_sz: usize, b: &Leaf) {
|
||||
// transform_type( )
|
||||
let set = tx_set(tx_sz, b.is_inter);
|
||||
let t = b.tx_type_y;
|
||||
if set > 0 && self.qidx > 0 {
|
||||
let sqr = TX_SIZE_SQR[tx_sz] as usize;
|
||||
if b.is_inter {
|
||||
// The decision only picks types of this set (DCT_DCT otherwise).
|
||||
let s = TX_TYPE_INTER_INV_SET3.iter().position(|&v| v as usize == t).unwrap_or_default();
|
||||
self.sw.symbol(s, &mut self.cdf.inter_tx_type_set3[sqr]);
|
||||
} else {
|
||||
let s = TX_TYPE_INTRA_INV_SET2.iter().position(|&v| v as usize == t).unwrap_or_default();
|
||||
self.sw.symbol(s, &mut self.cdf.intra_tx_type_set2[sqr][b.y_mode]);
|
||||
}
|
||||
} else {
|
||||
assert_eq!(t, DCT_DCT);
|
||||
}
|
||||
self.set_tx_types(x4, y4, tx_sz, t);
|
||||
}
|
||||
|
||||
fn set_tx_types(&mut self, x4: usize, y4: usize, tx_sz: usize, t: usize) {
|
||||
for i in 0..(TX_WIDTH[tx_sz] as usize >> 2) {
|
||||
for j in 0..(TX_HEIGHT[tx_sz] as usize >> 2) {
|
||||
|
||||
@@ -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<Pic> = (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();
|
||||
|
||||
+17
-1
@@ -97,7 +97,9 @@ pub fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
|
||||
return (0.0, 0.0, l);
|
||||
}
|
||||
let d = max - min;
|
||||
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
|
||||
let denom = if l > 0.5 { 2.0 - max - min } else { max + min };
|
||||
// Over-range (HDR) or negative inputs can make the denominator zero or negative.
|
||||
let s = if denom > 0.0 { d / denom } else { 1.0 };
|
||||
let h = if max == r {
|
||||
(((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0).rem_euclid(1.0)
|
||||
} else if max == g {
|
||||
@@ -262,6 +264,20 @@ impl Label {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hsl_round_trip_is_finite_for_out_of_range_colours() {
|
||||
for (r, g, b) in [(1.5, 0.5, 0.5), (2.0, 0.1, 0.3), (-0.5, 0.2, 0.9)] {
|
||||
let (h, s, l) = rgb_to_hsl(r, g, b);
|
||||
assert!(h.is_finite() && s.is_finite() && l.is_finite(), "{h} {s} {l}");
|
||||
let (r2, g2, b2) = hsl_to_rgb(h, s, l);
|
||||
assert!(r2.is_finite() && g2.is_finite() && b2.is_finite(), "{r2} {g2} {b2}");
|
||||
}
|
||||
// A 32 bpc colour whose denominator stays positive still round-trips exactly.
|
||||
let (h, s, l) = rgb_to_hsl(1.2, 0.5, 0.5);
|
||||
let (r, g, b) = hsl_to_rgb(h, s, l);
|
||||
assert!((r - 1.2).abs() < 1e-5 && (g - 0.5).abs() < 1e-5 && (b - 0.5).abs() < 1e-5, "{r} {g} {b}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hsv_to_rgb_tiny_negative_hue_is_red() {
|
||||
let (r, g, b) = hsv_to_rgb(-1e-9, 1.0, 1.0);
|
||||
|
||||
@@ -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<T> {
|
||||
json: Box<str>,
|
||||
key: u64,
|
||||
value: Arc<T>,
|
||||
}
|
||||
|
||||
pub(crate) struct AnalysisCache<T> {
|
||||
entries: OnceLock<Mutex<Vec<Entry<T>>>>,
|
||||
}
|
||||
|
||||
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<T> AnalysisCache<T> {
|
||||
pub(crate) const fn new() -> Self {
|
||||
Self { entries: OnceLock::new() }
|
||||
}
|
||||
|
||||
pub(crate) fn get(&self, json: &str, parse: impl FnOnce(&str) -> Option<T>) -> Option<(u64, Arc<T>)> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<T> = Mutex<HashMap<u64, Arc<T>>>;
|
||||
|
||||
fn cached<T>(c: &'static OnceLock<Cache<T>>, json: &str, parse: impl Fn(&str) -> Option<T>) -> Option<Arc<T>> {
|
||||
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<Arc<CameraTracks>> {
|
||||
static C: OnceLock<Cache<CameraTracks>> = OnceLock::new();
|
||||
static C: AnalysisCache<CameraTracks> = 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<Arc<CameraSolve>> {
|
||||
static C: OnceLock<Cache<CameraSolve>> = OnceLock::new();
|
||||
static C: AnalysisCache<CameraSolve> = 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;
|
||||
|
||||
@@ -92,8 +92,8 @@ const CPU_ONLY_CONTROLS: &[(&str, &[&str])] = &[
|
||||
];
|
||||
|
||||
/// Whether the GPU kernel of effect `id` covers the instance's current settings (see
|
||||
/// `CPU_ONLY_CONTROLS`; plus Cell Pattern's HQ variants and Turbulent Displace's locked
|
||||
/// pinning).
|
||||
/// `CPU_ONLY_CONTROLS`; plus Cell Pattern's HQ variants, Turbulent Displace's locked
|
||||
/// pinning and CC Particle World's motion trails and textured squares).
|
||||
pub fn gpu_supported(id: &str, ctx: &crate::EffectCtx) -> bool {
|
||||
match id {
|
||||
"ec.generate.cellpattern" if (6..=10).contains(&ctx.params.e("cellPattern")) => return false,
|
||||
@@ -101,6 +101,8 @@ pub fn gpu_supported(id: &str, ctx: &crate::EffectCtx) -> bool {
|
||||
// #1195: CC Particle World's motion trails (the averaged shutter samples) render on the
|
||||
// CPU kernel until the GPU particle pass supports them.
|
||||
"ec.sim.ccparticleworld" if ctx.env.shutter.filter(|(a, _, _)| *a > 0.0).is_some() => return false,
|
||||
// #1465: Textured Square's Texture Layer is drawn on the CPU only.
|
||||
"ec.sim.ccparticleworld" if crate::sim::particle_world_textured(ctx) => return false,
|
||||
_ => {}
|
||||
}
|
||||
let Some((_, controls)) = CPU_ONLY_CONTROLS.iter().find(|(e, _)| *e == id) else { return true };
|
||||
@@ -182,6 +184,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::<u32>().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;
|
||||
@@ -210,6 +226,10 @@ mod tests {
|
||||
assert!(!super::gpu_supported("ec.sim.ccparticleworld", &ctx));
|
||||
}
|
||||
assert!(ok("ec.sim.ccparticleworld", &[]));
|
||||
// #1465: the Texture Layer keeps CC Particle World on the CPU only when Textured Square draws it.
|
||||
let tex = ("particle/texture/textureLayer", Value::Layer(Some(1)));
|
||||
assert!(ok("ec.sim.ccparticleworld", &[tex.clone(), ("particle/particleType", Value::Enum(2))]));
|
||||
assert!(!ok("ec.sim.ccparticleworld", &[tex, ("particle/particleType", Value::Enum(8))]));
|
||||
assert!(ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(3))]));
|
||||
assert!(!ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(9))]));
|
||||
assert!(!ok("ec.distort.turbulentdisplace", &[("pinning", Value::Enum(9))]));
|
||||
|
||||
@@ -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<EffectSpec> {
|
||||
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<EffectSpec> {
|
||||
],
|
||||
identifier,
|
||||
),
|
||||
spec("ec.3d.instance3d", "3D Layer Instance", vec![p("layer", "Layer", Value::Layer(None), ParamUi::Layer)], instance_3d),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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<LayerPixels> {
|
||||
(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<Vec<f32>> {
|
||||
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);
|
||||
}
|
||||
+217
-34
@@ -275,27 +275,70 @@ pub fn transform_shutter(ctx: &EffectCtx) -> Option<(f64, f64, u32)> {
|
||||
(angle > 0.0).then_some((angle, phase, n.clamp(2, 64)))
|
||||
}
|
||||
|
||||
fn transform_matrix(pr: &crate::Params, b: &Buf) -> Mat3 {
|
||||
let anchor = b.to_px(pr.v2("anchor"));
|
||||
let pos = b.to_px(pr.v2("position"));
|
||||
/// The Transform effect's matrix in buffer pixels (`to_px` maps layer pixels into the buffer),
|
||||
/// as After Effects 2026 draws it (cases 723, 819, 820): `position + K · R · S · (p − anchor)`
|
||||
/// with the skew `K = rot(−axis) · shearY(tan skew) · rot(axis)` applied after the rotation.
|
||||
pub fn transform_matrix_px(pr: &crate::Params, to_px: impl Fn([f64; 2]) -> (f64, f64)) -> Mat3 {
|
||||
let anchor = to_px(pr.v2("anchor"));
|
||||
let pos = to_px(pr.v2("position"));
|
||||
let sh = pr.f("scaleHeight");
|
||||
let sw = if pr.b("uniform") { sh } else { pr.f("scaleWidth") };
|
||||
let axis = pr.f("skewAxis");
|
||||
let k = pr.f("skew").clamp(-85.0, 85.0).to_radians().tan();
|
||||
let k = if k.is_finite() { k } else { 0.0 };
|
||||
let skew = Mat3::rotate_deg(-axis) * Mat3([[1.0, 0.0, 0.0], [k, 1.0, 0.0], [0.0, 0.0, 1.0]]) * Mat3::rotate_deg(axis);
|
||||
Mat3::translate(vec2(pos.0, pos.1))
|
||||
* skew
|
||||
* Mat3::rotate_deg(pr.f("rotation"))
|
||||
* Mat3::skew_deg(pr.f("skew"), pr.f("skewAxis"))
|
||||
* Mat3::scale(vec2(sw / 100.0, sh / 100.0))
|
||||
* Mat3::translate(vec2(-anchor.0, -anchor.1))
|
||||
}
|
||||
|
||||
fn transform_matrix(pr: &crate::Params, b: &Buf) -> Mat3 {
|
||||
transform_matrix_px(pr, |p| b.to_px(p))
|
||||
}
|
||||
|
||||
/// The transparent margins (left, top, right, bottom) a `w` x `h` buffer needs so its warp by
|
||||
/// any of `ms` stays on it: After Effects draws Transform's result outside the layer's bounds
|
||||
/// (case 723). Each side grows by its own overshoot; the grown buffer is capped at 16 384 px a
|
||||
/// side and 16 Mpx in all (the margins shrink in proportion), so a layer moved far away costs
|
||||
/// a bounded allocation.
|
||||
pub fn transform_grow(ms: &[Mat3], w: u32, h: u32) -> [u32; 4] {
|
||||
const SIDE: f64 = 16_384.0;
|
||||
const AREA: f64 = 16_777_216.0;
|
||||
let (wf, hf) = (w as f64, h as f64);
|
||||
let mut m = [0.0f64; 4];
|
||||
for mat in ms {
|
||||
for (x, y) in [(0.0, 0.0), (wf, 0.0), (0.0, hf), (wf, hf)] {
|
||||
let q = mat.apply(vec2(x, y));
|
||||
if q.x.is_finite() && q.y.is_finite() {
|
||||
m = [m[0].max(-q.x), m[1].max(-q.y), m[2].max(q.x - wf), m[3].max(q.y - hf)];
|
||||
}
|
||||
}
|
||||
}
|
||||
let m = m.map(|v| v.ceil().max(0.0));
|
||||
let fit = |len: f64, a: f64, b: f64| -> f64 {
|
||||
let room = (SIDE - len).max(0.0);
|
||||
if a + b > room && a + b > 0.0 { room / (a + b) } else { 1.0 }
|
||||
};
|
||||
let kx = fit(wf, m[0], m[2]);
|
||||
let ky = fit(hf, m[1], m[3]);
|
||||
let area = |k: f64| (wf + (m[0] + m[2]) * kx * k) * (hf + (m[1] + m[3]) * ky * k);
|
||||
let k = if area(1.0) > AREA && area(1.0) > wf * hf {
|
||||
// Shrink both margins by the same factor until the area fits.
|
||||
let (mut lo, mut hi) = (0.0f64, 1.0f64);
|
||||
for _ in 0..40 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
if area(mid) > AREA { hi = mid } else { lo = mid }
|
||||
}
|
||||
lo
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
[m[0] * kx * k, m[1] * ky * k, m[2] * kx * k, m[3] * ky * k].map(|v| v.floor().clamp(0.0, SIDE) as u32)
|
||||
}
|
||||
|
||||
fn transform(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let anchor = b.to_px(ctx.params.v2("anchor"));
|
||||
let pos = b.to_px(ctx.params.v2("position"));
|
||||
let uniform = ctx.params.b("uniform");
|
||||
let sh = ctx.params.f("scaleHeight");
|
||||
let sw = if uniform { sh } else { ctx.params.f("scaleWidth") };
|
||||
let skew = ctx.params.f("skew");
|
||||
let skew_axis = ctx.params.f("skewAxis");
|
||||
let rot = ctx.params.f("rotation");
|
||||
let opacity = ctx.params.f("opacity") as f32 / 100.0;
|
||||
let sampling = if ctx.params.e("sampling") == 1 { effectcraft_raster::Sampling::Bicubic } else { effectcraft_raster::Sampling::Bilinear };
|
||||
// Motion blur: the transform at Samples instants across the shutter, averaged (the
|
||||
@@ -304,33 +347,35 @@ fn transform(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
&& let Some(host) = ctx.env.host
|
||||
{
|
||||
let fd = 1.0 / ctx.fps();
|
||||
let mut acc = Image::new(b.img.width, b.img.height);
|
||||
let mut count = 0.0f32;
|
||||
for k in 0..n {
|
||||
let t = ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd;
|
||||
let Some(pr) = host.params_at(t) else { continue };
|
||||
let mut one = Image::new(b.img.width, b.img.height);
|
||||
let op = pr.f("opacity") as f32 / 100.0;
|
||||
effectcraft_raster::composite_warp(
|
||||
&mut one,
|
||||
&b.img,
|
||||
&transform_matrix(&pr, &b),
|
||||
&effectcraft_raster::WarpOpts { opacity: op, sampling, ..Default::default() },
|
||||
);
|
||||
acc.data.iter_mut().zip(&one.data).for_each(|(a, o)| (0..4).for_each(|c| a[c] += o[c]));
|
||||
count += 1.0;
|
||||
}
|
||||
if count > 0.0 {
|
||||
let samples: Vec<crate::Params> = (0..n).filter_map(|k| host.params_at(ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd)).collect();
|
||||
if !samples.is_empty() {
|
||||
if !ctx.adjustment {
|
||||
let ms: Vec<Mat3> = samples.iter().map(|pr| transform_matrix(pr, &b)).collect();
|
||||
b.pad_sides(transform_grow(&ms, b.img.width, b.img.height));
|
||||
}
|
||||
let mut acc = Image::new(b.img.width, b.img.height);
|
||||
for pr in &samples {
|
||||
let mut one = Image::new(b.img.width, b.img.height);
|
||||
let op = pr.f("opacity") as f32 / 100.0;
|
||||
effectcraft_raster::composite_warp(
|
||||
&mut one,
|
||||
&b.img,
|
||||
&transform_matrix(pr, &b),
|
||||
&effectcraft_raster::WarpOpts { opacity: op, sampling, ..Default::default() },
|
||||
);
|
||||
acc.data.iter_mut().zip(&one.data).for_each(|(a, o)| (0..4).for_each(|c| a[c] += o[c]));
|
||||
}
|
||||
let count = samples.len() as f32;
|
||||
acc.data.iter_mut().for_each(|p| p.iter_mut().for_each(|v| *v /= count));
|
||||
b.img = acc;
|
||||
return b;
|
||||
}
|
||||
}
|
||||
let m = Mat3::translate(vec2(pos.0, pos.1))
|
||||
* Mat3::rotate_deg(rot)
|
||||
* Mat3::skew_deg(skew, skew_axis)
|
||||
* Mat3::scale(vec2(sw / 100.0, sh / 100.0))
|
||||
* Mat3::translate(vec2(-anchor.0, -anchor.1));
|
||||
if !ctx.adjustment {
|
||||
let m = transform_matrix(ctx.params, &b);
|
||||
b.pad_sides(transform_grow(&[m], b.img.width, b.img.height));
|
||||
}
|
||||
let m = transform_matrix(ctx.params, &b);
|
||||
let mut out = Image::new(b.img.width, b.img.height);
|
||||
effectcraft_raster::composite_warp(&mut out, &b.img, &m, &effectcraft_raster::WarpOpts { opacity, sampling, ..Default::default() });
|
||||
b.img = out;
|
||||
@@ -576,6 +621,144 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// After Effects 2026 (cases 723, 819, 820): a grey 960 × 540 solid centred in a 1920 × 1080
|
||||
/// comp with Transform. The skew applies after the rotation, and the result is drawn outside
|
||||
/// the layer's bounds. Points are comp pixels sampled from AE's renders (at least 3 px from
|
||||
/// an edge; true = the solid covers it), half where the old order and clipping differed.
|
||||
#[test]
|
||||
fn transform_matches_after_effects() {
|
||||
let solid = Image::filled(960, 540, [0.5, 0.5, 0.5, 1.0]);
|
||||
let p2 = |x: f64, y: f64| Value::Vec2([x, y]);
|
||||
let cases: [(&str, Vec<(&str, Value)>, &[(u32, u32, bool)]); 3] = [
|
||||
(
|
||||
"723",
|
||||
vec![
|
||||
("anchor", p2(400.0, 200.0)),
|
||||
("position", p2(500.0, 300.0)),
|
||||
("uniform", Value::Bool(false)),
|
||||
("scaleHeight", num(80.0)),
|
||||
("scaleWidth", num(120.0)),
|
||||
("skew", num(10.0)),
|
||||
("skewAxis", num(20.0)),
|
||||
("rotation", num(30.0)),
|
||||
],
|
||||
&[
|
||||
(1465, 954, true),
|
||||
(1127, 821, true),
|
||||
(1378, 846, true),
|
||||
(1416, 878, true),
|
||||
(1486, 850, true),
|
||||
(492, 467, true),
|
||||
(706, 239, true),
|
||||
(1402, 1021, true),
|
||||
(518, 440, true),
|
||||
(1218, 882, true),
|
||||
(454, 448, true),
|
||||
(1473, 920, true),
|
||||
(1048, 654, true),
|
||||
(1050, 486, true),
|
||||
(1060, 736, true),
|
||||
(932, 804, true),
|
||||
(1074, 562, true),
|
||||
(653, 591, true),
|
||||
(1353, 466, false),
|
||||
(88, 1021, false),
|
||||
(1855, 886, false),
|
||||
(1796, 401, false),
|
||||
(497, 913, false),
|
||||
(1445, 515, false),
|
||||
],
|
||||
),
|
||||
(
|
||||
"819",
|
||||
vec![("skew", num(15.0)), ("skewAxis", num(60.0))],
|
||||
&[
|
||||
(465, 367, true),
|
||||
(554, 793, false),
|
||||
(481, 529, true),
|
||||
(1451, 599, true),
|
||||
(428, 575, true),
|
||||
(432, 600, true),
|
||||
(463, 444, true),
|
||||
(759, 803, false),
|
||||
(445, 684, true),
|
||||
(1411, 757, true),
|
||||
(915, 784, false),
|
||||
(480, 737, true),
|
||||
(758, 620, true),
|
||||
(975, 705, true),
|
||||
(1289, 376, true),
|
||||
(575, 420, true),
|
||||
(1015, 507, true),
|
||||
(1377, 448, true),
|
||||
(1679, 171, false),
|
||||
(194, 435, false),
|
||||
(413, 159, false),
|
||||
(327, 741, false),
|
||||
(1824, 138, false),
|
||||
(1093, 29, false),
|
||||
],
|
||||
),
|
||||
(
|
||||
"820",
|
||||
vec![
|
||||
("skew", num(15.0)),
|
||||
("skewAxis", num(60.0)),
|
||||
("rotation", num(-25.0)),
|
||||
("uniform", Value::Bool(false)),
|
||||
("scaleWidth", num(130.0)),
|
||||
("scaleHeight", num(70.0)),
|
||||
("anchor", p2(100.0, 50.0)),
|
||||
],
|
||||
&[
|
||||
(1674, 566, true),
|
||||
(1425, 317, false),
|
||||
(1788, 278, true),
|
||||
(1530, 390, true),
|
||||
(1531, 652, true),
|
||||
(1589, 545, true),
|
||||
(1890, 441, true),
|
||||
(1724, 400, true),
|
||||
(1657, 511, true),
|
||||
(1895, 261, true),
|
||||
(1697, 344, true),
|
||||
(933, 840, true),
|
||||
(1413, 467, true),
|
||||
(1295, 691, true),
|
||||
(1353, 548, true),
|
||||
(1293, 548, true),
|
||||
(1103, 551, true),
|
||||
(973, 758, true),
|
||||
(446, 484, false),
|
||||
(1009, 420, false),
|
||||
(144, 761, false),
|
||||
(1812, 111, false),
|
||||
(1241, 10, false),
|
||||
(900, 854, false),
|
||||
],
|
||||
),
|
||||
];
|
||||
for (case, set, pts) in cases {
|
||||
let o = run("ec.distort.transform", &set, solid.clone());
|
||||
for &(x, y, inside) in pts {
|
||||
// Comp pixel → layer pixel (the solid's top-left sits at 480, 270) → buffer.
|
||||
let (bx, by) = o.to_px([x as f64 + 0.5 - 480.0, y as f64 + 0.5 - 270.0]);
|
||||
let a = if bx >= 0.0 && by >= 0.0 && bx < o.img.width as f64 && by < o.img.height as f64 { o.img.get(bx as i64, by as i64)[3] } else { 0.0 };
|
||||
assert_eq!(a > 0.5, inside, "case {case}: comp pixel ({x}, {y}) alpha {a}");
|
||||
}
|
||||
}
|
||||
// A huge scale stays within the buffer cap.
|
||||
let o = run("ec.distort.transform", &[("scaleHeight", num(100_000.0))], Image::filled(8, 8, [1.0; 4]));
|
||||
assert!(o.img.width <= 16_384 && o.img.height <= 16_384);
|
||||
// A layer moved far right grows the buffer on that side only, within 16 Mpx.
|
||||
let o = run("ec.distort.transform", &[("position", p2(960.0 + 2000.0, 540.0))], Image::filled(1920, 1080, [1.0; 4]));
|
||||
assert_eq!((o.offset[0], o.img.height), (0.0, 1080), "only the right side grows");
|
||||
assert!(o.img.width >= 1920 + 2000 && o.img.width as u64 * o.img.height as u64 <= 16_777_216, "{} x {}", o.img.width, o.img.height);
|
||||
assert!(o.img.get(2000 + 960, 540)[3] > 0.99 && o.img.get(10, 540)[3] < 0.01);
|
||||
let o = run("ec.distort.transform", &[("position", p2(40_000.0, 30_000.0))], Image::filled(1920, 1080, [1.0; 4]));
|
||||
assert!(o.img.width as u64 * o.img.height as u64 <= 16_777_216, "{} x {}", o.img.width, o.img.height);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_sampling_bicubic_differs_from_bilinear() {
|
||||
let im = img(32, 32);
|
||||
|
||||
@@ -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<Image> = 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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -692,18 +692,33 @@ impl BoltCfg<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Grow a bolt from `a` to `z` by midpoint displacement (`depth` levels), forking at random.
|
||||
/// Segments go to `out` with their intensity; `mains` marks the main core's segments.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn bolt(cfg: &BoltCfg, a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, main: bool, rng: &mut Rng, out: &mut Vec<Seg>, mains: &mut Vec<bool>) {
|
||||
if out.len() > 20_000 {
|
||||
/// One branch's geometry and recursive growth state.
|
||||
struct BoltBranch {
|
||||
start: (f64, f64),
|
||||
end: (f64, f64),
|
||||
depth: u32,
|
||||
intensity: f32,
|
||||
main: bool,
|
||||
}
|
||||
|
||||
/// Segments and their matching main-core flags, kept in the same traversal order.
|
||||
#[derive(Default)]
|
||||
struct BoltOutput {
|
||||
segments: Vec<Seg>,
|
||||
mains: Vec<bool>,
|
||||
}
|
||||
|
||||
/// Grow a branch by midpoint displacement, recursively appending its segments and forks.
|
||||
fn bolt(cfg: &BoltCfg, branch: BoltBranch, rng: &mut Rng, output: &mut BoltOutput) {
|
||||
let BoltBranch { start: a, end: z, depth, intensity: inten, main } = branch;
|
||||
if output.segments.len() > 20_000 {
|
||||
return;
|
||||
}
|
||||
let (dx, dy) = (z.0 - a.0, z.1 - a.1);
|
||||
let len = (dx * dx + dy * dy).sqrt();
|
||||
if depth == 0 || len < 1.0 {
|
||||
out.push((a, z, inten));
|
||||
mains.push(main);
|
||||
output.segments.push((a, z, inten));
|
||||
output.mains.push(main);
|
||||
return;
|
||||
}
|
||||
let (nx, ny) = (-dy / len.max(1e-9), dx / len.max(1e-9));
|
||||
@@ -725,8 +740,8 @@ fn bolt(cfg: &BoltCfg, a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, mai
|
||||
if cfg.obstacle > 0.0 && !main && cfg.alpha_at(m) > 0.5 && rng.f() < cfg.obstacle / 10.0 {
|
||||
return;
|
||||
}
|
||||
bolt(cfg, a, m, depth - 1, inten, main, rng, out, mains);
|
||||
bolt(cfg, m, z, depth - 1, inten, main, rng, out, mains);
|
||||
bolt(cfg, BoltBranch { start: a, end: m, depth: depth - 1, intensity: inten, main }, rng, output);
|
||||
bolt(cfg, BoltBranch { start: m, end: z, depth: depth - 1, intensity: inten, main }, rng, output);
|
||||
if rng.f() < cfg.fork && inten > cfg.term && len >= cfg.min_fork {
|
||||
let ang = rng.s() * 0.7;
|
||||
let (s, c) = ang.sin_cos();
|
||||
@@ -734,7 +749,7 @@ fn bolt(cfg: &BoltCfg, a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, mai
|
||||
let k = 1.2;
|
||||
// Fork Strength 50 % keeps the decayed intensity; Fork Variation randomises it.
|
||||
let fi = inten * (1.0 - cfg.decay) * cfg.fork_strength * 2.0 * if cfg.fork_var > 0.0 { 1.0 - cfg.fork_var * rng.f() as f32 } else { 1.0 };
|
||||
bolt(cfg, m, (m.0 + fx * k, m.1 + fy * k), depth.saturating_sub(1), fi.min(1.0), false, rng, out, mains);
|
||||
bolt(cfg, BoltBranch { start: m, end: (m.0 + fx * k, m.1 + fy * k), depth: depth.saturating_sub(1), intensity: fi.min(1.0), main: false }, rng, output);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,10 +787,9 @@ pub fn lightning_segments(ctx: &EffectCtx, b: &Buf) -> Vec<Seg> {
|
||||
fork_strength: (g("expertSettings/forkStrength", 50.0) / 100.0).clamp(0.0, 1.0) as f32,
|
||||
fork_var: (g("expertSettings/forkVariation", 0.0) / 100.0).clamp(0.0, 1.0) as f32,
|
||||
};
|
||||
let mut segs = Vec::new();
|
||||
let mut mains = Vec::new();
|
||||
let mut output = BoltOutput::default();
|
||||
let (h, w) = (b.img.height as f64, b.img.width as f64);
|
||||
let mut go = |a, z, depth, cfg: &BoltCfg, rng: &mut Rng| bolt(cfg, a, z, depth, 1.0, true, rng, &mut segs, &mut mains);
|
||||
let mut go = |a, z, depth, cfg: &BoltCfg, rng: &mut Rng| bolt(cfg, BoltBranch { start: a, end: z, depth, intensity: 1.0, main: true }, rng, &mut output);
|
||||
// LIGHTNING_TYPES order.
|
||||
match kind {
|
||||
// Direction: travels toward the Direction point and beyond.
|
||||
@@ -811,6 +825,7 @@ pub fn lightning_segments(ctx: &EffectCtx, b: &Buf) -> Vec<Seg> {
|
||||
// Strike.
|
||||
_ => go(o, d, depth, &cfg, &mut rng),
|
||||
}
|
||||
let BoltOutput { segments: mut segs, mains } = output;
|
||||
let dist = |p: (f64, f64)| ((p.0 - o.0).powi(2) + (p.1 - o.1).powi(2)).sqrt();
|
||||
let far = segs.iter().map(|(_, z, _)| dist(*z)).fold(1e-9, f64::max);
|
||||
// Decay Main Core: the whole bolt fades with distance from the origin.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::raster::{Seg, mask_px, over, poly_segs, raster_segs};
|
||||
use super::*;
|
||||
use super::{Buf, EffectCtx, IndexedParallelIterator, IntoParallelRefMutIterator, PI, ParallelIterator, poly_length, premul};
|
||||
|
||||
// ---------------------------------------------------------------- Audio Spectrum / Waveform
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{EffectSpec, ParamUi, Value, slider};
|
||||
|
||||
pub(super) fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
|
||||
EffectSpec { id, name, category: "Generate", params, render, gpu: false, float: true }
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::raster::over;
|
||||
use super::*;
|
||||
use super::{
|
||||
Buf, EffectCtx, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, PI, ParallelIterator, Plane, Px, gauss_plane, hash1,
|
||||
hsl_to_rgb, morph_frac, premul, rgb_to_hsl, smoothstep, unpremul,
|
||||
};
|
||||
|
||||
pub(super) fn glue_gun(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let pr = ctx.params;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::raster::over;
|
||||
use super::*;
|
||||
use super::{Buf, EffectCtx, IndexedParallelIterator, ParallelIterator, ParallelSliceMut, Px, hsl_to_rgb};
|
||||
|
||||
// ---------------------------------------------------------------- Fractal
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::raster::{Seg, all_masks_px, chaikin, mask_px, over, poly_segs, raster_convex, raster_segs, scan_runs, stroke_outline, trim_poly};
|
||||
use super::*;
|
||||
use super::{
|
||||
Buf, EffectCtx, Image, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, ParallelIterator, Params, Plane, gauss_plane, hash1,
|
||||
layer_or_self, luminance, poly_length, smoothstep, unpremul,
|
||||
};
|
||||
|
||||
/// How painted coverage meets the layer: 0 On Original Image, 1 On Transparent, 2 Reveal
|
||||
/// Original Image.
|
||||
|
||||
@@ -1,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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+171
-10
@@ -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<Params> {
|
||||
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<Value> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
+317
-20
@@ -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::<Vec<f32>>();
|
||||
*px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, k * a];
|
||||
*px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, ga];
|
||||
} else if use_colors {
|
||||
let t = glow_ab_t(l, looping, loops, phase, mid);
|
||||
let c = [ca[0] + (cb[0] - ca[0]) * t, ca[1] + (cb[1] - ca[1]) * t, ca[2] + (cb[2] - ca[2]) * t];
|
||||
*px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, k * a];
|
||||
*px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, ga];
|
||||
} else if alpha_based {
|
||||
*px = [px[0] * k, px[1] * k, px[2] * k, ga];
|
||||
} else {
|
||||
*px = px.map(|v| v * k);
|
||||
// Original colours: each channel of the straight colour against the threshold.
|
||||
let c = [px[0] / a, px[1] / a, px[2] / a].map(|v| glow_bright_channel(v, thr) * a);
|
||||
*px = [c[0], c[1], c[2], ga];
|
||||
}
|
||||
});
|
||||
let s = (radius / 2.0).max(0.5);
|
||||
let s = glow_sigma(radius);
|
||||
let blurred = gaussian_blur(&bright, s * kx, s * ky, false);
|
||||
let mode = ctx.params.e("operation");
|
||||
b.img.data.par_iter_mut().zip(blurred.data.par_iter()).for_each(|(o, g)| {
|
||||
let g = g.map(|v| v * intensity);
|
||||
// Intensity scales the glow's colour; with the colour channels its coverage stays.
|
||||
let g = [g[0] * intensity, g[1] * intensity, g[2] * intensity, if alpha_based { g[3] * intensity } else { g[3] }];
|
||||
match mode {
|
||||
// Behind: the glow shows only where the original is transparent.
|
||||
1 => {
|
||||
@@ -525,22 +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).
|
||||
|
||||
+24
-28
@@ -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<usize> = (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) }
|
||||
}
|
||||
|
||||
+165
-22
@@ -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<Plane> = 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<Vec<bool>> {
|
||||
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]);
|
||||
|
||||
@@ -60,6 +60,11 @@ pub const BLEND_NORMAL: u32 = 1;
|
||||
/// Index of "Soft Linear" in [`NOISE_TYPES`] (the default, and the GPU path's interpolation).
|
||||
pub const NOISE_SOFT_LINEAR: u32 = 2;
|
||||
|
||||
/// Lattice cell of Fractal / Turbulent Noise in pixels per percent of Scale: After Effects 2026
|
||||
/// draws Block noise at Scale 100 in 66.67 px cells (and Soft Linear noise has the matching
|
||||
/// correlation length), not 100 px.
|
||||
pub const NOISE_CELL: f64 = 2.0 / 3.0;
|
||||
|
||||
fn spec(id: &'static str, name: &'static str, params: Vec<ParamSpec>, render: crate::RenderFn) -> EffectSpec {
|
||||
EffectSpec { id, name, category: "Noise & Grain", params, render, gpu: false, float: true }
|
||||
}
|
||||
@@ -236,7 +241,7 @@ impl Fractal {
|
||||
overflow: pr.e("overflow"),
|
||||
invert: pr.b("invert"),
|
||||
rot: rot.sin_cos(),
|
||||
size: [(sw * b.scale).max(1.0) as f32, (sh * b.scale).max(1.0) as f32],
|
||||
size: [(sw * NOISE_CELL * b.scale).max(1.0) as f32, (sh * NOISE_CELL * b.scale).max(1.0) as f32],
|
||||
origin: [ox as f32, oy as f32],
|
||||
center: [cx as f32, cy as f32],
|
||||
perspective: pr.b("transform/perspectiveOffset"),
|
||||
@@ -535,14 +540,34 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn noise_types_differ_and_block_is_piecewise_constant() {
|
||||
let a = run("ec.noise.fractal", &[("noiseType", Value::Enum(0)), ("complexity", num(1.0)), ("transform/scale", num(10.0))]);
|
||||
// Block noise: neighbours inside one 10 px cell share a value.
|
||||
let a = run("ec.noise.fractal", &[("noiseType", Value::Enum(0)), ("complexity", num(1.0)), ("transform/scale", num(15.0))]);
|
||||
// Block noise: neighbours inside one 10 px cell (Scale 15) share a value.
|
||||
assert_eq!(a.get(4, 4), a.get(5, 5));
|
||||
let others: Vec<Image> = (1..4).map(|t| run("ec.noise.fractal", &[("noiseType", Value::Enum(t)), ("transform/scale", num(10.0))])).collect();
|
||||
assert_ne!(others[0], others[1]);
|
||||
assert_ne!(others[1], others[2]);
|
||||
}
|
||||
|
||||
/// After Effects 2026 (R9): Block noise at Scale 100, Complexity 1 changes value every
|
||||
/// 66.67 px along a row.
|
||||
#[test]
|
||||
fn block_noise_cells_are_two_thirds_of_scale_as_in_after_effects() {
|
||||
let img = run_fx(
|
||||
"ec.noise.fractal",
|
||||
&[("noiseType", Value::Enum(0)), ("complexity", num(1.0)), ("blendingMode", Value::Enum(BLEND_NONE))],
|
||||
Image::filled(400, 8, [0.0, 0.0, 0.0, 1.0]),
|
||||
0.0,
|
||||
EffectEnv::default(),
|
||||
)
|
||||
.img;
|
||||
let changes: Vec<i64> = (1..400).filter(|&x| img.get(x, 4) != img.get(x - 1, 4)).collect();
|
||||
assert!(changes.len() >= 4, "{changes:?}");
|
||||
for w in changes.windows(2) {
|
||||
let d = (w[1] - w[0]) as f64;
|
||||
assert!((d - 200.0 / 3.0).abs() <= 1.0, "cell width {d}: {changes:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_width_height_apply_without_uniform_scaling() {
|
||||
let base = &[("complexity", num(1.0)), ("transform/scale", num(8.0))];
|
||||
|
||||
@@ -191,6 +191,11 @@ pub fn roto_data(params: &Params, layer_size: [f64; 2], f: i64, with_model: bool
|
||||
/// Seed of the chain keys: what the frames are (the Input Key), the layer size, the frame rate,
|
||||
/// the mode and the model. The prompt is in each frame's key.
|
||||
pub fn seed(params: &Params, layer_size: [f64; 2], fps: f64, model: Option<&dyn MaskModel>) -> u64 {
|
||||
seed_of(params, layer_size, fps, model.map_or(effectcraft_segment::CLASSICAL, |m| m.info().id))
|
||||
}
|
||||
|
||||
/// [`seed`] for the model with id `model` ([`effectcraft_segment::CLASSICAL`]: no model).
|
||||
fn seed_of(params: &Params, layer_size: [f64; 2], fps: f64, model: &str) -> u64 {
|
||||
let mut h = 0xcbf2_9ce4_8422_2325;
|
||||
fnv(&mut h, ID.as_bytes());
|
||||
fnv(&mut h, s(params, INPUT_KEY).as_bytes());
|
||||
@@ -198,7 +203,7 @@ pub fn seed(params: &Params, layer_size: [f64; 2], fps: f64, model: Option<&dyn
|
||||
fnv(&mut h, &v.to_bits().to_le_bytes());
|
||||
}
|
||||
fnv(&mut h, &[tracks(params) as u8]);
|
||||
fnv(&mut h, model.map_or(effectcraft_segment::CLASSICAL, |m| m.info().id).as_bytes());
|
||||
fnv(&mut h, model.as_bytes());
|
||||
h
|
||||
}
|
||||
|
||||
@@ -255,9 +260,14 @@ pub fn frame_keys(params: &Params, layer_size: [f64; 2], fps: f64, frames: &[i64
|
||||
/// What frozen mattes depend on: what the frames are (the seed: Input Key, size, rate, mode,
|
||||
/// model), the prompt, the tracked span and the matte settings.
|
||||
pub fn frozen_key(params: &Params, layer_size: [f64; 2], fps: f64, model: Option<&dyn MaskModel>) -> String {
|
||||
let mut h = seed(params, layer_size, fps, model);
|
||||
frozen_key_of(params, layer_size, fps, model.map_or(effectcraft_segment::CLASSICAL, |m| m.info().id))
|
||||
}
|
||||
|
||||
/// [`frozen_key`] for the model with id `model` ([`effectcraft_segment::CLASSICAL`]: no model).
|
||||
fn frozen_key_of(params: &Params, layer_size: [f64; 2], fps: f64, model: &str) -> String {
|
||||
let mut h = seed_of(params, layer_size, fps, model);
|
||||
let base = reference_frame(params);
|
||||
if let Some(st) = prompt_strokes(params, layer_size, base, model.is_some()) {
|
||||
if let Some(st) = prompt_strokes(params, layer_size, base, model != effectcraft_segment::CLASSICAL) {
|
||||
fnv(&mut h, serde_json::to_string(&st).unwrap_or_default().as_bytes());
|
||||
}
|
||||
for v in [base, reach(params)[0], reach(params)[1]] {
|
||||
@@ -273,7 +283,9 @@ pub fn is_frozen(params: &Params) -> bool {
|
||||
}
|
||||
|
||||
/// Frame `f`'s frozen matte on a `w`×`h` grid, when the instance is frozen, the mattes still
|
||||
/// match ([`frozen_key`]) and `f` is one of them.
|
||||
/// match ([`frozen_key`]) and `f` is one of them. The mattes match under the session's `model`
|
||||
/// or under the model that froze them (which this session may not have loaded, e.g. a fresh
|
||||
/// headless render): frozen mattes need no segmentation.
|
||||
pub fn frozen_alpha(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, w: usize, h: usize, model: Option<&dyn MaskModel>) -> Option<Vec<f32>> {
|
||||
let j = s(params, FROZEN);
|
||||
if j.is_empty() {
|
||||
@@ -281,7 +293,10 @@ pub fn frozen_alpha(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, w:
|
||||
}
|
||||
let fc = frozen_cache(j)?;
|
||||
if fc.key != frozen_key(params, layer_size, fps, model) {
|
||||
return None;
|
||||
let ids = std::iter::once(effectcraft_segment::CLASSICAL).chain(effectcraft_segment::MODELS.iter().map(|m| m.id));
|
||||
if !ids.into_iter().any(|id| fc.key == frozen_key_of(params, layer_size, fps, id)) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let fr = fc.frames.get(&f)?;
|
||||
let [fw, fh] = fc.size;
|
||||
@@ -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<Arc<dyn MaskModel>>,
|
||||
frame: &mut dyn FnMut(i64) -> Option<Arc<Image>>,
|
||||
cancel: &dyn Fn() -> bool,
|
||||
) -> FrameMatte {
|
||||
let MatteFrame { layer_size, fps, frame: f, scale, img } = *input;
|
||||
let Some(chain) = chain(params, layer_size, fps, f, scale, model) else { return FrameMatte::NoPrompt };
|
||||
if !chain.data.in_span(f) {
|
||||
return FrameMatte::Outside;
|
||||
@@ -408,7 +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<f32> = (0..100 * 80).map(|i| ((i % 100) < 50) as u8 as f32).collect();
|
||||
let mut fc = FrozenCache { key: frozen_key(&p, [100.0, 80.0], 25.0, Some(&stub)), size: [100, 80], ..Default::default() };
|
||||
fc.frames.insert(2, roto::freeze_frame(&alpha, &[]));
|
||||
p.values.insert(FROZEN.into(), Value::Str(fc.to_json()));
|
||||
assert!(is_frozen(&p));
|
||||
assert!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 100, 80, Some(&stub)).is_some());
|
||||
assert_eq!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 50, 40, None).map(|a| (a[20 * 50 + 5], a[20 * 50 + 45])), Some((1.0, 0.0)));
|
||||
let out = run(&p, 2);
|
||||
assert!(out.data[40 * 100 + 10][3] > 0.99 && out.data[40 * 100 + 90][3] < 0.01, "the frozen matte is used");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{ParamUi, slider};
|
||||
|
||||
// ---------------------------------------------------------------- ASC CDL
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::cdl::Cdl;
|
||||
use super::lut::load_file;
|
||||
use super::space::{ACES_CCT, ACES_CG, ACES2065_1, D65, IDENTITY, P3, REC709, REC2020, SPACES, Space, Tf, Xform, matrix, sp, space_names};
|
||||
use super::*;
|
||||
use super::{Buf, ColorSpace, EffectCtx, EffectSpec, IntoParallelRefMutIterator, ParallelIterator, Value, invert, mul, mul_vec, p, popup, unpremul};
|
||||
|
||||
// ---------------------------------------------------------------- displays, views and looks
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::cdl::{Cdl, parse_cdl_xml};
|
||||
use super::*;
|
||||
use super::{Arc, HashMap, Lut, Mutex, OnceLock, parse_cube};
|
||||
|
||||
// ---------------------------------------------------------------- LUT files
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::effects::{
|
||||
space_param, spec,
|
||||
};
|
||||
use super::space::SPACES;
|
||||
use super::*;
|
||||
use super::{EffectSpec, ParamUi, Value, num, p, popup, slider};
|
||||
|
||||
pub fn specs() -> Vec<EffectSpec> {
|
||||
let cc = "Color Correction";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{invert, mul, mul_vec, rgb_to_xyz};
|
||||
|
||||
pub(super) type M3 = [[f64; 3]; 3];
|
||||
|
||||
|
||||
@@ -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<Vec<u32>> {
|
||||
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<Vec<u32>> {
|
||||
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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,18 @@ 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(crate) use particle_fx::particle_world_textured;
|
||||
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;
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{EffectSpec, ParamUi, Value, hash1, slider};
|
||||
|
||||
pub(crate) fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
|
||||
EffectSpec { id, name, category: "Simulation", params, render, gpu: false, float: true }
|
||||
|
||||
@@ -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<Particle>,
|
||||
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<std::sync::Arc<PState>> {
|
||||
/// `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<std::sync::Arc<PState>> {
|
||||
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<f64> {
|
||||
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<PState>, key: u64, phys: &Phys, rates: &[f64]) -> std::sync::Arc<PState> {
|
||||
// `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<PState>,
|
||||
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<PState> {
|
||||
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<PState> {
|
||||
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<Vec<crate::psim::SimParticle>> {
|
||||
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<PState>, key: u64, t: f64, phys: &Phys) -> std::sync::Arc<PState> {
|
||||
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 },
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
use super::engine::{PState, PW_EFFECT_CAMERA, Phys, accelerated, norm3, particle_look, sim_key, simulate, type_sprite};
|
||||
use super::sprites::{Acc, Post, Sprite, SpritePlan, raster, splat};
|
||||
use super::*;
|
||||
use super::engine::{MERCURY, PS2, PState, PW_EFFECT_CAMERA, Phys, norm3, particle_look, pw_state, type_sprite};
|
||||
use super::sprites::{Acc, Post, Sprite, SpritePlan, aa, raster, splat, sprite_bbox};
|
||||
use super::{Buf, EffectCtx, Image, IntoParallelRefIterator, ParallelIterator, SimCache, Value, unpremul};
|
||||
|
||||
// ------------------------------------------------------------------ CC Particle World
|
||||
|
||||
pub(super) static PW_CACHE: SimCache<PState> = 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],
|
||||
@@ -32,20 +35,44 @@ pub(super) fn pw_phys(ctx: &EffectCtx) -> Phys {
|
||||
}
|
||||
}
|
||||
|
||||
/// CC Particle World's Particle Type ▸ Textured Square.
|
||||
const PW_TEXTURED_SQUARE: u32 = 8;
|
||||
/// CC Particle World's Particle ▸ Texture ▸ Texture Layer.
|
||||
pub(super) const PW_TEXTURE_LAYER: &str = "particle/texture/textureLayer";
|
||||
|
||||
/// Whether CC Particle World draws its Texture Layer on the particles (Textured Square with a
|
||||
/// layer chosen). Only the CPU does: `catalog::gpu_supported` keeps this case off the GPU.
|
||||
pub(crate) fn particle_world_textured(ctx: &EffectCtx) -> bool {
|
||||
ctx.params.e("particle/particleType") == PW_TEXTURED_SQUARE && ctx.params.get(PW_TEXTURE_LAYER).and_then(Value::as_layer).is_some()
|
||||
}
|
||||
|
||||
pub(super) fn particle_world(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
// The texture at the current comp time, shared by every shutter sample. A layer the host
|
||||
// can't give (the effect's own layer with its effects, a reference cycle) leaves the
|
||||
// squares in the particle colour. Authored buffers (text, shapes) span only their content:
|
||||
// covering the whole layer keeps the clamped sampling from stretching their edge pixels.
|
||||
let tex = particle_world_textured(ctx).then(|| ctx.layer_param(PW_TEXTURE_LAYER, true)).flatten().map(|mut t| {
|
||||
t.buf.cover(t.size);
|
||||
t
|
||||
});
|
||||
let (w, h) = (b.img.width, b.img.height);
|
||||
let draw = |plan: &SpritePlan| match &tex {
|
||||
Some(t) => splat_textured(w, h, &plan.sprites, plan.acc, t),
|
||||
None => splat(w, h, &plan.sprites, plan.acc),
|
||||
};
|
||||
// #1195: with the comp's motion-blur shutter open, particles draw as motion trails: the
|
||||
// sprite fields of samples across the shutter are averaged, as Transform averages its
|
||||
// matrix samples. (The GPU compositor runs this case on the CPU: catalog::gpu_supported.)
|
||||
if let Some((angle, phase, n)) = ctx.env.shutter.filter(|(a, _, _)| *a > 0.0) {
|
||||
let n = (n.clamp(2, 64) as usize).max(2);
|
||||
let fd = 1.0 / ctx.fps().max(0.001);
|
||||
let mut acc = Image::new(b.img.width, b.img.height);
|
||||
let mut acc = Image::new(w, h);
|
||||
let mut post: Option<Post> = None;
|
||||
for k in 0..n {
|
||||
let t = ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd;
|
||||
let sub = EffectCtx { params: ctx.params, time: t, layer_size: ctx.layer_size, seed: ctx.seed, adjustment: ctx.adjustment, env: ctx.env };
|
||||
if let Some(plan) = particle_world_plan(&sub, &b) {
|
||||
let fx = splat(b.img.width, b.img.height, &plan.sprites, plan.acc);
|
||||
let fx = draw(&plan);
|
||||
acc.data.iter_mut().zip(&fx.data).for_each(|(a, o)| (0..4).for_each(|c| a[c] += o[c]));
|
||||
post.get_or_insert(plan.post);
|
||||
}
|
||||
@@ -57,11 +84,38 @@ pub(super) fn particle_world(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
};
|
||||
}
|
||||
match particle_world_plan(ctx, &b) {
|
||||
Some(plan) => plan.finish(b),
|
||||
Some(plan) => {
|
||||
let fx = draw(&plan);
|
||||
plan.finish_with(b, fx)
|
||||
}
|
||||
None => b,
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw square sprites into a new transparent image of `w`×`h`, each showing the layer `tex`
|
||||
/// stretched over the square (turned with it) at the sprite's opacity.
|
||||
fn splat_textured(w: u32, hgt: u32, sprites: &[Sprite], acc: Acc, tex: &crate::LayerPixels) -> Image {
|
||||
let mut img = Image::new(w, hgt);
|
||||
let (tw, th) = (tex.size[0], tex.size[1]);
|
||||
let shade = |s: &Sprite, x: f32, y: f32| {
|
||||
// As [`sprite_shade`]'s Square, sub-pixel sprites included.
|
||||
let (r, fade) = if s.r < 0.5 { (0.5, s.r / 0.5) } else { (s.r, 1.0) };
|
||||
let (sn, cs) = s.rot.sin_cos();
|
||||
let (dx, dy) = (x - s.x, y - s.y);
|
||||
let (ux, uy) = (dx * cs + dy * sn, -dx * sn + dy * cs);
|
||||
let e = r * 0.75;
|
||||
let a = aa(e - ux.abs()) * aa(e - uy.abs()) * s.c[3] * fade;
|
||||
if a <= 1e-6 {
|
||||
return None;
|
||||
}
|
||||
let (u, v) = (((ux / e + 1.0) * 0.5).clamp(0.0, 1.0) as f64, ((uy / e + 1.0) * 0.5).clamp(0.0, 1.0) as f64);
|
||||
let (px, py) = tex.buf.to_px([u * tw, v * th]);
|
||||
Some(tex.buf.img.sample_bilinear_clamped(px, py).map(|c| c * a))
|
||||
};
|
||||
raster(&mut img, sprites, sprite_bbox, shade, acc);
|
||||
img
|
||||
}
|
||||
|
||||
/// Effect Camera ▸ FOV's default (degrees).
|
||||
pub(super) const PW_FOV: f64 = 50.0;
|
||||
|
||||
@@ -152,8 +206,7 @@ pub(super) fn particle_world_plan(ctx: &EffectCtx, b: &Buf) -> Option<SpritePlan
|
||||
if phys.rate <= 0.0 && ctx.time <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let key = sim_key(ctx, 1);
|
||||
let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(&PW_CACHE, key, ctx.time, &phys));
|
||||
let st = pw_state(ctx, &phys);
|
||||
let kind = pr.e("particle/particleType");
|
||||
let birth = pr.color("particle/birthColor");
|
||||
let death = pr.color("particle/deathColor");
|
||||
@@ -169,7 +222,7 @@ pub(super) fn particle_world_plan(ctx: &EffectCtx, b: &Buf) -> Option<SpritePlan
|
||||
let PwSeen { depth, x, y, k, v } = cam.see(q.p, q.v, lw, lh, b)?;
|
||||
let (c, size) = particle_look(q, birth, death, bs, ds, svar, max_op);
|
||||
let r = size * 0.5 * lw * k * s * 0.25;
|
||||
let sp = type_sprite(kind, x, y, r, c, v[0] * lw * k * s, v[1] * lw * k * s, (q.id % 628) as f32 * 0.01 + q.age * 2.0);
|
||||
let sp = type_sprite(kind, x, y, r, c, [v[0] * lw * k * s, v[1] * lw * k * s], (q.id % 628) as f32 * 0.01 + q.age * 2.0);
|
||||
Some((depth, sp))
|
||||
})
|
||||
.collect();
|
||||
@@ -190,6 +243,9 @@ fn particle_sprites(sprites: Vec<Sprite>, mode: u32) -> SpritePlan {
|
||||
|
||||
pub(super) static PS2_CACHE: SimCache<PState> = 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 +254,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 +281,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 +296,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 +304,10 @@ pub(super) fn particle_systems2_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan {
|
||||
|
||||
// ------------------------------------------------------------------ CC Mr. Mercury
|
||||
|
||||
static MERCURY_CACHE: SimCache<PState> = SimCache::new(6);
|
||||
pub(super) static MERCURY_CACHE: SimCache<PState> = 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 +317,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<Blob> {
|
||||
/// 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 +338,15 @@ pub fn mercury_blobs(ctx: &EffectCtx, b: &Buf) -> Vec<Blob> {
|
||||
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<Blob> {
|
||||
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
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use super::common::{pct, pt, spec};
|
||||
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::particle_fx::{PW_FOV, PW_TEXTURE_LAYER, 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
|
||||
@@ -151,6 +151,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
p("physics/gravityVector/gravityY", "Gravity Y", num(1.0), slider(-1.0, 1.0, -1.0, 1.0, 2)),
|
||||
p("physics/gravityVector/gravityZ", "Gravity Z", num(0.0), slider(-1.0, 1.0, -1.0, 1.0, 2)),
|
||||
p("particle/particleType", "Particle Type", Value::Enum(0), popup(PARTICLE_TYPES)),
|
||||
p(PW_TEXTURE_LAYER, "Texture Layer", Value::Layer(None), ParamUi::Layer),
|
||||
p("particle/birthSize", "Birth Size", num(0.2), slider(0.0, 10.0, 0.0, 1.0, 3)),
|
||||
p("particle/deathSize", "Death Size", num(0.2), slider(0.0, 10.0, 0.0, 1.0, 3)),
|
||||
p("particle/sizeVariation", "Size Variation", num(0.0), pct()),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Buf, Image, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, ParallelIterator, ParallelSliceMut, Px, unpremul};
|
||||
|
||||
// ------------------------------------------------------------------ band rasteriser
|
||||
|
||||
@@ -225,7 +225,7 @@ impl Post {
|
||||
}
|
||||
}
|
||||
|
||||
fn sprite_bbox(s: &Sprite) -> Option<[f32; 4]> {
|
||||
pub(super) fn sprite_bbox(s: &Sprite) -> Option<[f32; 4]> {
|
||||
if s.c[3] <= 1e-5 || s.r <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -329,6 +383,110 @@ fn particle_world_effect_camera() {
|
||||
assert_ne!(key(¶ms), key(&moved));
|
||||
}
|
||||
|
||||
/// A host whose every layer is `img`.
|
||||
struct TexHost(Image);
|
||||
impl crate::EffectHost for TexHost {
|
||||
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
|
||||
let s = [self.0.width as f64, self.0.height as f64];
|
||||
Some(crate::LayerPixels { buf: Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 }, size: s })
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A texture whose left half is green and right half blue.
|
||||
fn two_tone_tex() -> Image {
|
||||
let mut tex = Image::new(8, 8);
|
||||
for y in 0..8 {
|
||||
for x in 0..8 {
|
||||
tex.set(x, y, if x < 4 { [0.0, 1.0, 0.0, 1.0] } else { [0.0, 0.0, 1.0, 1.0] });
|
||||
}
|
||||
}
|
||||
tex
|
||||
}
|
||||
|
||||
/// CC Particle World drawing red Textured Squares with Texture Layer `layer`.
|
||||
fn pw_textured_vals(layer: Option<u64>) -> Vec<(&'static str, Value)> {
|
||||
vec![
|
||||
("birthRate", num(1.0)),
|
||||
("extras/randomSeed", num(4.0)),
|
||||
("particle/particleType", Value::Enum(8)),
|
||||
("particle/texture/textureLayer", Value::Layer(layer)),
|
||||
("particle/birthSize", num(1.0)),
|
||||
("particle/deathSize", num(1.0)),
|
||||
("particle/birthColor", col(1.0, 0.0, 0.0)),
|
||||
("particle/deathColor", col(1.0, 0.0, 0.0)),
|
||||
]
|
||||
}
|
||||
|
||||
/// #1465: Textured Square draws the Texture Layer on each particle, not the particle colour.
|
||||
#[test]
|
||||
fn particle_world_textured_square_draws_the_texture_layer() {
|
||||
let host = TexHost(two_tone_tex());
|
||||
let vals = pw_textured_vals;
|
||||
let env = EffectEnv { host: Some(&host), ..Default::default() };
|
||||
let out = run_fx("ec.sim.ccparticleworld", &vals(Some(1)), Image::new(48, 48), 1.0, env).img;
|
||||
let seen: Vec<_> = out.data.iter().filter(|p| p[3] > 0.05).collect();
|
||||
assert!(!seen.is_empty(), "particles drawn");
|
||||
assert!(seen.iter().all(|p| p[0] < 0.02 * p[3]), "no particle colour: {:?}", seen.iter().find(|p| p[0] >= 0.02 * p[3]));
|
||||
// Both halves of the texture show: it is mapped across the square, not sampled once.
|
||||
assert!(seen.iter().any(|p| p[1] > 2.0 * p[2]) && seen.iter().any(|p| p[2] > 2.0 * p[1]), "both texture halves");
|
||||
// Without a texture layer the squares keep the particle colour.
|
||||
let plain = run_fx("ec.sim.ccparticleworld", &vals(None), Image::new(48, 48), 1.0, env).img;
|
||||
assert!(plain.data.iter().any(|p| p[3] > 0.05 && p[0] > 0.9 * p[3]));
|
||||
}
|
||||
|
||||
/// #1465 with #1195's motion trails: with the shutter open the averaged samples still show
|
||||
/// the Texture Layer, not the particle colour.
|
||||
#[test]
|
||||
fn particle_world_textured_square_motion_trails_draw_the_texture_layer() {
|
||||
let host = TexHost(two_tone_tex());
|
||||
let env = EffectEnv { host: Some(&host), frame_rate: 10.0, shutter: Some((360.0, -180.0, 8)), ..Default::default() };
|
||||
let blurred = run_fx("ec.sim.ccparticleworld", &pw_textured_vals(Some(1)), Image::new(48, 48), 1.0, env).img;
|
||||
let sharp = run_fx("ec.sim.ccparticleworld", &pw_textured_vals(Some(1)), Image::new(48, 48), 1.0, EffectEnv { shutter: None, ..env }).img;
|
||||
assert_ne!(sharp.data, blurred.data, "the shutter smears the particles");
|
||||
let seen: Vec<_> = blurred.data.iter().filter(|p| p[3] > 0.02).collect();
|
||||
assert!(!seen.is_empty(), "particles drawn");
|
||||
assert!(seen.iter().all(|p| p[0] < 0.02 * p[3]), "no particle colour: {:?}", seen.iter().find(|p| p[0] >= 0.02 * p[3]));
|
||||
assert!(seen.iter().any(|p| p[1] > 2.0 * p[2]) && seen.iter().any(|p| p[2] > 2.0 * p[1]), "both texture halves");
|
||||
}
|
||||
|
||||
/// A host whose every layer is `0` as given (a buffer that may span only its content).
|
||||
struct PixelsHost(crate::LayerPixels);
|
||||
impl crate::EffectHost for PixelsHost {
|
||||
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
|
||||
Some(self.0.clone())
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// #1465: a Texture Layer whose buffer spans only its content (text, shapes) is mapped as
|
||||
/// the whole layer, transparent around the content, not with its edge pixels stretched.
|
||||
#[test]
|
||||
fn particle_world_texture_layer_with_a_content_sized_buffer() {
|
||||
// An 8×8 layer with a green 4×4 square in the middle: once as the full layer, once as
|
||||
// just the square's pixels.
|
||||
let mut full = Image::new(8, 8);
|
||||
for y in 2..6 {
|
||||
for x in 2..6 {
|
||||
full.set(x, y, [0.0, 1.0, 0.0, 1.0]);
|
||||
}
|
||||
}
|
||||
let content = Image::filled(4, 4, [0.0, 1.0, 0.0, 1.0]);
|
||||
let render = |buf: Buf| {
|
||||
let host = PixelsHost(crate::LayerPixels { buf, size: [8.0, 8.0] });
|
||||
let env = EffectEnv { host: Some(&host), ..Default::default() };
|
||||
run_fx("ec.sim.ccparticleworld", &pw_textured_vals(Some(1)), Image::new(48, 48), 1.0, env).img
|
||||
};
|
||||
let want = render(Buf { img: full, offset: [0.0; 2], scale: 1.0 });
|
||||
let got = render(Buf { img: content, offset: [-2.0, -2.0], scale: 1.0 });
|
||||
assert!(want.data.iter().any(|p| p[3] > 0.05), "particles drawn");
|
||||
assert_eq!(want.data, got.data, "the content-sized buffer draws as the full layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn particle_systems2_seek_and_direction() {
|
||||
let v = vec![
|
||||
@@ -405,3 +563,266 @@ fn half_resolution_scales_sprites() {
|
||||
let sum = |i: &Image| i.data.iter().map(|p| p[0]).sum::<f32>() / 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<crate::LayerPixels> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
fn param_at(&self, effect_index: usize, id: &str, t: f64) -> Option<Value> {
|
||||
(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<crate::psim::SimParticle> {
|
||||
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<crate::psim::SimParticle> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Bubble> {
|
||||
.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;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Acc, Buf, EffectCtx, Image, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, ParallelIterator, Plane, Px, Shape, SimCache,
|
||||
Sprite, Value, hs, params_key, splat, unpremul, value_noise,
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ Foam
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::shatter::{ShatterPlan, card_dance_plan, shatter_impl};
|
||||
use super::*;
|
||||
use super::{Acc, Buf, EffectCtx, Image, Lighting, Proj, raster, unpremul};
|
||||
|
||||
// ------------------------------------------------------------------ 3D pieces
|
||||
|
||||
|
||||
@@ -1,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::pieces::{M3, Piece, PiecePlan, PieceTex, inv3, rot_axis, rot_xyz, sort_far_first};
|
||||
use super::*;
|
||||
use super::{
|
||||
Acc, Buf, EffectCtx, Image, IndexedParallelIterator, IntoParallelIterator, IntoParallelRefIterator, Lighting, ParallelIterator, Post, Shape, Sprite,
|
||||
SpritePlan, Value, h, hs, layer_or_self, unpremul,
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ Card Dance
|
||||
|
||||
|
||||
@@ -1,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
|
||||
|
||||
|
||||
@@ -575,7 +575,8 @@ fn mapper_from(ctx: &EffectCtx, group: &str, ephemeral: bool) -> Option<Mapper>
|
||||
|
||||
/// Draw a map layer frame as a particle: centred on (x, y) buffer px, rotated `angle` degrees
|
||||
/// and scaled, over `out`.
|
||||
fn blit_layer(out: &mut Image, lp: &crate::LayerPixels, x: f64, y: f64, angle: f64, scale: [f64; 2], buf_scale: f64, alpha: f32) {
|
||||
fn blit_layer(out: &mut Image, lp: &crate::LayerPixels, position: [f64; 2], angle: f64, scale: [f64; 2], buf_scale: f64, alpha: f32) {
|
||||
let [x, y] = position;
|
||||
let (w, h) = (lp.size[0] * scale[0].abs() * buf_scale, lp.size[1] * scale[1].abs() * buf_scale);
|
||||
let r = (w * w + h * h).sqrt() * 0.5 + 1.0;
|
||||
let (x0, x1) = ((x - r).floor().max(0.0) as i64, (x + r).ceil().min(out.width as f64) as i64);
|
||||
@@ -925,7 +926,7 @@ impl PgPlan {
|
||||
pub fn finish(&self, mut b: Buf) -> Buf {
|
||||
let mut fx = splat(b.img.width, b.img.height, &self.sprites.sprites, self.sprites.acc);
|
||||
for q in &self.blits {
|
||||
blit_layer(&mut fx, &self.frames[q.frame], q.x, q.y, q.angle, q.scale, b.scale, q.alpha);
|
||||
blit_layer(&mut fx, &self.frames[q.frame], [q.x, q.y], q.angle, q.scale, b.scale, q.alpha);
|
||||
}
|
||||
// Particles replace the layer's own pixels.
|
||||
b.img = combine(&b.img, &fx, 5);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<S: Clone> SimCache<S> {
|
||||
|
||||
/// 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<S> {
|
||||
pub fn run(&self, key: u64, target: u64, init: impl FnOnce() -> S, step: impl FnMut(&mut S, u64)) -> std::sync::Arc<S> {
|
||||
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<S> {
|
||||
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<S: Clone> SimCache<S> {
|
||||
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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<HashMap<u64, Arc<WarpAnalysis>>>;
|
||||
type PlanCache = Mutex<HashMap<(u64, u64), Arc<Plan>>>;
|
||||
|
||||
/// 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<WarpAnalysis>)> {
|
||||
static C: OnceLock<AnalysisCache> = 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<WarpAnalysis> = 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<Plan> {
|
||||
p
|
||||
}
|
||||
|
||||
/// Validate and cache the default stabilization plan after analysis. Use the serialized
|
||||
/// analysis so the cached plan has exactly the same inputs as the first render, including
|
||||
/// any floating-point round trip through JSON. Other settings still get their own plan.
|
||||
pub fn prepare_default_plan(json: &str) -> bool {
|
||||
let Some((key, a)) = analysis_cached(json) else { return false };
|
||||
if a.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let fps = if a.frame_duration > 0.0 { 1.0 / a.frame_duration } else { 30.0 };
|
||||
let p = plan_cached(key, &a, &StabSettings { fps, ..Default::default() });
|
||||
p.warps.len() == a.frames.len() && p.warps.iter().all(|h| h.0.iter().flatten().all(|v| v.is_finite()))
|
||||
}
|
||||
|
||||
/// The stored analysis of an instance (`None` when not analysed).
|
||||
pub fn analysis(params: &Params) -> Option<Arc<WarpAnalysis>> {
|
||||
let j = s(params, ANALYSIS);
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -39,3 +39,7 @@ rayon = { workspace = true }
|
||||
image = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
exr = { workspace = true }
|
||||
|
||||
# Native only: the volume's own identity for a file, to tell that a hard link is the footage it leads to.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
same-file = { workspace = true }
|
||||
@@ -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))"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Loaded 100 of 684 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user