diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 000000000..2a4cd2cb9 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,3 @@ +# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI +# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET). +nextest-version = "0.9.150" diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 1353830f8..b903f82ea 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -22,3 +22,27 @@ jobs: lint: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main + + windows-install-policy: + timeout-minutes: 10 + name: Windows installation policy (no app build) + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install the pinned packaging toolchain + shell: pwsh + run: | + $tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix' + dotnet tool install --tool-path $tools wix --version 5.0.2 + if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' } + $tools >> $env:GITHUB_PATH + $policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json + foreach ($extension in @($policy.extensions)) { + & (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2" + if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." } + } + - name: Build and check non-executable installer fixtures + shell: pwsh + run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests') diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3cfbdc8b0..603b21c18 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,6 +18,7 @@ jobs: release: permissions: contents: write + packages: write # 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/release.yml@main with: diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index e78fe1ea3..8f1aa5ad5 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -16,6 +16,9 @@ concurrency: jobs: sonarcloud: - # Tracked at @main so org-wide CI improvements propagate without PR churn. + # Track shared CI improvements on main. uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main + with: + timeout-minutes: 120 + fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 secrets: inherit diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index a074e6dbe..2cedc1a57 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -33,3 +33,5 @@ jobs: with: app-name: effectcraft app-dir: EffectCraft + install-relative-path: Programs/ArtCraft/EffectCraft + install-scope: perUser diff --git a/AGENTS.md b/AGENTS.md index 24abee6a1..97c35c2b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ People trust EffectCraft with hours of work. A malformed project, PSD, SVG, Lott expression or script, a broken plug-in, a bad control-channel or MCP argument, a corrupt preferences file or a full disk must produce an error the user (or agent) can act on, never a crash and never lost work. **This rule outranks feature work:** don't ship a feature by adding a panic path, and fix a crash before -building on top of it. Full standard: [craftrules `standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md). +building on top of it. - **Fail with `Result`** through the crate's error type and propagate with `?`; add context (`map_err`, an error variant) rather than discarding it. Lenient readers fall back and record the repair. @@ -108,8 +108,7 @@ a command, everything is agent-drivable, and the quality gates (`cargo xtask ci` - **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in -[`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in -[craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). +[`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`. @@ -118,7 +117,7 @@ as PhotoCraft does with `cargo xtask corpus`. - [ROADMAP.md](ROADMAP.md): stage, headline numbers and what's next - [docs/gaps.md](docs/gaps.md): every known shortfall against After Effects, ranked, and the prioritised workstreams (G1–G9); read it before choosing work -- [docs/target-app-parity.md](docs/target-app-parity.md): the parity numbers and how they were measured, with the per-area docs it links (UI, file formats, hardware, localization, effects, scripting); progress docs follow [craftrules `standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md) +- [docs/target-app-parity.md](docs/target-app-parity.md): the parity numbers and how they were measured, with the per-area docs it links (UI, file formats, hardware, localization, effects, scripting) - [CONTRIBUTING.md](CONTRIBUTING.md): setup, gates, commits, how to add things - [docs/architecture.md](docs/architecture.md): layers, data model, commands, pipeline - [docs/testing.md](docs/testing.md): oracle tests, criteria, benchmarks @@ -136,11 +135,13 @@ as PhotoCraft does with `cargo xtask corpus`. username has a `[people.]` entry in `contributors/people.toml`. If not, ask them once whether they want to be credited by more than their username: a real name, a display name, and/or their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry - (copy the template at the top of the file, or run - `python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`) + (copy the template at the top of the file) and include it in their PR, committed as them. If no, change nothing: they are credited as `@username` anyway. - Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or 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. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 77a9cc432..c97d32ed9 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -89,6 +89,8 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall `crates/engine/presets/transitions.json` (sidecar `.attribution`). - Backgrounds animation presets (23: drifting gradients, fog, cloud and smoke, plasma and magma, cells and crystals, scrolling grids and checks, pulse rings, bokeh, light sweep): `crates/engine/presets/backgrounds.json` (sidecar `.attribution`). +- Behaviors animation presets (22: wiggle, shake, float, orbit, bounce, drift, pendulum, spin, pulse, heartbeat, blink, strobe, flicker): + `crates/engine/presets/behaviors.json` (sidecar `.attribution`). - Sample scripts (File ▸ Scripts) and ScriptUI panel: `crates/engine/scripts/Rename Layers.jsx`, `crates/engine/scripts/Sort Layers by In Point.jsx`, `crates/engine/scripts/Create Null at Selected Layers.jsx`, `crates/engine/scripts/Render Queue Batch.jsx`, `crates/engine/scripts/ScriptUI Panels/Layer Tools.jsx` diff --git a/CLAUDE.md b/CLAUDE.md index e52386942..640b9800e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual e ## Non-negotiables **Read [`AGENTS.md`](AGENTS.md) first; its rules override everything here.** No Adobe assets, every asset licensed + attributed (`cargo xtask assets`). -- **Never crash** (AGENTS.md "Never crash"; outranks feature work: a crash loses people's work). Non-test code has no `unwrap()`/`expect()`/`panic!`/`unreachable!`/`todo!`/`unimplemented!` and no `unsafe`: return `Result` and use `?`, `ok_or`, `let … else`. Input-derived indices, slices and arithmetic use `get()` and checked/saturating math; recursion is depth-limited; locks handle poisoning. The app's last-resort guard turns an escaped panic into an error and keeps the document. Every crash fix gets a regression test. Standard: [craftrules `standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md). +- **Never crash** (AGENTS.md "Never crash"; outranks feature work: a crash loses people's work). Non-test code has no `unwrap()`/`expect()`/`panic!`/`unreachable!`/`todo!`/`unimplemented!` and no `unsafe`: return `Result` and use `?`, `ok_or`, `let … else`. Input-derived indices, slices and arithmetic use `get()` and checked/saturating math; recursion is depth-limited; locks handle poisoning. The app's last-resort guard turns an escaped panic into an error and keeps the document. Every crash fix gets a regression test. - **Clean-room.** Behaviour and public docs only. No GPL/LGPL/AGPL code. ffmpeg only as an external test oracle. - **Pure Rust.** Media codecs come from FilmCraft crates (git deps behind `crates/media`, see `plan/adr/0001`). - **Layering** (`cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd/cpal; L0–L4 build for wasm32. @@ -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 `); 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-`; 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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dfa28c9f0..29521ad73 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/Cargo.lock b/Cargo.lock index 512889540..7d27b959f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -209,14 +209,14 @@ dependencies = [ "clipboard-win", "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.59.0", "wl-clipboard-rs", "x11rb", ] @@ -437,15 +437,6 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec 0.8.0", -] - [[package]] name = "bit-set" version = "0.10.0" @@ -456,10 +447,13 @@ dependencies = [ ] [[package]] -name = "bit-vec" -version = "0.8.0" +name = "bit-set" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec 0.10.1", +] [[package]] name = "bit-vec" @@ -467,6 +461,16 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "bit_field" version = "0.10.3" @@ -509,7 +513,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -669,6 +673,30 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -695,6 +723,12 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -758,6 +792,45 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "candle-core" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c15b675b80d994b2eadb20a4bbe434eabeb454eac3ee5e2b4cf6f147ee9be091" +dependencies = [ + "byteorder", + "float8", + "gemm", + "half", + "libm", + "memmap2", + "num-traits", + "num_cpus", + "rand 0.9.5", + "rand_distr", + "rayon", + "safetensors", + "thiserror 2.0.21", + "yoke", + "zip 7.4.0", +] + +[[package]] +name = "candle-nn" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3045fa9e7aef8567d209a27d56b692f60b96f4d0569f4c3011f8ca6715c65e03" +dependencies = [ + "candle-core", + "half", + "libc", + "num-traits", + "rayon", + "safetensors", + "serde", + "thiserror 2.0.21", +] + [[package]] name = "cast" version = "0.3.0" @@ -832,6 +905,15 @@ dependencies = [ "error-code", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -1010,36 +1092,98 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" + +[[package]] +name = "craft-ecproj-format" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=ca1b5e2edb8568335c6c14f390307bd5beae8238#ca1b5e2edb8568335c6c14f390307bd5beae8238" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "craft-fonts-build" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" + +[[package]] +name = "craft-i18n" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "fixed_decimal", + "icu_decimal", + "icu_locale_core", + "icu_locale_fallback", + "icu_normalizer", + "icu_plurals", + "icu_properties", + "icu_provider", + "icu_provider_adapters", + "icu_provider_blob", + "log", + "objc2-foundation 0.3.2", + "sys-locale", + "unicode-bidi", + "windows-registry", + "writeable", +] [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "hayro-syntax", "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" -source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ + "craft-fonts-build", + "craft-i18n", "egui", + "epaint", + "js-sys", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "serde", + "skrifa", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "chrono", "dunce", "egui", "flate2", "getrandom 0.3.4", + "image", "log", "plist", "semver", @@ -1052,7 +1196,7 @@ dependencies = [ "verifysign", "winreg", "winsafe", - "zip", + "zip 9.1.3", ] [[package]] @@ -1171,7 +1315,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -1221,6 +1365,22 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-stack" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c4713e43e2886ba72b8271aa66c93d722116acf7a75555cce11dcde84388fe8" +dependencies = [ + "bytemuck", + "dyn-stack-macros", +] + +[[package]] +name = "dyn-stack-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9" + [[package]] name = "dynify" version = "0.1.2" @@ -1252,7 +1412,7 @@ dependencies = [ [[package]] name = "effectcraft" -version = "0.17.0" +version = "0.20.0" dependencies = [ "cpal", "craft-update", @@ -1264,10 +1424,10 @@ dependencies = [ "fmv-macos-events", "log", "muda", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", - "pollster 0.4.0", + "pollster 1.0.1", "rfd", "serde", "serde_json", @@ -1278,10 +1438,11 @@ dependencies = [ [[package]] name = "effectcraft-automation" -version = "0.17.0" +version = "0.20.0" dependencies = [ "effectcraft-engine", "effectcraft-host", + "effectcraft-model", "effectcraft-script", "image", "serde_json", @@ -1290,14 +1451,14 @@ dependencies = [ [[package]] name = "effectcraft-av1enc" -version = "0.17.0" +version = "0.20.0" dependencies = [ "filmcraft-av1", ] [[package]] name = "effectcraft-cli" -version = "0.17.0" +version = "0.20.0" dependencies = [ "cpu-time", "effectcraft-automation", @@ -1305,7 +1466,10 @@ dependencies = [ "effectcraft-gpu", "effectcraft-host", "effectcraft-render", + "effectcraft-sam2", + "effectcraft-segment", "effectcraft-time", + "effectcraft-track", "effectcraft-ui-egui", "image", "serde_json", @@ -1313,7 +1477,7 @@ dependencies = [ [[package]] name = "effectcraft-color" -version = "0.17.0" +version = "0.20.0" dependencies = [ "proptest", "serde", @@ -1321,7 +1485,7 @@ dependencies = [ [[package]] name = "effectcraft-effects" -version = "0.17.0" +version = "0.20.0" dependencies = [ "chrono", "effectcraft-color", @@ -1339,7 +1503,7 @@ dependencies = [ [[package]] name = "effectcraft-engine" -version = "0.17.0" +version = "0.20.0" dependencies = [ "chrono", "craft-docpath", @@ -1356,6 +1520,7 @@ dependencies = [ "effectcraft-psd", "effectcraft-raster", "effectcraft-render", + "effectcraft-sam2", "effectcraft-segment", "effectcraft-svg", "effectcraft-text", @@ -1366,15 +1531,17 @@ dependencies = [ "kurbo", "log", "rayon", + "same-file", "serde", "serde_json", + "tempfile", "thiserror 2.0.21", "web-time", ] [[package]] name = "effectcraft-export" -version = "0.17.0" +version = "0.20.0" dependencies = [ "effectcraft-av1enc", "effectcraft-color", @@ -1404,7 +1571,7 @@ dependencies = [ [[package]] name = "effectcraft-expr" -version = "0.17.0" +version = "0.20.0" dependencies = [ "boa_engine", "effectcraft-color", @@ -1421,7 +1588,7 @@ dependencies = [ [[package]] name = "effectcraft-geom" -version = "0.17.0" +version = "0.20.0" dependencies = [ "proptest", "serde", @@ -1429,7 +1596,7 @@ dependencies = [ [[package]] name = "effectcraft-gpu" -version = "0.17.0" +version = "0.20.0" dependencies = [ "bytemuck", "effectcraft-color", @@ -1443,7 +1610,7 @@ dependencies = [ "effectcraft-time", "js-sys", "log", - "pollster 0.4.0", + "pollster 1.0.1", "wasm-bindgen", "web-time", "wgpu", @@ -1451,14 +1618,14 @@ dependencies = [ [[package]] name = "effectcraft-hevcenc" -version = "0.17.0" +version = "0.20.0" dependencies = [ "filmcraft-hevc", ] [[package]] name = "effectcraft-host" -version = "0.17.0" +version = "0.20.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -1483,7 +1650,7 @@ dependencies = [ [[package]] name = "effectcraft-interchange" -version = "0.17.0" +version = "0.20.0" dependencies = [ "effectcraft-color", "effectcraft-keyframe", @@ -1502,7 +1669,7 @@ dependencies = [ [[package]] name = "effectcraft-keyframe" -version = "0.17.0" +version = "0.20.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1514,7 +1681,7 @@ dependencies = [ [[package]] name = "effectcraft-lottie" -version = "0.17.0" +version = "0.20.0" dependencies = [ "craft-docpath", "effectcraft-color", @@ -1532,9 +1699,10 @@ dependencies = [ [[package]] name = "effectcraft-media" -version = "0.17.0" +version = "0.20.0" dependencies = [ "craft-docpath", + "effectcraft-color", "effectcraft-model", "effectcraft-pdf", "effectcraft-project", @@ -1544,28 +1712,31 @@ 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.20.0" dependencies = [ "effectcraft-geom", "image", - "miniz_oxide 0.8.9", + "log", + "miniz_oxide 0.9.1", "serde", "serde_json", "thiserror 2.0.21", @@ -1573,14 +1744,14 @@ dependencies = [ [[package]] name = "effectcraft-opusenc" -version = "0.17.0" +version = "0.20.0" dependencies = [ "filmcraft-opus", ] [[package]] name = "effectcraft-path" -version = "0.17.0" +version = "0.20.0" dependencies = [ "effectcraft-geom", "effectcraft-keyframe", @@ -1592,7 +1763,7 @@ dependencies = [ [[package]] name = "effectcraft-pdf" -version = "0.17.0" +version = "0.20.0" dependencies = [ "craft-pdf", "effectcraft-raster", @@ -1600,7 +1771,7 @@ dependencies = [ "effectcraft-text", "image", "kurbo", - "miniz_oxide 0.8.9", + "miniz_oxide 0.9.1", "skrifa", "wasm-bindgen-test", "zune-jpeg", @@ -1608,7 +1779,7 @@ dependencies = [ [[package]] name = "effectcraft-plugin" -version = "0.17.0" +version = "0.20.0" dependencies = [ "effectcraft-effects", "effectcraft-keyframe", @@ -1620,8 +1791,9 @@ dependencies = [ [[package]] name = "effectcraft-project" -version = "0.17.0" +version = "0.20.0" dependencies = [ + "craft-ecproj-format", "effectcraft-color", "effectcraft-geom", "effectcraft-keyframe", @@ -1633,14 +1805,14 @@ dependencies = [ [[package]] name = "effectcraft-psd" -version = "0.17.0" +version = "0.20.0" dependencies = [ - "miniz_oxide 0.8.9", + "miniz_oxide 0.9.1", ] [[package]] name = "effectcraft-raster" -version = "0.17.0" +version = "0.20.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1649,7 +1821,7 @@ dependencies = [ [[package]] name = "effectcraft-render" -version = "0.17.0" +version = "0.20.0" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1669,9 +1841,24 @@ dependencies = [ "web-time", ] +[[package]] +name = "effectcraft-sam2" +version = "0.20.0" +dependencies = [ + "bytemuck", + "candle-core", + "candle-nn", + "effectcraft-segment", + "half", + "log", + "pollster 1.0.1", + "rayon", + "wgpu", +] + [[package]] name = "effectcraft-script" -version = "0.17.0" +version = "0.20.0" dependencies = [ "boa_engine", "effectcraft-engine", @@ -1689,16 +1876,16 @@ dependencies = [ [[package]] name = "effectcraft-segment" -version = "0.17.0" +version = "0.20.0" dependencies = [ - "miniz_oxide 0.8.9", + "miniz_oxide 0.9.1", "ndarray", "rayon", ] [[package]] name = "effectcraft-svg" -version = "0.17.0" +version = "0.20.0" dependencies = [ "effectcraft-geom", "effectcraft-path", @@ -1710,7 +1897,7 @@ dependencies = [ [[package]] name = "effectcraft-text" -version = "0.17.0" +version = "0.20.0" dependencies = [ "effectcraft-keyframe", "harfrust", @@ -1722,7 +1909,7 @@ dependencies = [ [[package]] name = "effectcraft-time" -version = "0.17.0" +version = "0.20.0" dependencies = [ "proptest", "serde", @@ -1730,7 +1917,7 @@ dependencies = [ [[package]] name = "effectcraft-track" -version = "0.17.0" +version = "0.20.0" dependencies = [ "effectcraft-raster", "effectcraft-segment", @@ -1742,9 +1929,10 @@ dependencies = [ [[package]] name = "effectcraft-ui-egui" -version = "0.17.0" +version = "0.20.0" dependencies = [ "chrono", + "craft-quit", "craft-ui", "craft-update", "effectcraft-color", @@ -1756,6 +1944,7 @@ dependencies = [ "effectcraft-path", "effectcraft-project", "effectcraft-render", + "effectcraft-segment", "effectcraft-text", "effectcraft-time", "eframe", @@ -1765,7 +1954,7 @@ dependencies = [ "image", "kurbo", "log", - "pollster 0.4.0", + "pollster 1.0.1", "rayon", "serde", "serde_json", @@ -1776,11 +1965,11 @@ dependencies = [ [[package]] name = "effectcraft-vp9enc" -version = "0.17.0" +version = "0.20.0" [[package]] name = "effectcraft-web" -version = "0.17.0" +version = "0.20.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -1818,7 +2007,7 @@ dependencies = [ "image", "js-sys", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "parking_lot", @@ -1886,7 +2075,7 @@ dependencies = [ "egui", "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", @@ -1964,6 +2153,18 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -2049,7 +2250,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2155,8 +2356,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", @@ -2164,40 +2365,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", @@ -2207,6 +2425,7 @@ dependencies = [ "filmcraft-isobmff", "filmcraft-matroska", "filmcraft-media", + "filmcraft-mpa", "filmcraft-mpeg2v", "filmcraft-mpegts", "filmcraft-mxf", @@ -2214,9 +2433,11 @@ dependencies = [ "filmcraft-opus", "filmcraft-prores", "filmcraft-time", + "filmcraft-vp8", "filmcraft-vp9", "image", "log", + "rayon", "symphonia", "thiserror 2.0.21", "web-time", @@ -2224,16 +2445,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", @@ -2242,8 +2472,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", @@ -2253,16 +2483,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", @@ -2271,8 +2501,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", @@ -2281,8 +2511,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", @@ -2291,8 +2521,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", @@ -2308,8 +2538,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", @@ -2317,15 +2547,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", @@ -2334,10 +2565,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", @@ -2345,31 +2585,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", @@ -2382,8 +2622,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", @@ -2392,16 +2632,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", @@ -2414,6 +2662,17 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixed_decimal" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + [[package]] name = "fixedbitset" version = "0.5.7" @@ -2431,13 +2690,25 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "float8" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719a903cc23e4a89e87962c2a80fdb45cdaad0983a89bd150bb57b4c8571a7d5" +dependencies = [ + "half", + "num-traits", + "rand 0.9.5", + "rand_distr", +] + [[package]] name = "fmv-macos-events" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", ] @@ -2573,6 +2844,125 @@ dependencies = [ "slab", ] +[[package]] +name = "gemm" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa0673db364b12263d103b68337a68fbecc541d6f6b61ba72fe438654709eacb" +dependencies = [ + "dyn-stack", + "gemm-c32", + "gemm-c64", + "gemm-common", + "gemm-f16", + "gemm-f32", + "gemm-f64", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-c32" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "086936dbdcb99e37aad81d320f98f670e53c1e55a98bee70573e83f95beb128c" +dependencies = [ + "dyn-stack", + "gemm-common", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-c64" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c8aeeeec425959bda4d9827664029ba1501a90a0d1e6228e48bef741db3a3f" +dependencies = [ + "dyn-stack", + "gemm-common", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-common" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88027625910cc9b1085aaaa1c4bc46bb3a36aad323452b33c25b5e4e7c8e2a3e" +dependencies = [ + "bytemuck", + "dyn-stack", + "half", + "libm", + "num-complex", + "num-traits", + "once_cell", + "paste", + "pulp", + "raw-cpuid", + "rayon", + "seq-macro", + "sysctl", +] + +[[package]] +name = "gemm-f16" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3df7a55202e6cd6739d82ae3399c8e0c7e1402859b30e4cb780e61525d9486e" +dependencies = [ + "dyn-stack", + "gemm-common", + "gemm-f32", + "half", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "rayon", + "seq-macro", +] + +[[package]] +name = "gemm-f32" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02e0b8c9da1fbec6e3e3ab2ce6bc259ef18eb5f6f0d3e4edf54b75f9fd41a81c" +dependencies = [ + "dyn-stack", + "gemm-common", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-f64" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "056131e8f2a521bfab322f804ccd652520c79700d81209e9d9275bbdecaadc6a" +dependencies = [ + "dyn-stack", + "gemm-common", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -2693,7 +3083,7 @@ dependencies = [ "glutin_glx_sys", "glutin_wgl_sys", "libloading", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -2774,9 +3164,12 @@ version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ + "bytemuck", "cfg-if", "crunchy", "num-traits", + "rand 0.9.5", + "rand_distr", "zerocopy", ] @@ -2816,6 +3209,8 @@ dependencies = [ "allocator-api2", "equivalent", "foldhash 0.2.0", + "serde", + "serde_core", ] [[package]] @@ -2838,7 +3233,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "hayro-ccitt", ] @@ -2852,7 +3247,7 @@ checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "hayro-jbig2", "hayro-jpeg2000", @@ -2910,6 +3305,23 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_decimal" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_locale_core", + "icu_pattern", + "icu_plurals", + "icu_provider", + "serde", + "writeable", + "zerovec", +] + [[package]] name = "icu_locale_core" version = "2.3.0" @@ -2918,11 +3330,32 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + [[package]] name = "icu_normalizer" version = "2.3.0" @@ -2935,6 +3368,7 @@ dependencies = [ "icu_provider", "smallvec", "utf16_iter", + "utf8_iter", "write16", "zerovec", ] @@ -2945,6 +3379,32 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +[[package]] +name = "icu_pattern" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b3db611446bd92c246b03e4aa0bafa71ddb194af431ddbb42b9d70e4bfce1a0" +dependencies = [ + "displaydoc", + "either", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_plurals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_provider", + "serde", + "zerovec", +] + [[package]] name = "icu_properties" version = "2.3.0" @@ -2974,6 +3434,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "postcard", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -2981,6 +3444,30 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_provider_adapters" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820" +dependencies = [ + "icu_locale_fallback", + "icu_provider", +] + +[[package]] +name = "icu_provider_blob" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e" +dependencies = [ + "icu_provider", + "postcard", + "serde", + "writeable", + "zerotrie", + "zerovec", +] + [[package]] name = "idna" version = "1.1.0" @@ -3268,9 +3755,9 @@ checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" [[package]] name = "linesweeper" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c19728333c060c6569a53c9a0e56c4be0df52cb4e6e07a8fbe16084cecce769" +checksum = "a25df1b0c5432893f41bba817150e323703ee5f6d974f3a6808ac7fd7f046d18" dependencies = [ "arrayvec", "kurbo", @@ -3296,6 +3783,9 @@ name = "litemap" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +dependencies = [ + "serde_core", +] [[package]] name = "litrs" @@ -3320,9 +3810,9 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lz4_flex" -version = "0.11.6" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" +checksum = "ecbdfe44b1bd960b68170b417450a628c43f7cf56bb3c5317e61cb230ee7f226" [[package]] name = "mach2" @@ -3356,6 +3846,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", + "stable_deref_trait", ] [[package]] @@ -3409,21 +3900,20 @@ dependencies = [ [[package]] name = "muda" -version = "0.21.0" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6744ab645ebe79f9c428455c7cb9dfa4275fa3b50a25408e08f927da8dc618c" +checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03" dependencies = [ "crossbeam-channel", "dpi", "keyboard-types", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", - "once_cell", "png", "thiserror 2.0.21", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3548,7 +4038,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3608,6 +4098,16 @@ dependencies = [ "libm", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "num_enum" version = "0.7.6" @@ -3657,9 +4157,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", ] @@ -3688,7 +4188,7 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -3702,7 +4202,7 @@ checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" dependencies = [ "bitflags 2.13.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-audio", "objc2-core-audio-types", "objc2-core-foundation", @@ -3740,7 +4240,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" dependencies = [ "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-audio-types", "objc2-core-foundation", ] @@ -3752,7 +4252,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -3775,7 +4275,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -3786,7 +4286,7 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-io-surface", ] @@ -3842,7 +4342,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -3853,7 +4353,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -3889,7 +4389,7 @@ checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -3913,7 +4413,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -3958,7 +4458,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", ] @@ -4289,12 +4789,24 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -4345,16 +4857,17 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" [[package]] name = "proptest" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" dependencies = [ - "bit-set 0.8.0", - "bit-vec 0.8.0", + "bit-set 0.11.1", + "bit-vec 0.10.1", "bitflags 2.13.2", + "chacha20", + "core_detect", "num-traits", - "rand 0.9.5", - "rand_chacha", + "rand 0.10.3", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -4489,12 +5002,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] -name = "rand_xorshift" -version = "0.4.0" +name = "rand_distr" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" dependencies = [ - "rand_core 0.9.5", + "num-traits", + "rand 0.9.5", +] + +[[package]] +name = "rand_xorshift" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" +dependencies = [ + "rand_core 0.10.1", ] [[package]] @@ -4524,7 +5047,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", "objc2-quartz-core 0.3.2", @@ -4632,7 +5155,7 @@ dependencies = [ "js-sys", "libc", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -4664,9 +5187,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", ] @@ -4715,7 +5238,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4772,7 +5295,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4816,6 +5339,17 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" +[[package]] +name = "safetensors" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "675656c1eabb620b921efea4f9199f97fc86e36dd6ffd1fbbe48d0f59a4987f5" +dependencies = [ + "hashbrown 0.16.1", + "serde", + "serde_json", +] + [[package]] name = "same-file" version = "1.0.6" @@ -4902,6 +5436,12 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + [[package]] name = "serde" version = "1.0.229" @@ -4934,9 +5474,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -5142,7 +5682,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5423,7 +5963,24 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" dependencies = [ + "js-sys", "libc", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "sysctl" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc" +dependencies = [ + "bitflags 2.13.2", + "byteorder", + "enum-as-inner", + "libc", + "thiserror 1.0.69", + "walkdir", ] [[package]] @@ -5458,7 +6015,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5620,6 +6177,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -5741,6 +6299,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typed-path" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54ec2755506471f54baf5e4bb5499d8e510cc1caf271270e778a736bd2d7336c" + [[package]] name = "typed-path" version = "0.13.0" @@ -5761,7 +6325,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6326,7 +6890,7 @@ dependencies = [ "jni 0.22.4", "log", "ndk-context", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "url", @@ -6476,7 +7040,7 @@ dependencies = [ "naga", "naga-types", "ndk-sys", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -6553,7 +7117,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6665,6 +7229,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.1.2" @@ -6719,15 +7294,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -6761,30 +7327,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows-threading" version = "0.2.1" @@ -6806,12 +7355,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -6824,12 +7367,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -6842,24 +7379,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -6872,12 +7397,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -6890,12 +7409,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -6908,12 +7421,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -6926,12 +7433,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winit" version = "0.30.13" @@ -7004,9 +7505,9 @@ dependencies = [ [[package]] name = "winresource" -version = "0.1.31" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" dependencies = [ "toml", "version_check", @@ -7053,6 +7554,9 @@ name = "writeable" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +dependencies = [ + "either", +] [[package]] name = "x11-dl" @@ -7119,9 +7623,10 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.17.0" +version = "0.20.0" dependencies = [ "chrono", + "pkg-config", "serde_json", "tempfile", ] @@ -7309,8 +7814,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", + "litemap", + "serde_core", "yoke", "zerofrom", + "zerovec", ] [[package]] @@ -7319,6 +7827,7 @@ version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -7337,9 +7846,21 @@ dependencies = [ [[package]] name = "zip" -version = "9.1.0" +version = "7.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704" +checksum = "cc12baa6db2b15a140161ce53d72209dacea594230798c24774139b54ecaa980" +dependencies = [ + "crc32fast", + "indexmap", + "memchr", + "typed-path 0.12.4", +] + +[[package]] +name = "zip" +version = "9.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb" dependencies = [ "crc32fast", "flate2", @@ -7347,7 +7868,7 @@ dependencies = [ "indexmap", "memchr", "nix", - "typed-path", + "typed-path 0.13.0", "windows-sys 0.61.2", "zopfli", ] diff --git a/Cargo.toml b/Cargo.toml index 2057b6e19..9e63ffc26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,16 +4,19 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*"] [workspace.package] -version = "0.17.0" +version = "0.20.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" repository = "https://github.com/storytold/effectcraft" [workspace.dependencies] -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0", default-features = false, features = ["read"] } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["read"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-quit = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-ecproj-format = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "ca1b5e2edb8568335c6c14f390307bd5beae8238" } +# craft-update: app-specific pretend versions apply to startup and manual checks. +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] } # internal effectcraft-time = { path = "crates/time" } effectcraft-geom = { path = "crates/geom" } @@ -26,6 +29,7 @@ effectcraft-text = { path = "crates/text" } effectcraft-effects = { path = "crates/effects" } effectcraft-track = { path = "crates/track" } effectcraft-segment = { path = "crates/segment" } +effectcraft-sam2 = { path = "crates/sam2" } effectcraft-model = { path = "crates/model" } effectcraft-render = { path = "crates/render" } effectcraft-gpu = { path = "crates/gpu" } @@ -73,42 +77,44 @@ 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" +tempfile = "3.27" # 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"] } bytemuck = "1" -pollster = "0.4" +pollster = "1" # ui # Shared frontend components; keep this dependency above the engine boundary. # Shared UI and the complete egui family select reviewed immutable commits below. -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f", features = ["serde"] } +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] } # `default_fonts` and `monochrome_emoji_fonts` are explicit: the app's `FontDefinitions::default()` # and its `✔`/`▣` chrome glyphs need both, and kittest's own dev-dependencies do not propagate. egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] } @@ -130,14 +136,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 +160,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 @@ -220,8 +237,8 @@ lto = "thin" codegen-units = 1 [patch.crates-io] -hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.7.2" } -hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.3.0" } +hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.7.2" } +hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.3.0" } ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } diff --git a/README.md b/README.md index 5fa28f0bb..81b5a0ba5 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@

A free, open-source compositor: compositions, layers, - keyframes, 306 effects, layer styles, expressions, 3D cameras and lights, and a render queue, native on macOS, + keyframes, 308 effects, layer styles, expressions, 3D cameras and lights, and a render queue, native on macOS, Windows and Linux, and in the browser. Young, moving fast, and already usable.

@@ -84,9 +84,10 @@ a few places where it matters to us: without a plugin. - **A project file you can read.** Projects are versioned JSON (`.ecproj`), so they diff cleanly in version control. -- **Everything is scriptable.** Every menu item, timeline drag and property edit goes through one - command registry, so the same actions are reachable from a command line, a JSON control - channel and an MCP server for agents. +- **Everything is scriptable.** Menu actions, timeline edits and property changes are exposed as + commands. Engine commands are available through the CLI, JSON control channel and MCP; + UI-only commands use the frontend dispatcher and can be invoked through the control channel + or an attached MCP bridge. - **No FFmpeg.** Video and audio decoding and encoding are pure Rust: FilmCraft's codecs and EffectCraft's own VP9, AV1, HEVC and Opus encoders. @@ -126,7 +127,7 @@ or into the Timeline, between layers and at the time you point to. ## Effects -306 built-in effects across its categories, each with its parameter names, order and +308 built-in effects across its categories, each with its parameter names, order and defaults: blur and sharpen, channel, color correction (Curves, Levels, Hue/Saturation, a full color-grading panel…), distort (Warp, Bulge, Turbulent Displace, CC Power Pin…), generate (Fractal Noise, Gradient Ramp, Stroke, Write-on, Audio Spectrum…), keying, matte, noise and grain, perspective, @@ -155,7 +156,7 @@ tools, and Front, Top, Left and Custom views. A dedicated Render Queue, with Render Settings and Output Modules: **H.264** MP4 and **ProRes** MOV (Proxy to 4444 XQ with alpha) with audio, **HEVC** (Main / Main 10) and **AV1** -MP4, **WebM** (VP9 with inter frames and alpha, or AV1; Opus audio), PNG, JPEG, TIFF and 32-bit EXR sequences, animated GIF and WAV/AIFF. Field +MP4, **WebM** (VP9 with inter frames and alpha, or AV1; Opus audio), PNG, JPEG, TIFF, Targa and 32-bit EXR sequences, animated GIF and WAV/AIFF. Field rendering with 3:2 pulldown, effect/solo/guide/depth overrides, crop, region of interest and resize, Render Settings and Output Module templates with defaults, post-render actions, storage overflow and render logs. The same queue runs from the command line. Every encoder is pure @@ -173,8 +174,10 @@ cannot express; File ▸ Import ▸ Lottie… opens one as a new composition. ## Built for agents -Everything you can do from a menu is a command with an id, and agents can reach every one of -them: +Engine commands are available through the CLI, JSON control channel and MCP. UI-only commands +need the running app's frontend dispatcher: complete menu access requires the app's control +channel or an attached MCP bridge. Headless MCP sessions expose supported engine commands, +but not UI-only commands or bridge-only `ui_*` tools. - **MCP server:** `effectcraft-cli mcp` speaks the Model Context Protocol over stdio, headless or bridged to the running app (`--bridge 9877`). Tools cover commands, the project and property @@ -228,7 +231,7 @@ tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING ## Where it stands EffectCraft is young: its first commit was on 1 October 2026. A comprehensive feature set -exists, including 306 effects, but breadth is not the same as full maturity across every edge +exists, including 308 effects, but breadth is not the same as full maturity across every edge case. Today it's a good place to try things and to tell us what breaks. It isn't yet recommended for production client work. In particular: @@ -275,9 +278,13 @@ send us right now. | arm64 (Snapdragon and other ARM PCs) | `effectcraft--windows-arm64.msi` | `effectcraft--windows-arm64-portable.zip` | | x86 (32-bit) | `effectcraft--windows-x86.msi` | `effectcraft--windows-x86-portable.zip` | -Installers and executables are code-signed. The installer asks where to install EffectCraft -(Program Files by default), and later versions install into the same folder. To install silently +Installers and executables are code-signed. The installer asks who to install EffectCraft for and where. By default it installs for your +account only, into `%LOCALAPPDATA%\Programs\ArtCraft\EffectCraft`, without an administrator prompt; +"Everyone on this computer" installs into `C:\Program Files\ArtCraft\EffectCraft` (`Program Files (x86)` for the 32-bit build). Later versions +install into the same folder. To install silently into a folder of your choice: `msiexec /i effectcraft--windows-x64.msi /qn INSTALLFOLDER="D:\Apps\EffectCraft"`. +A silent install is per-user by default. `INSTALLFOLDER` chooses the folder, not who it is installed for; +add `ALLUSERS=1`, from an elevated terminal or deployment tool, for an all-users install. ### macOS @@ -337,8 +344,17 @@ stand on its own. | | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) | | | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) | | | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) | -| | **EffectCraft** | **Motion graphics and visual effects · you are here** | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) | +| | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) | | | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) | +| | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) | +| | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) | +| | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) | +| | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) | +| | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) | +| | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) | +| | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) | +| | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) | +| | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | | And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control. diff --git a/ROADMAP.md b/ROADMAP.md index f4c99d61f..dff795d85 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~30 points of "ready for real work" and ~350–620 h away -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (open-issue count, crash list and localization share re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 (26.5.0.89) +> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-30 · **Change:** minor (features of 30 October in the progress log: text Line Join, Timeline drag-reorder and multi-select of effects, viewer edge pan, ScriptUI drag capture and dock size; previously: localization recount after #1774) · **Target:** Adobe After Effects 2026 (26.5.0.89) EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written from scratch in Rust. This page is the one-page summary; the evidence is in @@ -51,7 +51,7 @@ Linux/Windows reliability (30–50 h) and depth in animation, masks, effects and | UI/UX fidelity | 65% | 60–120 | [ui-parity.md](docs/ui-parity.md) | | File formats | 50% | 130–230 | [file-format-parity.md](docs/file-format-parity.md) | | Hardware | 40% | 60–120 | [hardware-parity.md](docs/hardware-parity.md) | -| Localization | 18% | 60–110 + review | [localization-parity.md](docs/localization-parity.md) | +| Localization | 24% | 60–110 + review | [localization-parity.md](docs/localization-parity.md) | | Performance | 35% | 50–100 | [gaps.md](docs/gaps.md#g5-performance-at-real-world-scale) | | Stability | 45% | 40–80 | [gaps.md](docs/gaps.md#g3-stability-and-a-green-main) | | Platforms | 65% | 30–60 | [gaps.md](docs/gaps.md#g2-real-user-reliability-on-every-platform) | @@ -80,24 +80,24 @@ Weights and evidence per row: [target-app-parity.md](docs/target-app-parity.md#b ## Languages -UI strings translated out of ≈ 3,900 (detail: [localization-parity.md](docs/localization-parity.md)). +UI strings translated out of ≈ 4,200 (detail: [localization-parity.md](docs/localization-parity.md)). | Language | Status | % | |---|---|---:| | English | full | 100% | -| Simplified Chinese | partial (menus, part of the panels) | 29% | -| Spanish | menus only | 16% | +| Simplified Chinese | partial (menus, most of the panels) | 46% | +| Spanish | menus only | 29% | | Hindi | none | 0% | | Arabic | none | 0% | | French | menus only | 16% | -| Portuguese (Brazil) | partial (menus, part of the panels) | 26% | +| Portuguese (Brazil) | partial (menus, part of the panels) | 34% | | Indonesian | none | 0% | -| Japanese | partial (menus, part of the panels) | 27% | +| Japanese | partial (menus, most of the panels) | 46% | | German | menus only | 16% | | Korean | none | 0% | | Vietnamese | none | 0% | -Also shipped: Traditional Chinese (partial, 29%), Ukrainian (partial, 29%), Czech (partial, 29%), Georgian (partial, 38%; native-speaker reviewed) and Russian (menus only, 16%). No other language has had +Also shipped: Traditional Chinese (partial, 38%), Ukrainian (partial, 29%), Czech (partial, 29%), Polish (partial, 34%), Georgian (partial, 39%; native-speaker reviewed), Russian (menus only, 15%) and Swedish (menus, panels and effect names; not yet measured). 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 @@ -132,10 +155,9 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). design — alongside its 618/618 menus. - 2026-10-10: Full parity re-measure against After Effects 2026 26.5 (installed bundle, 26.2–26.5 release notes, repository counts): breadth ≈ 91%, ready ≈ 45%, stage alpha. Progress docs - follow the shared standard: `docs/parity.md` became `docs/target-app-parity.md`; new + restructured: `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 | major | Progress docs restructured: 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-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-05 | major | Two numbers (breadth vs real use) and workstreams G1–G9 | diff --git a/apps/effectcraft-cli/Cargo.toml b/apps/effectcraft-cli/Cargo.toml index b5784f4eb..abfc014eb 100644 --- a/apps/effectcraft-cli/Cargo.toml +++ b/apps/effectcraft-cli/Cargo.toml @@ -20,6 +20,9 @@ effectcraft-ui-egui = { workspace = true } effectcraft-render = { workspace = true } effectcraft-gpu = { workspace = true } effectcraft-time = { workspace = true } +effectcraft-track = { workspace = true } +effectcraft-segment = { workspace = true } +effectcraft-sam2 = { workspace = true } serde_json = { workspace = true } image = { workspace = true } cpu-time = "1" diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index 6e5b8d6cc..6ba352247 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -7,11 +7,12 @@ //! effectcraft-cli exec --list [--filter TEXT] [--schemas] list engine commands (as `commands`) //! effectcraft-cli run [ …] run several commands in order //! effectcraft-cli run --args-file F the same, one argument per line of F +//! effectcraft-cli run --commands FILE|- steps {"command", "params"} from FILE or stdin //! effectcraft-cli props [--flat] [--time S] a layer's property tree (with paths) //! effectcraft-cli get [--time S] read a property //! effectcraft-cli set [--time S] [--expression E] //! effectcraft-cli render-frame [--comp C] [--time S|--frame N] [--max-side PX|--scale K] [--out F.png] [--transparent] -//! effectcraft-cli render [--comp C] --out FILE [--format h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff] [--start S] [--end S] +//! effectcraft-cli render [--comp C] --out FILE [--format h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff] [--start S] [--end S] //! [--work-area] [--fps N] [--resolution full|half|third|quarter|K] [--quality best|draft] [--channels rgb|rgba] //! [--jpeg-quality N] [--bitrate KBPS] [--prores proxy|lt|standard|hq|4444|4444xq] [--audio auto|on|off] //! [--profile main|main10] [--level auto|4.1] [--rate-control bitrate|quality] [--video-quality 1-100] @@ -26,6 +27,8 @@ //! (--gpu: CPU vs GPU ms/frame for every comp at Full and Half) //! effectcraft-cli bench --ops [--small] [--layers N] [--comps N] [--footage N] everyday-operation timings //! on a large generated project (open, save, auto-save, undo/redo, timeline, Project panel) +//! effectcraft-cli bench --roto [--frames N] [--sam2 model.safetensors] [--mobilesam mobile_sam.pt] [--fixes] [--clip FRAMES --truth MASKS] [--json] Roto Brush trackers on synthetic clips with known mattes +//! (classical engine, MobileSAM and SAM 2.1 (any installed size) when installed; IoU, flicker, re-appearance, seconds per frame) //! effectcraft-cli script FILE.jsx [F.ecproj] | --eval CODE run an After Effects-style script //! effectcraft-cli mcp [--autosave | --bridge PORT] MCP server on stdio //! @@ -98,6 +101,10 @@ const USAGE: &str = "usage: effectcraft-cli [--params JSON] run one engine command (exec --list: list them, as `commands`) run [ ...] run several commands in order run --args-file F the same, one argument per line of F (no command-line length limit) + run --commands FILE|- steps from FILE, or standard input for -: a JSON array of + {\"command\": ID, \"params\": {...}} (or \"id\" for \"command\"), or one + such object per line (JSON Lines); at most 64 MiB and 100000 steps. + Repeatable; run after the pairs and --args-file props [--flat] [--time S] a layer's property tree with paths get [--time S] read a property set [--time S] [--expression E] @@ -107,7 +114,7 @@ const USAGE: &str = "usage: effectcraft-cli ) -> Result raw, Err(e) => fail_usage(&e), @@ -334,6 +355,87 @@ impl From for Failure { } } +/// Most bytes `run --commands` reads from a file or standard input: the steps are untrusted input. +const MAX_COMMANDS_BYTES: u64 = 64 * 1024 * 1024; +/// Most steps one `run --commands` takes. +const MAX_COMMANDS_STEPS: usize = 100_000; + +/// The text of `--commands source`: the file `source`, or standard input for `-`, refused past +/// [`MAX_COMMANDS_BYTES`]. This reader, [`parse_commands`] and [`command_step`] are ported from +/// vectorcraft-cli's (storytold/vectorcraft#3630), with a tuple in place of its `Step` enum. +fn read_commands(source: &str) -> Result { + use std::io::Read; + let mut bytes = vec![]; + let read = if source == "-" { + std::io::stdin().lock().take(MAX_COMMANDS_BYTES + 1).read_to_end(&mut bytes) + } else { + std::fs::File::open(source).and_then(|f| f.take(MAX_COMMANDS_BYTES + 1).read_to_end(&mut bytes)) + }; + read.map_err(|e| format!("--commands {source}: {e}"))?; + if bytes.len() as u64 > MAX_COMMANDS_BYTES { + return Err(format!("--commands {source}: more than {} MiB of steps", MAX_COMMANDS_BYTES / (1024 * 1024))); + } + String::from_utf8(bytes).map_err(|_| format!("--commands {source}: not UTF-8 text")) +} + +/// The steps of `--commands source` from its `text`: a JSON array of `{"command": ID, "params": +/// {…}}` objects (`"id"` may stand for `"command"`), or one such object per line (JSON Lines, blank +/// lines skipped). `first` is the run's index of the first of them, which an error names with the +/// entry's place in the text. +fn parse_commands(source: &str, text: &str, first: usize) -> Result, String> { + // Notepad on Windows starts UTF-8 files with a byte order mark. + let text = text.strip_prefix('\u{feff}').unwrap_or(text); + let too_many = || format!("--commands {source}: more than {MAX_COMMANDS_STEPS} steps"); + // Each entry with where it is in the text: its line for JSON Lines. + let mut entries: Vec<(Option, Value)> = vec![]; + if text.trim_start().starts_with('[') { + let v: Value = serde_json::from_str(text).map_err(|e| format!("--commands {source}: {e}"))?; + let Value::Array(items) = v else { return Err(format!("--commands {source}: not a JSON array")) }; + if items.len() > MAX_COMMANDS_STEPS { + return Err(too_many()); + } + entries.extend(items.into_iter().map(|v| (None, v))); + } else { + for (n, line) in text.lines().enumerate().filter(|(_, l)| !l.trim().is_empty()) { + if entries.len() == MAX_COMMANDS_STEPS { + return Err(too_many()); + } + let v = serde_json::from_str(line).map_err(|e| format!("--commands {source}: step {} (line {}): {e}", first + entries.len(), n + 1))?; + entries.push((Some(n + 1), v)); + } + } + entries + .into_iter() + .enumerate() + .map(|(k, (line, v))| { + command_step(v).map_err(|e| { + let place = line.map_or_else(|| format!("entry {}", k + 1), |l| format!("line {l}")); + format!("--commands {source}: step {} ({place}): {e}", first + k) + }) + }) + .collect() +} + +/// The command id and params of one `--commands` entry, `{"command": ID, "params": {…}}`. +fn command_step(v: Value) -> Result<(String, Value), String> { + let Value::Object(mut o) = v else { return Err("a step must be an object such as {\"command\": \"comp.new\", \"params\": {}}".into()) }; + let id = match (o.remove("command"), o.remove("id")) { + (Some(Value::String(id)), None) | (None, Some(Value::String(id))) => id, + (Some(_), Some(_)) => return Err("give `command` or `id`, not both".into()), + (None, None) => return Err("no `command`".into()), + _ => return Err("`command` must be a string, a command id".into()), + }; + let params = match o.remove("params") { + None | Some(Value::Null) => json!({}), + Some(p @ Value::Object(_)) => p, + Some(_) => return Err(format!("`params` of {id} must be a JSON object")), + }; + if let Some(key) = o.keys().next() { + return Err(format!("unknown key `{key}`: a step has `command` (or `id`) and `params`")); + } + Ok((id, params)) +} + fn usage_err(m: &str) -> Result { Err(Failure::Usage(m.to_string())) } @@ -524,14 +626,14 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { let text = std::fs::read_to_string(f).map_err(|e| Failure::Usage(format!("--args-file {f}: {e}")))?; pairs.extend(text.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from)); } - if pairs.is_empty() { - return usage_err("run needs [] pairs"); + let sources = args.opts("--commands"); + if pairs.is_empty() && sources.is_empty() { + return usage_err("run needs [] pairs or --commands FILE|-"); } - let mut b = backend(args, true)?; - let mut results = vec![]; + let mut steps = vec![]; let mut i = 0; while i < pairs.len() { - let id = &pairs[i]; + let id = pairs[i].clone(); let params = match pairs.get(i + 1).filter(|p| p.trim_start().starts_with('{')) { Some(p) => { i += 1; @@ -540,9 +642,26 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { None => json!({}), }; i += 1; - let r = b.exec(id, params).map_err(|e| Failure::Error(format!("{id}: {e}")))?; - if let Some(e) = failed_render(id, &r) { - return Err(Failure::Error(format!("{id}: {e}"))); + steps.push((id, params)); + } + if sources.iter().filter(|s| **s == "-").count() > 1 { + return usage_err("--commands - is given twice: standard input is read once"); + } + for &source in &sources { + let text = read_commands(source).map_err(Failure::Usage)?; + steps.extend(parse_commands(source, &text, steps.len()).map_err(Failure::Usage)?); + } + if steps.is_empty() { + return Err(Failure::Usage(format!("--commands {}: no commands", sources.join(", ")))); + } + let mut b = backend(args, true)?; + let mut results = vec![]; + // The step's index closes the message: a generated list repeats ids, and callers match + // the leading `: `. + for (n, (id, params)) in steps.into_iter().enumerate() { + let r = b.exec(&id, params).map_err(|e| Failure::Error(format!("{id}: {e} (step {n})")))?; + if let Some(e) = failed_render(&id, &r) { + return Err(Failure::Error(format!("{id}: {e} (step {n})"))); } if !json_out { emit(&r, false); @@ -620,9 +739,8 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { } let out = args.opt("--out").unwrap_or("frame.png").to_string(); let t0 = std::time::Instant::now(); - let f = b.render_with(comp.as_ref(), time, max_side, args.flag("--transparent"))?; + let f = b.render_to_file(comp.as_ref(), time, max_side, args.flag("--transparent"), &out)?; let ms = t0.elapsed().as_secs_f64() * 1000.0; - std::fs::write(&out, &f.png).map_err(|e| format!("cannot write {out}: {e}"))?; let info = json!({"path": out, "comp": f.comp, "time": f.time, "width": f.width, "height": f.height, "ms": (ms * 10.0).round() / 10.0}); if json_out { emit(&info, true); @@ -783,6 +901,7 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> { r["outputModuleSummary"].as_str().unwrap_or("") ); } + let attempted: std::collections::HashSet = 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 +940,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 => { @@ -868,6 +987,9 @@ fn bench_cmd(args: &Args) -> Result<(), Failure> { if args.flag("--ops") { return bench_ops(args); } + if args.flag("--roto") { + return bench_roto(args); + } let mut s = effectcraft_host::session(); match &args.project { Some(p) => s.execute("file.open", json!({"path": p})), @@ -890,6 +1012,117 @@ fn bench_cmd(args: &Args) -> Result<(), Failure> { Ok(()) } +/// The model a bench option asks for: the file named on the command line, where failing to read or +/// build it is an error saying why (not "not installed"); else the installed file, where a file that +/// cannot be used just leaves the model out. +fn bench_weights( + flag: &str, + named: Option<&str>, + installed: Option, + build: impl Fn(&[u8]) -> Result, +) -> Result, Failure> { + match named { + Some(path) => { + let bytes = std::fs::read(path).map_err(|e| Failure::Error(format!("{flag} {path}: {e}")))?; + build(&bytes).map(Some).map_err(|e| Failure::Error(format!("{flag} {path}: {e}"))) + } + None => Ok(installed.and_then(|p| std::fs::read(p).ok()).and_then(|bytes| build(&bytes).ok())), + } +} + +/// `bench --roto`: the Roto Brush's trackers on synthetic clips whose true mattes are known. +fn bench_roto(args: &Args) -> Result<(), Failure> { + use effectcraft_segment::{Loaded, MOBILE_SAM, MaskModel}; + use effectcraft_track::roto::bench; + let frames = args.num("--frames")?.unwrap_or(24.0).clamp(1.0, 240.0) as usize; + let session = effectcraft_host::session(); + // `--mobilesam FILE` uses the weights at FILE (a file that cannot be used is an error); + // otherwise the installed ones. + let mobilesam: Option> = bench_weights("--mobilesam", args.opt("--mobilesam"), session.model_path(&MOBILE_SAM), |bytes| { + match effectcraft_segment::load(MOBILE_SAM.id, bytes)? { + Loaded::Mask(m) => Ok(m), + _ => Err("not a mask model".to_string()), + } + })?; + if mobilesam.is_none() { + note!("bench --roto: MobileSAM is not installed; comparing the classical engine only"); + } + // `--sam2 FILE` uses the weights at FILE (a file that cannot be used is an error); otherwise + // the installed ones. + let installed_sam2 = effectcraft_sam2::mask::SIZES.iter().find_map(|m| session.model_path(m)); + let sam2: Option> = + bench_weights("--sam2", args.opt("--sam2"), installed_sam2, |bytes| effectcraft_sam2::mask::load(bytes).map(|m| m as std::sync::Arc))?; + if sam2.is_none() { + note!("bench --roto: no SAM 2.1 size is installed; no SAM 2 column"); + } + // `--fixes`: count correction rounds before every frame reaches IoU 0.9 (up to 6 rounds, each up to two strokes). + let fixes = args.flag("--fixes"); + let measure = |sc: &bench::Scenario, run: &dyn Fn(&bench::Scenario) -> Option| if fixes { bench::with_fixes(sc, 0.9, 6, run) } else { run(sc) }; + let mut runs = vec![]; + // User clips pair unique filename stems, allowing JPEG frames with PNG truth masks. + let scenarios = if let Some(dir) = args.opt("--clip") { + let truth = args.opt("--truth").ok_or_else(|| Failure::Error("--clip needs --truth MASKS_DIR".into()))?; + let name = std::path::Path::new(dir).file_name().map_or_else(|| "clip".to_string(), |n| n.to_string_lossy().chars().take(10).collect()); + vec![load_bench_clip(&name, dir, truth, frames).map_err(Failure::Error)?] + } else { + bench::scenarios(frames) + }; + for sc in scenarios { + runs.extend(measure(&sc, &|s| Some(bench::run_flow(s, "classical", None)))); + if let Some(m) = &mobilesam { + runs.extend(measure(&sc, &|s| Some(bench::run_flow(s, "mobilesam", Some(m.as_ref()))))); + } + if let Some(m) = &sam2 { + let label = m.info().id.replacen("sam2.1-hiera-", "sam2-", 1); + runs.extend(measure(&sc, &|s| bench::run_tracker(s, &label, m.as_ref()))); + } + } + if args.flag("--json") { + say!("{}", serde_json::to_string_pretty(&runs).map_err(|e| Failure::Error(e.to_string()))?); + } else { + say!("{}", bench::table(&runs)); + } + Ok(()) +} + +/// The image files of `dir` (png, jpg, jpeg), ordered by stem then full file name. +fn image_files(dir: &str) -> Result, String> { + let mut files: Vec = std::fs::read_dir(dir) + .map_err(|e| format!("cannot read {dir}: {e}"))? + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["png", "jpg", "jpeg"].contains(&e.to_ascii_lowercase().as_str()))) + .collect(); + files.sort_by(|a, b| a.file_stem().cmp(&b.file_stem()).then_with(|| a.file_name().cmp(&b.file_name()))); + Ok(files) +} + +/// A benchmark scenario from the user's frames and truth masks (at most `limit` frames, frames at most 480 px +/// wide; a mask pixel above 127 is the object). Nothing is copied or kept. +fn load_bench_clip(name: &str, frames_dir: &str, truth_dir: &str, limit: usize) -> Result { + let (frames, masks) = (image_files(frames_dir)?, image_files(truth_dir)?); + if frames.is_empty() || frames.len() != masks.len() { + return Err(format!("{} frames in {frames_dir} but {} masks in {truth_dir}: they must match one to one", frames.len(), masks.len())); + } + if frames.iter().map(|p| p.file_stem()).ne(masks.iter().map(|p| p.file_stem())) + || frames.windows(2).chain(masks.windows(2)).any(|pair| pair.first().and_then(|p| p.file_stem()) == pair.get(1).and_then(|p| p.file_stem())) + { + return Err("frame and truth file names must match one to one, with unique stems (extensions may differ)".into()); + } + let n = frames.len().min(limit.clamp(1, 240)); + let (mut imgs, mut truth) = (vec![], vec![]); + for (f, m) in frames.iter().zip(&masks).take(n) { + let rgba = image::open(f).map_err(|e| format!("{}: {e}", f.display()))?.to_rgba8(); + let scale = (480.0 / f64::from(rgba.width().max(1))).min(1.0); + let (w, h) = (((f64::from(rgba.width()) * scale).round() as u32).max(1), ((f64::from(rgba.height()) * scale).round() as u32).max(1)); + let rgba = if (w, h) == rgba.dimensions() { rgba } else { image::imageops::resize(&rgba, w, h, image::imageops::FilterType::Triangle) }; + let mask = image::open(m).map_err(|e| format!("{}: {e}", m.display()))?.to_luma8(); + let mask = image::imageops::resize(&mask, w, h, image::imageops::FilterType::Nearest); + imgs.push(effectcraft_render::Image::from_rgba8(w, h, rgba.as_raw())); + truth.push(mask.as_raw().iter().map(|v| u8::from(*v > 127)).collect()); + } + effectcraft_track::roto::bench::scenario_from(name, imgs, truth) +} + /// `bench --ops`: everyday operations on a large generated project (200 comps, 5,000 layers in /// the main comp, 300 footage items, 20-deep precomp nesting, expressions): startup to first /// frame, open, save, auto-save, small and large edits with undo/redo, first frame, timeline @@ -1189,7 +1422,7 @@ fn adv3d_bench_comp(p: &mut effectcraft_engine::project::Project) -> Option Command { - Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")) + Command::new(crate::binary()) } /// Run with `--json`; returns (exit code, parsed stdout). @@ -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 = steps.iter().flat_map(|(id, p)| [id.to_string(), p.to_string()]).collect(); + let pairs: Vec<&str> = pairs.iter().map(String::as_str).collect(); + let expected = ok_json(&[&["run", "--empty"][..], &pairs[..]].concat()); + let layers = |v: &Value| v[3]["result"]["items"][0]["layers"].as_u64(); + assert_eq!(layers(&expected), Some(2), "{expected}"); + + let objects: Vec = steps.iter().map(|(id, p)| json!({"command": id, "params": p})).collect(); + let array = tmp("commands.json"); + std::fs::write(&array, serde_json::to_string_pretty(&objects).unwrap()).unwrap(); + assert_eq!(ok_json(&["run", "--commands", array.to_str().unwrap(), "--empty"]), expected); + + let lines: String = objects.iter().map(|o| format!("{o}\n\n")).collect(); + let (code, out, err) = run_with_stdin(&["run", "--commands", "-", "--empty", "--json"], &lines); + assert_eq!(code, 0, "{err}"); + assert_eq!(serde_json::from_str::(&out).unwrap(), expected); + + // Positional pairs first, then the file. + let rest = tmp("commands-rest.jsonl"); + std::fs::write(&rest, objects[1..].iter().map(|o| format!("{o}\n")).collect::()).unwrap(); + assert_eq!(ok_json(&[&["run", "--empty", "--commands", rest.to_str().unwrap()][..], &pairs[..2]].concat()), expected); + + let name = "n".repeat(140 * 1024); + let big = tmp("commands-big.jsonl"); + std::fs::write(&big, format!("{}\n{}\n", json!({"command": "comp.new", "params": {"name": name}}), json!({"command": "project.summary"}))).unwrap(); + let v = ok_json(&["run", "--commands", big.to_str().unwrap(), "--empty"]); + assert_eq!(v[1]["result"]["items"][0]["name"].as_str().map(str::len), Some(name.len())); + + for (input, args, error) in [ + ("", &["run", "--commands", "-", "--empty"][..], "--commands -: no commands"), + ( + "{\"command\":\"project.summary\"}\n{\"command\":\"x\",\"param\":{}}\n", + &["run", "project.summary", "{}", "--commands", "-", "--empty"], + "step 2 (line 2): unknown key `param`", + ), + ("[{\"command\":\"project.summary\"}, {\"params\":{}}]", &["run", "--commands", "-", "--empty"], "step 1 (entry 2): no `command`"), + ("{}", &["run", "--commands", "-", "--commands", "-", "--empty"], "standard input is read once"), + ] { + let (code, out, err) = run_with_stdin(args, input); + assert_eq!(code, 2, "{input:?}: {err}"); + assert!(err.contains(error), "{input:?}: {err}"); + assert!(out.is_empty(), "{input:?}: nothing ran"); + } + // An empty list after positional pairs is no error: there is still something to run. + let (code, _, err) = run_with_stdin(&["run", "project.summary", "--commands", "-", "--empty"], ""); + assert_eq!(code, 0, "{err}"); + // A step that fails when it runs is named by its index too. + let (code, out, err) = run_with_stdin(&["run", "--commands", "-", "--empty", "--json"], "{\"command\":\"project.summary\"}\n{\"command\":\"no.such\"}\n"); + assert_eq!(code, 1, "{err}"); + let e = serde_json::from_str::(&out).unwrap()["error"].as_str().map(String::from).unwrap_or_default(); + assert!(e.starts_with("no.such: ") && e.ends_with(" (step 1)"), "{e}"); +} + #[test] fn render_frame_writes_png() { let out = tmp("f.png"); @@ -347,6 +419,67 @@ fn render_out_is_relative_to_the_working_directory() { let _ = std::fs::remove_dir_all(&root); } +/// #1743: `render-frame --out` writes a PNG to the path it is given, and never over footage or +/// the project file. +#[test] +fn render_frame_never_writes_over_footage_or_the_project() { + let root = tmp("render-frame-overwrite"); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let (clip, project) = (root.join("clip.png"), root.join("p.ecproj")); + let (c, p) = (clip.to_str().unwrap(), project.to_str().unwrap()); + // A real PNG to import: a frame of the demo project. + ok_json(&["render-frame", "--max-side", "64", "--out", c]); + let imported = json!({"paths": [c]}).to_string(); + ok_json(&["run", "file.import", &imported, "comp.new", r#"{"name":"T","width":64,"height":36,"duration":1}"#, "--empty", "--save-as", p]); + let (footage, saved) = (std::fs::read(&clip).unwrap(), std::fs::read(&project).unwrap()); + for (out, what) in [(c, "footage"), (p, "project")] { + let (code, v) = run_json(&["render-frame", "--project", p, "--out", out]); + assert_eq!(code, 1, "{what}: {v}"); + let error = v["error"].as_str().unwrap(); + assert!(error.contains(out) && error.contains(what), "{what}: {v}"); + } + assert_eq!(std::fs::read(&clip).unwrap(), footage, "the footage is as it was"); + assert_eq!(std::fs::read(&project).unwrap(), saved, "the project file is as it was"); + // Anywhere else it writes. + let free = root.join("frame.png"); + ok_json(&["render-frame", "--project", p, "--out", free.to_str().unwrap()]); + assert_eq!(&std::fs::read(&free).unwrap()[..8], b"\x89PNG\r\n\x1a\n"); + let _ = std::fs::remove_dir_all(&root); +} + +/// Two queue items on one file are refused by the render before anything is written, with the +/// file as it was. +#[test] +fn render_refuses_two_items_on_one_file() { + let root = tmp("render-clash"); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let out = root.join("clash.mp4"); + std::fs::write(&out, b"EARLIER").unwrap(); + let project = root.join("p.ecproj"); + let p = project.to_str().unwrap(); + let add = json!({"output": out.to_str().unwrap()}).to_string(); + ok_json(&[ + "run", + "comp.new", + r#"{"name":"T","width":64,"height":36,"duration":1}"#, + "renderQueue.add", + &add, + "renderQueue.add", + &add, + "--empty", + "--save-as", + p, + ]); + let (code, v) = run_json(&["render", "--queue", "--project", p]); + assert_eq!(code, 1, "{v}"); + let error = v["error"].as_str().unwrap(); + assert!(error.contains("#1") && error.contains("#2") && error.contains("clash.mp4"), "{v}"); + assert_eq!(std::fs::read(&out).unwrap(), b"EARLIER"); + let _ = std::fs::remove_dir_all(&root); +} + /// A real stdio client: each edit's reply is read before disconnecting or killing the server. struct McpChild { child: std::process::Child, @@ -533,6 +666,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() { @@ -563,6 +739,113 @@ fn render_bitrate_applies_to_vp9_webm() { let _ = std::fs::remove_dir_all(&root); } +/// `bench --roto --clip FRAMES --truth MASKS` runs the trackers on the user's own image sequences: here a bright disc +/// drifting over a dark textured frame, with its true masks. Mismatched sequences are refused with a message. +#[test] +fn bench_roto_runs_on_a_users_clip() { + let (frames, masks) = (tmp("clip-frames"), tmp("clip-masks")); + let config = frames.join("config"); + for d in [&frames, &masks] { + let _ = std::fs::remove_dir_all(d); + std::fs::create_dir_all(d).unwrap(); + } + let (w, h) = (96u32, 72u32); + for f in 0..14u32 { + let (cx, cy) = (30.0 + 4.0 * f as f32, 36.0f32); + let (mut img, mut mask) = (image::RgbImage::new(w, h), image::GrayImage::new(w, h)); + for y in 0..h { + for x in 0..w { + let inside = ((x as f32 - cx).powi(2) + (y as f32 - cy).powi(2)).sqrt() < 14.0; + let tex = 40 + ((x * 7 + y * 13) % 30) as u8; + img.put_pixel(x, y, if inside { image::Rgb([230, 120, 40 + (x % 20) as u8]) } else { image::Rgb([tex / 2, tex, tex + 20]) }); + mask.put_pixel(x, y, image::Luma([if inside { 255 } else { 0 }])); + } + } + let extension = if f == 0 { "jpg" } else { "png" }; + let stem = match f { + 0 => "a".into(), + 1 => "a.jpg".into(), + _ => format!("f{f:03}"), + }; + img.save(frames.join(format!("{stem}.{extension}"))).unwrap(); + mask.save(masks.join(format!("{stem}.png"))).unwrap(); + } + let (f, m) = (frames.to_str().unwrap(), masks.to_str().unwrap()); + let out = bin().env("EFFECTCRAFT_CONFIG_DIR", &config).args(["bench", "--roto", "--clip", f, "--truth", m, "--frames", "6", "--json"]).output().unwrap(); + let text = String::from_utf8_lossy(&out.stdout).to_string(); + assert!(out.status.success(), "{text}\n{}", String::from_utf8_lossy(&out.stderr)); + let rows: Value = serde_json::from_str(&text).unwrap(); + let row = rows.as_array().unwrap().iter().find(|row| row["tracker"] == "classical").unwrap(); + assert_eq!(row["per_frame"].as_array().unwrap().len(), 6, "a user clip respects the requested frame limit"); + let mean = row["mean_iou"].as_f64().unwrap(); + assert!(mean > 0.8, "classical mean IoU on the user's clip: {mean}\n{text}"); + // one mask short: refused + std::fs::remove_file(masks.join("f005.png")).unwrap(); + let out = bin().env("EFFECTCRAFT_CONFIG_DIR", &config).args(["bench", "--roto", "--clip", f, "--truth", m]).output().unwrap(); + assert!(!out.status.success()); + assert!(String::from_utf8_lossy(&out.stderr).contains("must match"), "{}", String::from_utf8_lossy(&out.stderr)); + // --clip without --truth + let out = bin().env("EFFECTCRAFT_CONFIG_DIR", &config).args(["bench", "--roto", "--clip", f]).output().unwrap(); + assert!(!out.status.success()); +} + +#[test] +fn bench_roto_rejects_mismatched_names_before_loading_images() { + let root = tmp("bench-clip-names"); + let (frames, masks) = (root.join("frames"), root.join("masks")); + let _ = std::fs::remove_dir_all(&root); + for dir in [&frames, &masks] { + std::fs::create_dir_all(dir).unwrap(); + std::fs::write(dir.join("f000.png"), []).unwrap(); + } + std::fs::write(frames.join("f001.png"), []).unwrap(); + std::fs::write(masks.join("f002.png"), []).unwrap(); + let out = bin() + .env("EFFECTCRAFT_CONFIG_DIR", root.join("config")) + .args(["bench", "--roto", "--frames", "1", "--clip"]) + .arg(&frames) + .arg("--truth") + .arg(&masks) + .output() + .unwrap(); + let error = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success()); + assert!(error.contains("names must match"), "validate the entire name set before decoding: {error}"); + std::fs::remove_file(frames.join("f001.png")).unwrap(); + std::fs::remove_file(masks.join("f002.png")).unwrap(); + for dir in [&frames, &masks] { + std::fs::write(dir.join("f000.jpg"), []).unwrap(); + } + let out = + bin().env("EFFECTCRAFT_CONFIG_DIR", root.join("config")).args(["bench", "--roto", "--clip"]).arg(&frames).arg("--truth").arg(&masks).output().unwrap(); + assert!(!out.status.success()); + assert!(String::from_utf8_lossy(&out.stderr).contains("unique stems")); + std::fs::remove_dir_all(root).unwrap(); +} + +/// `bench --roto --sam2 FILE` / `--mobilesam FILE` say why a file named on the command line cannot be +/// used (a missing file, one that is not the published weights), instead of "not installed". +#[test] +fn bench_roto_reports_why_a_named_model_file_failed() { + let dir = tmp("bench-weights"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let bogus = dir.join("bogus.bin"); + std::fs::write(&bogus, b"not the weights").unwrap(); + for flag in ["--sam2", "--mobilesam"] { + let missing = dir.join("missing.bin"); + let out = bin().env("EFFECTCRAFT_CONFIG_DIR", dir.join("config")).args(["bench", "--roto", flag, missing.to_str().unwrap()]).output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr).to_string(); + assert!(!out.status.success(), "{flag}: {err}"); + assert!(err.contains(flag) && err.contains("missing.bin"), "{flag} names the file that could not be read: {err}"); + let out = bin().env("EFFECTCRAFT_CONFIG_DIR", dir.join("config")).args(["bench", "--roto", flag, bogus.to_str().unwrap()]).output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr).to_string(); + assert!(!out.status.success(), "{flag}: {err}"); + assert!(err.contains("does not match"), "{flag} says the file is not the published weights: {err}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + /// Render uses the same empty/project/demo selection as the other CLI commands. #[test] fn render_empty_does_not_load_the_demo() { diff --git a/apps/effectcraft-cli/tests/it/main.rs b/apps/effectcraft-cli/tests/it/main.rs index 17149f545..bca36ec78 100644 --- a/apps/effectcraft-cli/tests/it/main.rs +++ b/apps/effectcraft-cli/tests/it/main.rs @@ -2,3 +2,8 @@ mod cli; mod qa_template; + +/// Nextest archives relocate binaries; plain Cargo keeps the compile-time path valid. +fn binary() -> std::ffi::OsString { + std::env::var_os("NEXTEST_BIN_EXE_effectcraft_cli").unwrap_or_else(|| env!("CARGO_BIN_EXE_effectcraft-cli").into()) +} diff --git a/apps/effectcraft-cli/tests/it/qa_template.rs b/apps/effectcraft-cli/tests/it/qa_template.rs index 0618c0d63..ffbdb94e9 100644 --- a/apps/effectcraft-cli/tests/it/qa_template.rs +++ b/apps/effectcraft-cli/tests/it/qa_template.rs @@ -9,7 +9,7 @@ use std::process::Command; use serde_json::Value; fn cli(dir: &PathBuf, args: &[&str]) -> (i32, Value) { - let out = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")).current_dir(dir).args(args).arg("--json").output().unwrap(); + let out = Command::new(crate::binary()).current_dir(dir).args(args).arg("--json").output().unwrap(); let stdout = String::from_utf8_lossy(&out.stdout).to_string(); let v = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| panic!("{args:?}: {e}: {stdout} {}", String::from_utf8_lossy(&out.stderr))); (out.status.code().unwrap_or(-1), v) @@ -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(env!("CARGO_BIN_EXE_effectcraft-cli")).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); @@ -251,7 +262,7 @@ fn assert_template_output_preserves_inputs(dir: &PathBuf, out: &str) -> Value { let template_before = std::fs::read(dir.join("lt.ectemplate")).unwrap(); let values_before = std::fs::read(dir.join("values.json")).unwrap(); // Read the inputs before interpreting the subprocess status or error text. - let result = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")) + let result = Command::new(crate::binary()) .current_dir(dir) .args(["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", out, "--json"]) .output() @@ -527,7 +538,7 @@ fn template_unknown_control_does_not_replace_extracted_media() { let invalid_values_before = std::fs::read(dir.join("bad-values.json")).unwrap(); // Capture every file before interpreting status/JSON, so a failed command cannot hide a write. - let result = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")) + let result = Command::new(crate::binary()) .current_dir(&dir) .args(["render-frame", "--template", "lt.ectemplate", "--values", "bad-values.json", "--time", "1", "--out", "failed.png", "--json"]) .output() @@ -623,11 +634,8 @@ fn template_missing_output_does_not_replace_extracted_media() { assert_ne!(sentinel_before, source_before); // Observe writes before interpreting the actual CLI usage exit. - let result = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")) - .current_dir(&dir) - .args(["render", "--template", "lt.ectemplate", "--values", "values.json", "--json"]) - .output() - .unwrap(); + let result = + Command::new(crate::binary()).current_dir(&dir).args(["render", "--template", "lt.ectemplate", "--values", "values.json", "--json"]).output().unwrap(); let template_after = std::fs::read(dir.join("lt.ectemplate")).unwrap(); let values_after = std::fs::read(dir.join("values.json")).unwrap(); let source_after = std::fs::read(dir.join("source.png")).unwrap(); diff --git a/apps/effectcraft-web/Cargo.toml b/apps/effectcraft-web/Cargo.toml index 28ea2f369..7743347af 100644 --- a/apps/effectcraft-web/Cargo.toml +++ b/apps/effectcraft-web/Cargo.toml @@ -37,6 +37,8 @@ js-sys = "0.3" web-sys = { version = "0.3", features = [ "Blob", "BlobPropertyBag", + "AbortController", + "AbortSignal", "CssStyleDeclaration", "Document", "Element", @@ -51,6 +53,12 @@ web-sys = { version = "0.3", features = [ "HtmlInputElement", "Location", "Navigator", + "ReadableStream", + "ReadableStreamDefaultReader", + "Request", + "RequestCache", + "RequestInit", + "Response", "Url", "UrlSearchParams", "Window", diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js index 12cb90bfa..c8e91a260 100644 --- a/apps/effectcraft-web/js/host.js +++ b/apps/effectcraft-web/js/host.js @@ -7,6 +7,23 @@ // // Plain functions only: the Rust side owns the logic. +// ------------------------------------------------------------------ shortcuts + +/// Keep Duplicate on the app canvas instead of also opening the browser's bookmarks. +/// The physical D key counts on layouts where it types no ASCII letter (e.g. Cyrillic), because +/// eframe falls back to the key code there; it does not on Dvorak, where it types E. +/// eframe still receives the key; other chords and HTML inputs keep their browser behavior. +export function installCanvasShortcuts(canvas) { + canvas.addEventListener("keydown", (event) => { + if (event.target === canvas && (event.ctrlKey || event.metaKey) + && !event.shiftKey && !event.altKey && !event.isComposing + && (event.key.toLowerCase() === "d" + || (event.code === "KeyD" && !/^[a-z]$/i.test(event.key)))) { + event.preventDefault(); + } + }, { capture: true }); +} + // ------------------------------------------------------------------ storage const DIR = "effectcraft"; @@ -19,7 +36,7 @@ const enc = (k) => encodeURIComponent(k); const dec = (n) => decodeURIComponent(n); async function opfs() { - if (!navigator.storage || !navigator.storage.getDirectory) throw new Error("no OPFS"); + if (!navigator.storage?.getDirectory) throw new Error("no OPFS"); const root = await navigator.storage.getDirectory(); const dir = await root.getDirectoryHandle(DIR, { create: true }); // Probe a write: some browsers expose OPFS but only write it from workers. @@ -116,7 +133,7 @@ export async function storeOpen(prefer) { try { backend = await make(); break; } catch (e) { console.info(`storage: ${make.name}: ${e.message ?? e}`); } } // Ask the browser not to evict the store under storage pressure (granted for installed apps). - try { if (navigator.storage && navigator.storage.persist) await navigator.storage.persist(); } catch {} + try { if (navigator.storage?.persist) await navigator.storage.persist(); } catch {} return backend.name; } @@ -128,7 +145,7 @@ export async function storeDelete(key) { return backend.del(key); } /// Ask the browser to keep the origin's storage under pressure; resolves with whether it is. export async function storePersist() { try { - if (navigator.storage && navigator.storage.persist) return await navigator.storage.persist(); + if (navigator.storage?.persist) return await navigator.storage.persist(); } catch {} return false; } @@ -227,7 +244,7 @@ export async function audioStart(workletUrl) { } if (gen !== generation) return; node = new AudioWorkletNode(c, "effectcraft-output", { numberOfInputs: 0, outputChannelCount: [2] }); - node.port.onmessage = (e) => { if (e.data && e.data.underrun) underruns += e.data.underrun; }; + node.port.onmessage = (e) => { if (e.data?.underrun) underruns += e.data.underrun; }; kind = "worklet"; } catch (e) { console.warn("audio: AudioWorklet unavailable, using ScriptProcessorNode", e); @@ -318,11 +335,11 @@ function spawn(base) { const w = { worker, files: new Map(), ready: null, gpu: null }; w.ready = new Promise((res, rej) => { worker.onmessage = (e) => { - if (e.data && e.data.type === "gpu") { + if (e.data?.type === "gpu") { w.gpu = e.data.gpu ? { adapter: e.data.gpu } : { cpu: e.data.error || "no GPU" }; jobGpu = w.gpu; } - if (e.data && e.data.type === "ready") res(); + if (e.data?.type === "ready") res(); }; worker.onerror = (e) => rej(new Error(e.message || "worker failed to start")); }); @@ -341,7 +358,7 @@ export function workerRun(id, json, files, base, onMessage) { } catch (e) { // `new Worker` throws at once when the page's origin is opaque (a sandboxed iframe): the job // fails like one whose worker didn't start. - setTimeout(() => onMessage("error", String((e && e.message) || e), null, null)); + setTimeout(() => onMessage("error", String(e?.message || e), null, null)); return; } running.set(id, w); @@ -416,12 +433,12 @@ export function frameWorkerStart(base, onMessage, gpu) { // slot stays, and the failure is reported like a worker that died, after the caller has // recorded the index: frames then render on the page's thread (as with `?noworkers`). frameWorkers.push(null); - setTimeout(() => onMessage(index, undefined, undefined, String((e && e.message) || e || "frame worker failed"))); + setTimeout(() => onMessage(index, undefined, undefined, String(e?.message || e || "frame worker failed"))); return index; } worker.onmessage = (e) => { const m = e.data; - if (m && m.type === "frameReply") onMessage(index, m.json, m.bytes ? new Uint8Array(m.bytes) : null, undefined); + if (m?.type === "frameReply") onMessage(index, m.json, m.bytes ? new Uint8Array(m.bytes) : null, undefined); }; worker.onerror = (e) => { onMessage(index, undefined, undefined, String(e.message || "frame worker failed")); @@ -447,7 +464,7 @@ const cacheDirHandles = {}; async function cacheDir(kind = "frames") { if (cacheDirHandles[kind]) return cacheDirHandles[kind]; - if (!navigator.storage || !navigator.storage.getDirectory) throw new Error("no Origin Private File System"); + if (!navigator.storage?.getDirectory) throw new Error("no Origin Private File System"); let d = await navigator.storage.getDirectory(); for (const n of [CACHE_ROOT, "v1", kind]) d = await d.getDirectoryHandle(n, { create: true }); cacheDirHandles[kind] = d; @@ -544,7 +561,7 @@ export async function cacheClear() { /// Whether this thread has WebGPU (`navigator.gpu`). export function hasWebGpu() { - return !!(self.navigator && self.navigator.gpu); + return !!self.navigator?.gpu; } /// Post a message ({type: "frame", json} or {type: "file", path, bytes}) to frame worker `index`. @@ -652,7 +669,7 @@ export async function browseForget(name) { export async function browseRead(path) { const rel = path.replace(/^\/Folders\//, ""); const folder = folders.get(rel.split("/")[0]); - const h = folder && folder.files.get(rel); + const h = folder?.files.get(rel); if (!h) throw new Error(`${path}: its folder is not open (reconnect it)`); return new Uint8Array(await (await h.getFile()).arrayBuffer()); } @@ -706,7 +723,7 @@ export function showDeviceLost(message, kept) { self.effectcraft .execute("file.save", {}) .catch(() => self.effectcraft.execute("file.saveAs", { path: "/Untitled Project.ecproj" })) - .catch((e) => (text.textContent = `Saving failed: ${(e && e.message) || e}`)); + .catch((e) => (text.textContent = `Saving failed: ${e?.message || e}`)); const reload = document.createElement("button"); reload.textContent = "Reload"; reload.onclick = async () => { @@ -714,7 +731,7 @@ export function showDeviceLost(message, kept) { try { await self.effectcraft.flush(); } catch (e) { - text.textContent = `Writing to browser storage failed (${(e && e.message) || e}). Save the project, or reload anyway and lose the changes.`; + text.textContent = `Writing to browser storage failed (${e?.message || e}). Save the project, or reload anyway and lose the changes.`; reload.textContent = "Reload Anyway"; reload.disabled = false; reload.onclick = () => location.reload(); diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index f09bf28a1..2882b16cf 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -6,7 +6,8 @@ use std::sync::atomic::{AtomicBool, Ordering}; use effectcraft_engine::Session; -use crate::{api, audio, browse, diskcache, files, frames, persist, storage, worker}; +use crate::startup::Startup; +use crate::{api, audio, browse, config_file, diskcache, files, frames, persist, storage, worker}; use effectcraft_ui_egui::EffectcraftApp; use serde_json::json; use wasm_bindgen::prelude::*; @@ -15,6 +16,8 @@ use wasm_bindgen::prelude::*; extern "C" { #[wasm_bindgen(js_name = showDeviceLost)] fn show_device_lost(message: &str, kept: bool); + #[wasm_bindgen(js_name = installCanvasShortcuts)] + fn install_canvas_shortcuts(canvas: &web_sys::HtmlCanvasElement); } /// Work for the UI thread with access to the app (posted by async tasks: picked files…). @@ -81,11 +84,19 @@ pub(crate) fn open_or_import(app: &mut EffectcraftApp, paths: Vec) { /// The eframe app: the shared EffectCraft UI plus the browser host's per-frame duties. pub struct WebApp { pub app: EffectcraftApp, - /// Keep the session snapshot (off with `?empty`). + /// Keep the session snapshot (off with `?empty`; off with an explicit `"demo"` startup until + /// the user's own work begins, see `demo_start`). pub persist: bool, + /// Started on the demo by the config: the snapshot stays off, so the demo never replaces the + /// last visit's project, until the user opens a project from a file or edits the demo. + pub demo_start: bool, + /// The last title written to `document.title`. + pub document_title: String, } impl eframe::App for WebApp { + /// Take dropped files out of `raw_input` before eframe can replay them on a hidden tab, read them asynchronously + /// through `open_or_import`, then run the shared app's input hook. fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { // Files dropped on the page are input: read them here, where the browser's drop event is // still the current pass's, not from a later (possibly logic-only) tick. @@ -101,15 +112,28 @@ impl eframe::App for WebApp { self.app.raw_input_hook(ctx, raw_input); } + /// The browser host's logic tick: run queued UI actions, tick the shared app, poll API replies, flush Render + /// Queue downloads, persist the session snapshot when enabled, publish GPU/frame-worker/disk-cache info and close + /// the IME pass. fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { let actions = ACTIONS.with(|a| std::mem::take(&mut *a.borrow_mut())); for f in actions { f(&mut self.app); } self.app.tick(ctx, frame); + // eframe on the web ignores `ViewportCommand::Title`: the tab title follows the open document here. + if self.app.window_title() != self.document_title { + self.document_title = self.app.window_title().to_owned(); + set_document_title(&self.document_title); + } 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. @@ -129,6 +153,13 @@ impl eframe::App for WebApp { } } +/// The browser tab's title. +fn set_document_title(title: &str) { + if let Some(doc) = web_sys::window().and_then(|w| w.document()) { + doc.set_title(title); + } +} + fn query() -> Option { web_sys::window().and_then(|w| w.location().search().ok()).and_then(|s| web_sys::UrlSearchParams::new_with_str(&s).ok()) } @@ -182,8 +213,24 @@ fn install_hooks(app: &mut EffectcraftApp) { } None })); - // Saving downloads the file: the chosen name is all a "save dialog" needs. - app.hooks.pick_save = Some(Box::new(|name: &str| Some(files::path_for(if name.is_empty() { "Untitled Project.ecproj" } else { name })))); + // Saving downloads the file: the chosen name is all a "save dialog" needs. A project (Save As, + // Save a Copy) asks for the name, starting from the open project's (#2788): without asking, + // Save As would replace the open project's file; Cancel cancels. A name that is already in + // browser storage asks before replacing it, as the desktop's save dialogs do. + app.hooks.pick_save = Some(Box::new(|name: &str| { + let name = if name.is_empty() { "Untitled Project.ecproj" } else { name }; + if !std::path::Path::new(name).extension().is_some_and(|e| e.eq_ignore_ascii_case("ecproj") || e.eq_ignore_ascii_case("ecprojx")) { + return Some(files::path_for(name)); + } + let window = web_sys::window()?; + let typed = window.prompt_with_message_and_default("Save project as:", name).ok().flatten(); + let typed = crate::store::typed_save_name(name, typed)?; + let path = files::path_for(&typed); + if files::get(&path).is_some() && !window.confirm_with_message(&format!("{typed} already exists. Replace it?")).unwrap_or(false) { + return None; + } + Some(path) + })); app.hooks.audio_device = Some(Box::new(audio::open)); app.hooks.audio_devices = Some(Box::new(|| vec!["Browser default output".into()])); } @@ -199,6 +246,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 @@ -215,6 +263,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(); @@ -240,7 +293,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); @@ -249,11 +302,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)); @@ -279,7 +335,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, document_title: String::new() })) }), ) .await?; diff --git a/apps/effectcraft-web/src/config_file.rs b/apps/effectcraft-web/src/config_file.rs new file mode 100644 index 000000000..614d7d4cb --- /dev/null +++ b/apps/effectcraft-web/src/config_file.rs @@ -0,0 +1,73 @@ +//! `./effectcraft-config.json`, fetched once per page load and shared by whoever reads a key. + +use std::cell::RefCell; + +use wasm_bindgen::{JsCast, closure::Closure}; +use wasm_bindgen_futures::JsFuture; + +use crate::startup::{self, Startup}; + +/// Longest the request and the body read may take (ms); a stalled server must not hold up start-up. +const TIMEOUT_MS: i32 = 5_000; + +thread_local! { + /// `None`: not fetched yet; `Some(None)`: no usable file. + static FILE: RefCell>>> = const { RefCell::new(None) }; +} + +/// The config file's bytes, or `None` when it is missing, too large, unreadable or the request +/// takes longer than [`TIMEOUT_MS`] (the timer aborts the request and covers the body read). +async fn fetch() -> Option> { + let win = web_sys::window()?; + let ctl = web_sys::AbortController::new().ok()?; + let init = web_sys::RequestInit::new(); + // Always ask the server: the file is the operator's switch, never a cached copy. + init.set_cache(web_sys::RequestCache::NoCache); + init.set_signal(Some(&ctl.signal())); + let on_timeout = Closure::once(move || ctl.abort()); + let timer = win.set_timeout_with_callback_and_timeout_and_arguments_0(on_timeout.as_ref().unchecked_ref(), TIMEOUT_MS).ok()?; + let out = read(&win, &init).await; + win.clear_timeout_with_handle(timer); + out +} + +/// Fetch the file and read its body chunk by chunk, giving up as soon as it passes `CONFIG_MAX`. +async fn read(win: &web_sys::Window, init: &web_sys::RequestInit) -> Option> { + let req = web_sys::Request::new_with_str_and_init("effectcraft-config.json", init).ok()?; + let resp: web_sys::Response = JsFuture::from(win.fetch_with_request(&req)).await.ok()?.dyn_into().ok()?; + if resp.status() != 200 { + return None; + } + let reader: web_sys::ReadableStreamDefaultReader = resp.body()?.get_reader().unchecked_into(); + let mut out = Vec::new(); + loop { + let chunk = JsFuture::from(reader.read()).await.ok()?; + if js_sys::Reflect::get(&chunk, &"done".into()).ok()?.as_bool() == Some(true) { + return Some(out); + } + let bytes: js_sys::Uint8Array = js_sys::Reflect::get(&chunk, &"value".into()).ok()?.dyn_into().ok()?; + if out.len().saturating_add(bytes.length() as usize) > startup::CONFIG_MAX { + // Stop the download: the limit is enforced while reading, not after the whole body arrived. + let _ = reader.cancel(); + return None; + } + out.extend_from_slice(&bytes.to_vec()); + } +} + +/// The config file's bytes (`None` when there is none), fetched on the first call. +pub async fn bytes() -> Option> { + if let Some(done) = FILE.with(|f| f.borrow().clone()) { + return done; + } + let got = fetch().await; + FILE.with(|f| *f.borrow_mut() = Some(got.clone())); + got +} + +/// The configured startup behaviour. +pub async fn startup() -> Startup { + let s = bytes().await.map(|b| startup::parse(&b)).unwrap_or_default(); + log::info!("startup: {s:?}"); + s +} diff --git a/apps/effectcraft-web/src/files.rs b/apps/effectcraft-web/src/files.rs index 206314e4b..f6aac5aa4 100644 --- a/apps/effectcraft-web/src/files.rs +++ b/apps/effectcraft-web/src/files.rs @@ -151,6 +151,10 @@ impl Services for WebServices { fn is_dir(&self, _path: &str) -> bool { false } + /// The table's keys are the paths themselves. + fn full_path(&self, path: &str) -> Option { + Some(path.to_string()) + } } /// Media import from the table (the bytes are also handed to the media pool, which decodes them). diff --git a/apps/effectcraft-web/src/frames.rs b/apps/effectcraft-web/src/frames.rs index c7f350526..69089e308 100644 --- a/apps/effectcraft-web/src/frames.rs +++ b/apps/effectcraft-web/src/frames.rs @@ -311,10 +311,11 @@ pub fn stats() -> Value { }) } +type PendingFrameCheck = (js_sys::Function, js_sys::Function, (u32, u32, Vec), f64); + thread_local! { /// `workerFrameCheck` promises waiting for their frame: id → (resolve, reject, local frame). - #[allow(clippy::type_complexity)] - static CHECKS: RefCell), f64)>> = RefCell::new(Default::default()); + static CHECKS: RefCell> = RefCell::new(Default::default()); } /// `effectcraft.workerFrameCheck({time?, scale?, backend?})`: render the active comp's frame in diff --git a/apps/effectcraft-web/src/lib.rs b/apps/effectcraft-web/src/lib.rs index 0d59906f2..e1bf85894 100644 --- a/apps/effectcraft-web/src/lib.rs +++ b/apps/effectcraft-web/src/lib.rs @@ -6,22 +6,23 @@ //! //! | Desktop | Web | //! |---|---| -//! | file system (`FsServices`, media reads, export writes) | a virtual file table ([`files`]) persisted to the Origin Private File System / IndexedDB ([`persist`], [`store`]) | +//! | file system (`FsServices`, media reads, export writes) | a virtual file table (`files`) persisted to the Origin Private File System / IndexedDB (`persist`, [`store`]) | //! | config directory (settings, shortcuts, recent projects, auto-saves) | the same store ([`store::WebConfig`]) | //! | rfd file dialogs | `` pickers; drop files on the page | -//! | Media Browser on the file system | browser storage and folders opened with the File System Access API ([`browse`]) | +//! | Media Browser on the file system | browser storage and folders opened with the File System Access API (`browse`) | //! | written files (Save, Render Queue) | browser downloads (several render files as one `.zip`) | -//! | frame render threads | frame workers, each with a project replica fed by diffs and its own WebGPU device ([`frames`]); `Frames::pump` on the UI thread without workers | -//! | disk cache folder | frames in the Origin Private File System, written by the frame workers ([`diskcache`]) | -//! | (none) | the storage manager: usage, quota, persistence, clearing ([`storage`]) | -//! | Render Queue / analysis threads | Web Workers running their own engine instance ([`worker`]) | -//! | cpal audio output | Web Audio: an AudioWorklet fed from the preview mixdown ([`audio`]) | -//! | TCP control channel / MCP | `window.effectcraft` JavaScript API ([`api`]) | +//! | frame render threads | frame workers, each with a project replica fed by diffs and its own WebGPU device (`frames`); `Frames::pump` on the UI thread without workers | +//! | disk cache folder | frames in the Origin Private File System, written by the frame workers (`diskcache`) | +//! | (none) | the storage manager: usage, quota, persistence, clearing (`storage`) | +//! | Render Queue / analysis threads | Web Workers running their own engine instance (`worker`) | +//! | cpal audio output | Web Audio: an AudioWorklet fed from the preview mixdown (`audio`) | +//! | TCP control channel / MCP | `window.effectcraft` JavaScript API (`api`) | //! -//! [`store`] is portable (unit-tested natively); everything else is `wasm32`-only. +//! [`store`] and [`startup`] are portable (unit-tested natively); everything else is `wasm32`-only. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod startup; pub mod store; #[cfg(target_arch = "wasm32")] @@ -33,6 +34,8 @@ pub mod audio; #[cfg(target_arch = "wasm32")] pub mod browse; #[cfg(target_arch = "wasm32")] +pub mod config_file; +#[cfg(target_arch = "wasm32")] pub mod diskcache; #[cfg(target_arch = "wasm32")] pub mod files; diff --git a/apps/effectcraft-web/src/startup.rs b/apps/effectcraft-web/src/startup.rs new file mode 100644 index 000000000..4759096f3 --- /dev/null +++ b/apps/effectcraft-web/src/startup.rs @@ -0,0 +1,55 @@ +//! The `startup` key of `./effectcraft-config.json`: which project the page opens on load. +//! +//! | Value | On load | +//! |---|---| +//! | `"last"` (and absent) | the last visit's project; the demo project on a first visit | +//! | `"empty"` | the last visit's project; a blank project on a first visit, never the demo | +//! | `"demo"` | the demo project, whatever the last visit left | +//! +//! The `?empty` and `?demo` URL flags still win. Anything else (a missing key, another type, an +//! unknown value, a config that is not JSON) is the default, `"last"`. + +use serde_json::Value; + +/// What the page opens on load. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Startup { + #[default] + Last, + Empty, + Demo, +} + +/// The largest config file read (bytes). +pub const CONFIG_MAX: usize = 64 * 1024; + +/// The `startup` key of a config file's bytes. +pub fn parse(bytes: &[u8]) -> Startup { + if bytes.len() > CONFIG_MAX { + return Startup::default(); + } + let Ok(v) = serde_json::from_slice::(bytes) else { return Startup::default() }; + match v.get("startup").and_then(Value::as_str) { + Some("empty") => Startup::Empty, + Some("demo") => Startup::Demo, + _ => Startup::Last, + } +} + +impl Startup { + /// Whether the last visit's project is restored (off for the demo). + pub fn restores(self) -> bool { + self != Startup::Demo + } + + /// Whether the session snapshot keeps being saved (off for the demo, so it never replaces the + /// project the last visit left). + pub fn keeps_session(self) -> bool { + self != Startup::Demo + } + + /// Whether the demo project opens when nothing was restored (off for a blank start). + pub fn demo_when_nothing_restored(self) -> bool { + self != Startup::Empty + } +} diff --git a/apps/effectcraft-web/src/store.rs b/apps/effectcraft-web/src/store.rs index 8aafdac1c..0cc877948 100644 --- a/apps/effectcraft-web/src/store.rs +++ b/apps/effectcraft-web/src/store.rs @@ -152,6 +152,24 @@ pub fn key_path(key: &str) -> Option<&str> { key.strip_prefix(FILES).filter(|p| p.starts_with('/')) } +/// The project file name typed in the web's Save As prompt (#2788): `None` when it was cancelled or +/// left empty; folders are dropped (the file table is flat) and a name without the default's +/// extension gets it (`Intro copy` → `Intro copy.ecproj`, `Intro.v2` → `Intro.v2.ecproj`), so the +/// browser still opens and keeps it as a project. +pub fn typed_save_name(default: &str, typed: Option) -> Option { + let typed = typed?; + let name = typed.trim().rsplit(['/', '\\']).next().unwrap_or("").trim(); + if name.is_empty() { + return None; + } + let ext = Path::new(default).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default(); + if ext.is_empty() || Path::new(name).extension().is_some_and(|e| e.eq_ignore_ascii_case(&ext)) { + Some(name.to_string()) + } else { + Some(format!("{name}.{ext}")) + } +} + /// FNV-1a 64. Computed when a disk-cache key first needs the file, not on every write. fn content_hash(data: &[u8]) -> u64 { let mut h: u64 = 0xcbf2_9ce4_8422_2325; @@ -366,6 +384,21 @@ impl ConfigStore for WebConfig { v.sort(); v } + /// The first name part of the keys under `dir/`. + fn list_dirs(&self, dir: &str) -> Vec { + let pre = format!("{CONFIG}{}/", dir.trim_end_matches('/')); + let mut v: Vec = self + .store + .lock() + .list(&pre) + .into_iter() + .filter_map(|(k, ..)| k.strip_prefix(&pre)?.split_once('/').map(|(d, _)| d.to_string())) + .filter(|d| !d.is_empty()) + .collect(); + v.sort(); + v.dedup(); + v + } fn dir(&self) -> Option { Some(PathBuf::from("/")) } @@ -421,6 +454,20 @@ pub struct SnapshotMeta { mod tests { use super::*; + #[test] + fn typed_save_names() { + let t = |s: &str| Some(s.to_string()); + assert_eq!(typed_save_name("Intro.ecproj", t("Intro copy")).as_deref(), Some("Intro copy.ecproj")); + assert_eq!(typed_save_name("Intro.ecproj", t(" Other.ecproj ")).as_deref(), Some("Other.ecproj")); + assert_eq!(typed_save_name("Intro.ecproj", t("a/b\\Shot.ecproj")).as_deref(), Some("Shot.ecproj")); + assert_eq!(typed_save_name("Intro.ecproj", t("Intro.v2")).as_deref(), Some("Intro.v2.ecproj")); + assert_eq!(typed_save_name("Intro.ecproj", t("Intro.ECPROJ")).as_deref(), Some("Intro.ECPROJ")); + assert_eq!(typed_save_name("Intro.ecprojx", t("Intro")).as_deref(), Some("Intro.ecprojx")); + assert_eq!(typed_save_name("Intro.ecproj", t(" ")), None); + assert_eq!(typed_save_name("Intro.ecproj", t("folder/")), None); + assert_eq!(typed_save_name("Intro.ecproj", None), None); + } + fn store() -> Arc { let s = Arc::new(Store::default()); let _ = s.clock.set(|| 1_000.0); @@ -533,6 +580,22 @@ mod tests { assert_eq!(s.file("/clip.mp4").as_deref(), Some(&b"abc"[..])); } + /// Scripts in a subfolder of the Scripts folder (`Tools/Hello.jsx`) are listed, as on the desktop. + #[test] + fn config_store_lists_folders() { + let c = WebConfig::new(store()); + for name in ["Scripts/Top.jsx", "Scripts/Tools/Hello.jsx", "Scripts/Tools/More.jsx", "Scripts/ScriptUI Panels/Kit/A.jsx"] { + c.write(name, "x").unwrap(); + } + assert_eq!(c.list("Scripts"), ["Top.jsx"]); + assert_eq!(c.list_dirs("Scripts"), ["ScriptUI Panels", "Tools"]); + assert_eq!(c.list_dirs("Scripts/ScriptUI Panels"), ["Kit"]); + let mut s = effectcraft_engine::Session { config: Some(Arc::new(c)), ..Default::default() }; + let list = s.execute("file.scripts.list", serde_json::json!({})).unwrap(); + let installed: Vec<&str> = list.as_array().unwrap().iter().filter(|e| e["source"] == "installed").filter_map(|e| e["name"].as_str()).collect(); + assert_eq!(installed, ["Tools/Hello.jsx", "Tools/More.jsx", "Top.jsx", "Kit/A.jsx"]); + } + #[test] fn config_store_and_file_ops() { let s = store(); diff --git a/apps/effectcraft-web/tests/it/main.rs b/apps/effectcraft-web/tests/it/main.rs new file mode 100644 index 000000000..a4d242ba5 --- /dev/null +++ b/apps/effectcraft-web/tests/it/main.rs @@ -0,0 +1 @@ +mod startup_tests; diff --git a/apps/effectcraft-web/tests/it/startup_tests.rs b/apps/effectcraft-web/tests/it/startup_tests.rs new file mode 100644 index 000000000..3ce90569c --- /dev/null +++ b/apps/effectcraft-web/tests/it/startup_tests.rs @@ -0,0 +1,52 @@ +//! The `startup` key of the config file. + +use effectcraft_web::startup::{CONFIG_MAX, Startup, parse}; + +/// The three accepted values parse. +#[test] +fn the_three_values() { + assert_eq!(parse(br#"{"startup":"empty"}"#), Startup::Empty); + assert_eq!(parse(br#"{"startup":"demo"}"#), Startup::Demo); + assert_eq!(parse(br#"{"startup":"last"}"#), Startup::Last); +} + +/// A missing key, a wrong type, an unknown value or a non-object file gives `Last`. +#[test] +fn absent_or_invalid_is_the_default() { + for bytes in + [&b"{}"[..], b"", b"not json", b"[]", b"null", br#"{"startup":1}"#, br#"{"startup":"Empty"}"#, br#"{"startup":null}"#, br#"{"startup":["empty"]}"#] + { + assert_eq!(parse(bytes), Startup::Last, "{:?}", String::from_utf8_lossy(bytes)); + } +} + +/// Other keys do not hide `startup`. +#[test] +fn a_config_with_other_keys_still_has_its_startup() { + assert_eq!(parse(br#"{"remote":{"mounts":[]},"startup":"empty"}"#), Startup::Empty); + assert_eq!(parse(br#"{"startup":"demo","unknown":{"a":1}}"#), Startup::Demo); +} + +/// A file over `CONFIG_MAX` is ignored. +#[test] +fn an_oversized_file_is_the_default() { + let mut big = br#"{"startup":"empty","pad":""#.to_vec(); + big.extend(std::iter::repeat_n(b'x', CONFIG_MAX)); + big.extend_from_slice(br#""}"#); + assert_eq!(parse(&big), Startup::Last); +} + +/// Which project each value restores or opens. +#[test] +fn what_each_value_does_on_load() { + assert!(Startup::Last.restores() && Startup::Last.demo_when_nothing_restored()); + assert!(Startup::Empty.restores() && !Startup::Empty.demo_when_nothing_restored()); + assert!(!Startup::Demo.restores() && Startup::Demo.demo_when_nothing_restored()); +} + +/// Only the explicit demo start stops saving the session. +#[test] +fn only_the_demo_start_leaves_the_saved_session_alone() { + assert!(Startup::Last.keeps_session() && Startup::Empty.keeps_session()); + assert!(!Startup::Demo.keeps_session()); +} diff --git a/apps/effectcraft-web/tests/page.mjs b/apps/effectcraft-web/tests/page.mjs index 063015236..3728e59e2 100644 --- a/apps/effectcraft-web/tests/page.mjs +++ b/apps/effectcraft-web/tests/page.mjs @@ -8,6 +8,8 @@ // - A lost WebGPU device: the page says so over the canvas and offers Save Project and Reload; // the reload writes pending changes to browser storage first, and a failed write is reported // instead of reloading (#225). +// - Duplicate cancels the browser's bookmark action on the app canvas, while still delivering +// the key to the app; other chords and HTML inputs keep their browser behavior (#2495). // Exits non-zero on failure. import assert from "node:assert/strict"; import { dirname, join } from "node:path"; @@ -58,6 +60,30 @@ globalThis.location = { reload: () => reloads++ }; const host = await import(pathToFileURL(join(here, "../js/host.js"))); +// #2495: prevent the browser default without swallowing the app's Duplicate key. +const canvas = new EventTarget(); +const input = new EventTarget(); +host.installCanvasShortcuts(canvas); +let delivered = 0; +canvas.addEventListener("keydown", () => delivered++); +const key = (target, properties) => { + const event = new Event("keydown", { cancelable: true }); + Object.assign(event, { key: "d", ...properties }); + target.dispatchEvent(event); + return event.defaultPrevented; +}; +assert.equal(key(canvas, { ctrlKey: true }), true); +assert.equal(key(canvas, { metaKey: true, key: "D" }), true); +for (const properties of [{}, { ctrlKey: true, shiftKey: true }, { ctrlKey: true, altKey: true }, { ctrlKey: true, isComposing: true }, { ctrlKey: true, key: "r" }]) { + assert.equal(key(canvas, properties), false); +} +// Non-Latin layouts: the physical D key types "в", and eframe still reads it as D. +assert.equal(key(canvas, { ctrlKey: true, key: "в", code: "KeyD" }), true); +// Dvorak: the physical D key types "e", which is not Duplicate. +assert.equal(key(canvas, { ctrlKey: true, key: "e", code: "KeyD" }), false); +assert.equal(delivered, 9, "all keys still reach the app"); +assert.equal(key(input, { ctrlKey: true }), false, "an external HTML input keeps its shortcut"); + // #226: one readable and one unreadable file. const file = (name, read) => ({ name, webkitRelativePath: "", arrayBuffer: read }); const picked = host.browsePickFiles(false); diff --git a/apps/effectcraft-web/tests/smoke.mjs b/apps/effectcraft-web/tests/smoke.mjs index 3a6c1337c..83091a72a 100644 --- a/apps/effectcraft-web/tests/smoke.mjs +++ b/apps/effectcraft-web/tests/smoke.mjs @@ -162,7 +162,7 @@ try { const info = await js("effectcraft.info()"); report.steps.gpu = { webgpu: info.webgpu, backend: info.backend, ...info.gpu }; if (info.webgpu && info.backend === "BrowserWebGpu") { - check(info.gpu && info.gpu.compositor, "GPU compositor on WebGPU"); + check(info.gpu?.compositor, "GPU compositor on WebGPU"); check(info.gpu.viewerOnGpu, "viewer frames on the GPU"); } @@ -250,7 +250,7 @@ try { const st = await js(`(async () => { await new Promise((r) => setTimeout(r, 1200)); return effectcraft.execute("storage.info", {}); })()`); report.steps.storage = st; check(st.available && st.quota > 0 && st.usage > 0, `storage.info: ${JSON.stringify(st)}`); - check(st.diskCache && st.diskCache.enabled && st.diskCache.writes > 0, `disk cache in OPFS: ${JSON.stringify(st.diskCache)}`); + check(st.diskCache?.enabled && st.diskCache.writes > 0, `disk cache in OPFS: ${JSON.stringify(st.diskCache)}`); await js(`effectcraft.execute("prefs.open", {page: "disk"})`); await sleep(800); report.steps.storage.screenshot = await shot("01c-storage-manager"); @@ -378,14 +378,14 @@ try { const jobs = gj.workers.jobs || []; const rj = jobs.filter((j) => j.kind === "Render").pop(); const wj = jobs.filter((j) => j.kind === "Warp").pop(); - check(gj.workers.gpu && gj.workers.gpu.adapter, `job workers have a GPU: ${JSON.stringify(gj.workers)}`); + check(gj.workers.gpu?.adapter, `job workers have a GPU: ${JSON.stringify(gj.workers)}`); check(gj.status.every((st) => st === "Done"), `GPU job render: ${JSON.stringify(gj.status)}`); - check(rj && rj.gpu && rj.readbacks >= 3 && rj.passes >= 6, `Render Queue frames on the job worker's GPU, in passes: ${JSON.stringify(rj)}`); + check(rj?.gpu && rj.readbacks >= 3 && rj.passes >= 6, `Render Queue frames on the job worker's GPU, in passes: ${JSON.stringify(rj)}`); check(gj.warp.analyzed, `Warp Stabilizer analysed in a job worker: ${JSON.stringify(gj.warp)}`); - check(wj && wj.gpu && wj.readbacks > 0, `Warp Stabilizer input frames on the job worker's GPU: ${JSON.stringify(wj)}`); + check(wj?.gpu && wj.readbacks > 0, `Warp Stabilizer input frames on the job worker's GPU: ${JSON.stringify(wj)}`); const fj = jobs.filter((j) => j.kind === "ContentFill").pop(); check(!gj.fill.error && gj.fillLayers.length > 0, `Content-Aware Fill in a job worker: ${JSON.stringify(gj.fill)} ${JSON.stringify(gj.fillLayers)}`); - check(fj && fj.gpu && fj.readbacks > 0, `Content-Aware Fill frames on the job worker's GPU: ${JSON.stringify(fj)}`); + check(fj?.gpu && fj.readbacks > 0, `Content-Aware Fill frames on the job worker's GPU: ${JSON.stringify(fj)}`); check(gj.maxGapMs < 400, `event loop blocked ${gj.maxGapMs} ms during the GPU jobs: ${JSON.stringify(gj.gaps)}`); check(gj.gaps.prepareFill < 50, `event loop blocked ${gj.gaps.prepareFill} ms while editing after the analysis (pre-compose, mask): ${JSON.stringify(gj.gaps)}`); } else { @@ -418,7 +418,7 @@ try { check(mb.path === "/Browser Storage" && mb.entries.some((e) => e.name === "tone.wav" && e.kind === "audio"), `Media Browser: ${JSON.stringify(report.steps.mediaBrowser)}`); check(mb.actions.some((a) => a.id === "addFiles"), "Media Browser: Add Files…"); const mbImport = await js(`effectcraft.execute("mediaBrowser.import", {paths: [${JSON.stringify(mb.entries.find((e) => e.name === "tone.wav")?.path ?? "/tone.wav")}]})`); - check(!mbImport.pending && mbImport.items && mbImport.items.length === 1, `Media Browser import: ${JSON.stringify(mbImport)}`); + check(!mbImport.pending && mbImport.items?.length === 1, `Media Browser import: ${JSON.stringify(mbImport)}`); await js(`effectcraft.request("ui.playback", {action: "stop"})`); await js(`effectcraft.execute("time.set", {time: 0})`); await js(`effectcraft.request("ui.playback", {action: "play"})`); @@ -579,7 +579,7 @@ try { report.steps.final2 = { screenshot: await shot("03-offline") }; } catch (e) { failed = true; - report.error = String(e && e.stack || e); + report.error = String(e?.stack || e); try { report.errorShot = await shot("error"); } catch {} } report.console = logs.slice(-40); diff --git a/apps/effectcraft-web/web/sw.js b/apps/effectcraft-web/web/sw.js index 4acbeeeeb..8f48a93bc 100644 --- a/apps/effectcraft-web/web/sw.js +++ b/apps/effectcraft-web/web/sw.js @@ -3,7 +3,8 @@ // `cargo xtask web` fills in the build's file list and version. Every file is precached into a // cache named after the version, served cache-first (responses keep the server's headers, so // the page stays cross-origin isolated: COOP/COEP), and old versions are dropped once the new -// one activates. Anything else same-origin is fetched from the network and cached for next time. +// one activates. Anything else same-origin is fetched from the network and cached for next time, +// except the config file, which must stay live. const VERSION = "__EC_VERSION__"; const FILES = __EC_FILES__; const CACHE = `effectcraft-${VERSION}`; @@ -30,6 +31,7 @@ self.addEventListener("fetch", (event) => { const req = event.request; const url = new URL(req.url); if (req.method !== "GET" || url.origin !== self.location.origin) return; + if (url.pathname.endsWith("/effectcraft-config.json")) return; event.respondWith( (async () => { const cache = await caches.open(CACHE).catch(() => null); diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index d49330fa5..a560b00e3 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -9,8 +9,8 @@ //! //! `--control ` (or `EFFECTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server; //! see `effectcraft_ui_egui::control` for the methods. -#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] // Built everywhere so its tests run on every platform; only Linux AppImages use it. mod appearance; @@ -128,20 +128,34 @@ fn control_port(name: &str, value: Option<&str>) -> Result { /// answer without pumping events, so the window stops answering the compositor and the desktop /// offers to force-quit it while the Open dialog is up (#1275). Windows' blocking dialog pumps /// its own messages, so it keeps [`effectcraft_ui_egui::Hooks::pick_files`] and friends. -fn install_file_dialogs(app: &mut EffectcraftApp, ctx: &egui::Context) { +/// The dialogs asked for in a frame show at its end ([`sheet_dialogs::show_asked`]), where the +/// main window's handle is at hand. +fn install_file_dialogs(app: &mut EffectcraftApp) -> Option> { #[cfg(any(target_os = "macos", target_os = "linux"))] { - let ctx = ctx.clone(); - app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| sheet_dialogs::show(dialog, done, ctx.clone()))); + let (tx, rx) = std::sync::mpsc::channel::(); + app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| { + if tx.send((dialog, done)).is_err() { + log::error!("file dialog: the window is gone"); + } + })); + Some(rx) } #[cfg(not(any(target_os = "macos", target_os = "linux")))] - let _ = (app, ctx); + { + let _ = app; + None + } } +/// Run a normal desktop launch: install logging and the panic hook, parse arguments (`--version` exits before the +/// session log is attached), scan fonts, honor the launch guard's backend choice and open the eframe window. +/// Builds the session, loads settings, plugins, the requested project/media and the optional control server, then +/// reports startup failure through `startup_failed`. Returns the event loop's result. fn main() -> eframe::Result { // Windows: the update helper is a copy of this program started with `--finish-update`. #[cfg(windows)] - if let Some(result) = craft_update::self_update::finish(&craft_update::suite::EFFECTCRAFT, &std::env::args_os().skip(1).collect::>()) { + if let Some(result) = craft_update::self_update::finish(&effectcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) { std::process::exit(i32::from(result.is_err())); } // First, so the panic hook and every start-up warning are recorded. Records go to stderr @@ -249,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 { @@ -326,7 +343,7 @@ fn main() -> eframe::Result { })); // The commands' file dialogs don't block the frame where the platform's backend // would (#511, #1275); see [`install_file_dialogs`]. - install_file_dialogs(&mut app, &cc.egui_ctx); + let file_dialogs = install_file_dialogs(&mut app); #[cfg(target_os = "macos")] { app.hooks.app_action = Some(Box::new(native_menu::app_action)); @@ -344,20 +361,18 @@ fn main() -> eframe::Result { #[cfg(target_os = "macos")] apple_events: apple_events.connect(&cc.egui_ctx), app, + file_dialogs, window: window::Tracker::new(opened, saved_window, control_port.is_none()), launch, drawn: false, })) }), ); - 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 } @@ -366,9 +381,15 @@ fn main() -> eframe::Result { /// instead of eframe's fixed set: an older GPU that can't meet those (Intel HD Graphics 5500, /// #198) still opens the app, and the compositor then checks what it needs and falls back to /// the CPU when the device can't run it. `backends`: Window Graphics' choice, when it makes one. +/// +/// The surface is eframe's own default, [`eframe::SurfaceConfig::LOW_LATENCY`]: vsync with one +/// frame in flight, so a drag or a scrub shows one frame sooner. The bare +/// `WgpuConfiguration::default()` is `HIGH_THROUGHPUT` instead (two frames in flight, three +/// drawables on Metal): one more frame of pointer-to-screen lag on every interaction, about 8 ms +/// at 120 Hz and 17 ms at 60 Hz, and a third 15 MB drawable held for the window. fn wgpu_options(backends: Option) -> eframe::WgpuConfiguration { use eframe::egui_wgpu::WgpuSetup; - let mut config = eframe::WgpuConfiguration::default(); + let mut config = eframe::WgpuConfiguration::default().with_surface_config(eframe::SurfaceConfig::LOW_LATENCY); if let WgpuSetup::CreateNew(setup) = &mut config.wgpu_setup { if let Some(b) = backends { setup.instance_descriptor.backends = b; @@ -425,6 +446,8 @@ fn startup_failed(e: &eframe::Error, gl_next: bool) { /// The desktop app: the egui app plus, on macOS, the system menu bar. struct Desktop { app: EffectcraftApp, + /// The commands' file dialogs, shown at the end of the frame that asked for them. + file_dialogs: Option>, #[cfg(target_os = "macos")] menu: native_menu::NativeBar, /// Documents opened from Finder, the Dock or `open` (macOS). @@ -438,15 +461,24 @@ struct Desktop { } impl eframe::App for Desktop { + /// 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); self.app.tick(ctx, frame); self.app.finish_ime_logic(ctx); + 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) { self.app.ui(ui, frame); + if let Some(asked) = &self.file_dialogs { + sheet_dialogs::show_asked(asked, frame, ui.ctx()); + } self.window.frame(ui.ctx(), self.app.session.config.as_deref()); if !self.drawn { self.drawn = true; @@ -460,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) { @@ -541,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() { @@ -646,10 +696,10 @@ mod tests { #[test] fn commands_file_dialogs_do_not_block_the_frame() { let mut app = effectcraft_ui_egui::EffectcraftApp::new(effectcraft_engine::Session::default()); - super::install_file_dialogs(&mut app, &egui::Context::default()); + let asked = super::install_file_dialogs(&mut app); #[cfg(any(target_os = "macos", target_os = "linux"))] - assert!(app.hooks.file_dialog_async.is_some(), "the commands' file dialogs must not block the frame"); + assert!(app.hooks.file_dialog_async.is_some() && asked.is_some(), "the commands' file dialogs must not block the frame"); #[cfg(not(any(target_os = "macos", target_os = "linux")))] - assert!(app.hooks.file_dialog_async.is_none(), "the blocking dialog pumps its own messages here"); + assert!(app.hooks.file_dialog_async.is_none() && asked.is_none(), "the blocking dialog pumps its own messages here"); } } diff --git a/apps/effectcraft/src/native_menu.rs b/apps/effectcraft/src/native_menu.rs index 9db2ffe76..c3b142c5f 100644 --- a/apps/effectcraft/src/native_menu.rs +++ b/apps/effectcraft/src/native_menu.rs @@ -2,10 +2,10 @@ //! [`effectcraft_ui_egui::native_menu`]'s spec of the engine's After Effects menu tree. //! //! Each frame [`NativeBar::update`] runs menu activations (the same command ids as the in-window -//! bar), and when [`native_menu::state_key`] changed it rebuilds the spec and pushes labels, -//! enabled and check state into the existing items; a structural change (Open Recent, a new -//! shortcut preset) rebuilds the menu. Settings ▸ Appearance ▸ Use In-Window Menu Bar on macOS -//! swaps back to the in-window bar. +//! bar), and when [`native_menu::state_key`] or whether a text field has the keyboard changed it +//! rebuilds the spec and pushes labels, enabled and check state into the existing items; a +//! structural change (Open Recent, a new shortcut preset) rebuilds the menu. Settings ▸ +//! Appearance ▸ Use In-Window Menu Bar on macOS swaps back to the in-window bar. use std::collections::HashMap; use std::sync::mpsc::{Receiver, channel}; @@ -24,7 +24,7 @@ pub struct NativeBar { items: HashMap, spec: NativeMenu, structure: u64, - state: Option, + state: Option<(u64, bool)>, events: Receiver, /// The in-window-bar setting last applied (None: not yet). in_window: Option, @@ -67,12 +67,13 @@ impl NativeBar { if in_window { return; } - let key = native_menu::state_key(app); + // While a text field has the keyboard, the Edit items it takes stay enabled. + let key = (native_menu::state_key(app), native_menu::text_field_window(ctx).is_some()); if self.state == Some(key) && self.menu.is_some() { return; } self.state = Some(key); - let spec = native_menu::build(app); + let spec = native_menu::build_now(app, ctx); let structure = spec.structure(); if self.menu.is_none() || structure != self.structure { self.install(&spec); diff --git a/apps/effectcraft/src/sheet_dialogs.rs b/apps/effectcraft/src/sheet_dialogs.rs index 7a75ef2a7..911870190 100644 --- a/apps/effectcraft/src/sheet_dialogs.rs +++ b/apps/effectcraft/src/sheet_dialogs.rs @@ -17,10 +17,24 @@ use std::pin::Pin; type Answer = Pin> + Send>>; -/// Show `dialog` (on the main thread: the sheet opens here) and hand its paths to `done` once it -/// closes, then wake the UI. -pub fn show(dialog: FileDialog, done: DialogDone, ctx: egui::Context) { - let answer = open(dialog); +/// A command's dialog waiting for [`show_asked`]. +pub type Asked = (FileDialog, DialogDone); + +/// Show the dialogs the commands asked for in this frame (on the main thread: a sheet opens +/// here) and hand each one's paths to its `done` once it closes, then wake the UI. +/// +/// On macOS the sheet goes on the main window (`frame`'s). Without a parent rfd puts it on +/// AppKit's main window, which is a floating panel's OS window after a click in one; that +/// window hides while the app has no focused window, and a sheet whose window goes away ends +/// without answering, with later sheets queued behind it on the same window. +pub fn show_asked(asked: &std::sync::mpsc::Receiver, frame: &eframe::Frame, ctx: &egui::Context) { + while let Ok((dialog, done)) = asked.try_recv() { + show(dialog, done, frame, ctx.clone()); + } +} + +fn show(dialog: FileDialog, done: DialogDone, frame: &eframe::Frame, ctx: egui::Context) { + let answer = open(dialog, frame); let spawned = std::thread::Builder::new().name("ec-file-dialog".into()).spawn(move || { done(pollster::block_on(answer)); ctx.request_repaint(); @@ -30,6 +44,13 @@ pub fn show(dialog: FileDialog, done: DialogDone, ctx: egui::Context) { } } +/// A dialog on the main window. Linux keeps the portal's dialog unparented: rfd exports the +/// window for the portal from the dialog's worker thread there. +fn parented(frame: &eframe::Frame) -> AsyncFileDialog { + let d = AsyncFileDialog::new(); + if cfg!(target_os = "macos") { d.set_parent(frame) } else { d } +} + fn paths(files: Option>) -> Vec { files.unwrap_or_default().into_iter().map(|f| f.path().to_string_lossy().to_string()).collect() } @@ -39,10 +60,10 @@ fn path(file: Option) -> Vec { } /// The dialog, as the blocking hooks in `main.rs` show it. -fn open(dialog: FileDialog) -> Answer { +fn open(dialog: FileDialog, frame: &eframe::Frame) -> Answer { match dialog { FileDialog::Open { exts, many } => { - let d = AsyncFileDialog::new().add_filter("Media", &exts); + let d = parented(frame).add_filter("Media", &exts); if many { let f = d.pick_files(); Box::pin(async move { paths(f.await) }) @@ -52,22 +73,22 @@ fn open(dialog: FileDialog) -> Answer { } } FileDialog::OpenProject => { - let f = AsyncFileDialog::new().add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file(); + let f = parented(frame).add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file(); Box::pin(async move { path(f.await) }) } FileDialog::Folder => { - let f = AsyncFileDialog::new().pick_folder(); + let f = parented(frame).pick_folder(); Box::pin(async move { path(f.await) }) } FileDialog::Save { default, ext, project } => { let at = std::path::Path::new(&default); let name = at.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); let mut d = if project { - AsyncFileDialog::new().add_filter("EffectCraft Project", &["ecproj"]) + parented(frame).add_filter("EffectCraft Project", &["ecproj"]) } else if ext.is_empty() { - AsyncFileDialog::new() + parented(frame) } else { - AsyncFileDialog::new().add_filter(&ext, &[ext.as_str()]) + parented(frame).add_filter(&ext, &[ext.as_str()]) }; // A default with a folder that exists opens the dialog there. if let Some(dir) = at.parent().filter(|d| d.is_dir()) { diff --git a/apps/effectcraft/tests/args.rs b/apps/effectcraft/tests/args.rs index 8367d2a99..62c591b45 100644 --- a/apps/effectcraft/tests/args.rs +++ b/apps/effectcraft/tests/args.rs @@ -1,11 +1,14 @@ //! 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}; use std::time::{Duration, Instant}; +/// Nextest archives relocate binaries; plain Cargo keeps the compile-time path valid. +fn binary() -> std::ffi::OsString { + std::env::var_os("NEXTEST_BIN_EXE_effectcraft").unwrap_or_else(|| env!("CARGO_BIN_EXE_effectcraft").into()) +} + /// A settings folder of its own, so a launch that got past the arguments would show up as /// preferences (Recent Footage) written there instead of in the user's settings. fn config_dir(name: &str) -> PathBuf { @@ -19,7 +22,7 @@ fn config_dir(name: &str) -> PathBuf { /// regression fails instead of leaving a window open: the process is killed after 5 s. fn rejects(name: &str, args: &[&str], env: &[(&str, &str)], message: &str) { let config = config_dir(name); - let mut cmd = Command::new(env!("CARGO_BIN_EXE_effectcraft")); + let mut cmd = Command::new(binary()); cmd.args(args).env_remove("EFFECTCRAFT_CONTROL_PORT").env_remove("RUST_LOG").env("EFFECTCRAFT_CONFIG_DIR", &config); for (k, v) in env { cmd.env(k, v); @@ -67,7 +70,7 @@ fn a_bad_control_port_stops_the_launch() { #[test] fn version_still_prints_with_a_bad_port_variable() { - let out = Command::new(env!("CARGO_BIN_EXE_effectcraft")).arg("--version").env("EFFECTCRAFT_CONTROL_PORT", "abc").output().unwrap(); + let out = Command::new(binary()).arg("--version").env("EFFECTCRAFT_CONTROL_PORT", "abc").output().unwrap(); assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("effectcraft {}\n", env!("CARGO_PKG_VERSION"))); } diff --git a/assets/app-icon/README.md b/assets/app-icon/README.md index 814ad601a..f0265ce9e 100644 --- a/assets/app-icon/README.md +++ b/assets/app-icon/README.md @@ -15,9 +15,8 @@ full-bleed colour field, tight head-and-shoulders crop, the body running off the | Paper | `#efe9dc` | the figure | | Ink | `#0b0b0c` | line work and contour | -**Provenance:** the project owner drew it in ArtCraft (a 2880 px engraving-style image, kept in -craftrules at `assets/app-icons/effectcraft/source.png`). It was vectorised with craftrules -`assets/logo-options/_tools/vectorize_tile.py`, which keys out the field and traces the ink with potrace. +**Provenance:** the project owner drew it in ArtCraft (a 2880 px engraving-style image). It was +vectorised with potrace, which keys out the field and traces the ink. Licence: see [LICENSE.txt](LICENSE.txt) (MIT OR Apache-2.0, like the repo). ## Files @@ -37,6 +36,5 @@ App ID: `ai.storyteller.effectcraft` (Wayland app ID, `packaging/linux/ai.storyt ## Regenerate -Copy a new `icon-master.svg` / `icon.svg` from craftrules `assets/app-icons/effectcraft/` over -`effectcraft.svg` / `effectcraft-small.svg`, then run `packaging/icons.sh` (needs `resvg`; `iconutil` +After replacing `effectcraft.svg` / `effectcraft-small.svg`, run `packaging/icons.sh` (needs `resvg`; `iconutil` on macOS for the `.icns`). Copy `effectcraft-small.svg` to `apps/effectcraft-web/web/favicon.svg` too. diff --git a/assets/app-icon/effectcraft-small.svg.attribution b/assets/app-icon/effectcraft-small.svg.attribution index 6dbaab168..88fd92379 100644 --- a/assets/app-icon/effectcraft-small.svg.attribution +++ b/assets/app-icon/effectcraft-small.svg.attribution @@ -5,4 +5,4 @@ source: original work license: MIT OR Apache-2.0 license-file: assets/app-icon/LICENSE.txt added: 2026-10-02 by EffectCraft contributors (Claude agent, app icon) -notes: The same drawing vectorised at 1024 px with craftrules vectorize_tile.py, about 350 KB, for places where size matters. +notes: The same drawing vectorised at 1024 px, about 350 KB, for places where size matters. diff --git a/assets/app-icon/effectcraft.svg.attribution b/assets/app-icon/effectcraft.svg.attribution index 219606236..19bf871bd 100644 --- a/assets/app-icon/effectcraft.svg.attribution +++ b/assets/app-icon/effectcraft.svg.attribution @@ -5,4 +5,4 @@ source: original work license: MIT OR Apache-2.0 license-file: assets/app-icon/LICENSE.txt added: 2026-10-02 by EffectCraft contributors (Claude agent, app icon) -notes: The owner's ArtCraft drawing of an engraved unicorn bust, vectorised at 2048 px with craftrules assets/logo-options/_tools/vectorize_tile.py (potrace; three colours). Canonical artwork; the other icon files derive from it. +notes: The owner's ArtCraft drawing of an engraved unicorn bust, vectorised at 2048 px (potrace; three colours). Canonical artwork; the other icon files derive from it. diff --git a/clippy.toml b/clippy.toml index 4b6c79f55..3522a15b6 100644 --- a/clippy.toml +++ b/clippy.toml @@ -2,3 +2,7 @@ allow-unwrap-in-tests = true allow-expect-in-tests = true allow-panic-in-tests = true allow-indexing-slicing-in-tests = true +disallowed-types = [ + { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, +] +cognitive-complexity-threshold = 30 diff --git a/contributors/people.toml b/contributors/people.toml index 1012a37bd..95e7e2e93 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -1,13 +1,10 @@ -# Contributor names for the About window (craftrules standards/contributors.md). +# Contributor names for the About window. # # Everyone who contributes is credited by GitHub username automatically. Anything more is up to # you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a # maintainer may add a person only when that person asked, recording where in consent_source. # -# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \ -# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name] -# -# Or copy this block, replacing your-github-username (every field is optional): +# Copy this block, replacing your-github-username (every field is optional): # # [people.your-github-username] # real_name = "Your Name" # shown when the viewer picks "Real name" @@ -17,11 +14,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 +103,27 @@ 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" + +[people.dylanmtaylor] +sync_github_name = true + +[people.adikshan11] +real_name = "Adithya Shankaran" +sync_github_name = true + +[people.marshiyar] +real_name = "Arshiya Rahgozar" +display_name = "Arshiya" +sync_github_name = false + +[people.JonRC] +real_name = "Jonathan Cardoso" +display_name = "Jonathan Cardoso" diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index b14b16321..916cc3501 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -14,6 +14,7 @@ effectcraft-engine = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } image = { workspace = true } +effectcraft-model = { workspace = true } [dev-dependencies] effectcraft-script = { workspace = true } diff --git a/crates/automation/src/backend.rs b/crates/automation/src/backend.rs index c3947c876..69f75df2e 100644 --- a/crates/automation/src/backend.rs +++ b/crates/automation/src/backend.rs @@ -4,7 +4,7 @@ use effectcraft_engine::Session; use effectcraft_engine::time::Tick; use serde_json::{Value, json}; -use crate::confine::Confinement; +use crate::confine::{ConfinedFootage, ConfinedServices, Confinement, Lookup}; use crate::{BridgeClient, Error, Result, base64, encode_png, recompress_png}; pub struct Backend { @@ -39,8 +39,14 @@ impl Backend { Ok(Backend { target: Target::Bridge(BridgeClient::new(addr)?), confinement: None }) } - /// Confine the files the commands run here may read and write ([`Confinement`]). + /// Confine the files the commands run here may read and write ([`Confinement`]). In-process, + /// the session's own footage and file reads are confined too, so a project from a read root + /// can't show footage from outside it. pub fn confine(&mut self, confinement: Confinement) { + if let Target::Headless(s) = &mut self.target { + s.footage = std::sync::Arc::new(ConfinedFootage::new(s.footage.clone(), confinement.clone())); + s.services = std::sync::Arc::new(ConfinedServices::new(s.services.clone(), confinement.clone())); + } self.confinement = Some(confinement); } @@ -63,12 +69,11 @@ impl Backend { pub fn exec(&mut self, id: &str, params: Value) -> Result { let params = if params.is_null() { json!({}) } else { params }; if let Some(c) = self.confinement.clone() { - let lookup = match c.check_command(id, ¶ms)? { - Some(crate::confine::Lookup::ProjectFile) => Some((crate::confine::Lookup::ProjectFile, self.run("project.summary", json!({}))?)), - Some(crate::confine::Lookup::RenderOutputs) => Some((crate::confine::Lookup::RenderOutputs, self.run("renderQueue.list", json!({}))?)), - None => None, - }; - if let Some((lookup, found)) = lookup { + for lookup in c.check_command(id, ¶ms)? { + let found = match lookup { + Lookup::ProjectFile | Lookup::QueueDefault => self.run("project.summary", json!({}))?, + Lookup::RenderOutputs => self.run("renderQueue.list", json!({}))?, + }; c.check_lookup(&lookup, &found)?; } } @@ -86,6 +91,18 @@ impl Backend { } } + /// Query whether the current project protects `path`. This is an advisory snapshot, not a + /// reservation: use [`Self::render_to_file`] to validate and write in the owning session. + /// Relative paths are resolved against this process's folder, because the app has its own. + pub fn check_output(&mut self, path: &str) -> Result<()> { + let absolute = std::path::absolute(path).map_err(|e| Error::BadArgs(format!("cannot resolve {path}: {e}")))?; + let found = self.exec("file.checkOutput", json!({"path": absolute.to_string_lossy()}))?; + match found.get("blocked").and_then(Value::as_bool) { + Some(false) => Ok(()), + _ => Err(Error::BadArgs(found.get("reason").and_then(Value::as_str).unwrap_or("this file is one the project reads").to_string())), + } + } + /// Call a raw control-channel method (bridge only). pub fn control(&mut self, method: &str, params: Value) -> Result { match &mut self.target { @@ -103,16 +120,40 @@ impl Backend { /// [`Backend::render`]; `transparent` keeps the frame's alpha instead of compositing it over /// the comp's background colour. pub fn render_with(&mut self, comp: Option<&Value>, time: Option, max_side: u32, transparent: bool) -> Result { + self.render_output(comp, time, max_side, transparent, None) + } + + /// Render and save in the owning session. In bridge mode, checking and writing are one + /// desktop operation, so another command cannot make the destination protected in between. + pub fn render_to_file(&mut self, comp: Option<&Value>, time: Option, max_side: u32, transparent: bool, path: &str) -> Result { + if let Some(confinement) = self.confinement() { + confinement.check(path, crate::confine::Access::Write)?; + } + let absolute = std::path::absolute(path).map_err(|e| Error::BadArgs(format!("cannot resolve {path}: {e}")))?; + self.render_output(comp, time, max_side, transparent, Some(&absolute.to_string_lossy())) + } + + fn render_output(&mut self, comp: Option<&Value>, time: Option, max_side: u32, transparent: bool, path: Option<&str>) -> Result { match &mut self.target { Target::Headless(s) => { + if let Some(path) = path { + s.refuse_export(path)?; + } let cid = s.resolve_comp(comp).map_err(|_| no_comp(comp))?; let t = time.map(Tick::from_seconds_f64).unwrap_or_else(|| s.time()); let (w, h, rgba) = s.render_rgba8_alpha(cid, t, max_side, transparent)?; let png = encode_png(w, h, rgba, max_side)?; + if let Some(path) = path { + s.write_output(path, &png)?; + } Ok(Frame { png, comp: json!(cid.0), time: t.seconds(), width: w, height: h }) } Target::Bridge(b) => { let mut p = json!({"max_side": max_side, "base64": true}); + if let Some(path) = path { + p["path"] = json!(path); + p["write"] = json!(true); + } if transparent { p["transparent"] = json!(true); } @@ -123,6 +164,11 @@ impl Backend { p["comp"] = c.clone(); } let r = b.call("render.frame", p)?; + if let Some(path) = path + && r.get("path").and_then(Value::as_str) != Some(path) + { + return Err(Error::App("render.frame did not confirm the saved path; update the connected app before saving frames".into())); + } let data = r.get("png").and_then(Value::as_str).ok_or_else(|| Error::App("render.frame returned no `png`".into()))?; let raw = base64::decode(data).ok_or_else(|| Error::App("render.frame: bad base64".into()))?; let (png, width, height) = recompress_png(&raw, max_side)?; diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index f396b3da0..5f0a7a51a 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -6,11 +6,23 @@ //! `engine.batch`) is checked before it runs: its file parameters must be absolute paths inside //! the roots (symbolic links resolved, no `..`), any other absolute path in its parameters too, //! and commands that write to a file they are not given (Save, Increment and Save, rendering the -//! Render Queue) are checked against where they would write. Commands that run code or open -//! other applications (scripts, Execute File, Reveal in Finder…) are refused while confined. +//! 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. In a headless session the footage and other files a project or an imported file names +//! are read only inside the roots ([`ConfinedFootage`], [`ConfinedServices`]). 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}; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use effectcraft_engine::Services; +use effectcraft_engine::project::{Footage, ItemId}; +use effectcraft_engine::raster::{AuxChannels, Image}; +use effectcraft_engine::render::{FloatCurve, FootageSource}; +use effectcraft_engine::time::Tick; use serde_json::Value; use crate::{Error, Result}; @@ -28,13 +40,21 @@ pub struct Confinement { write: Vec, } -/// Commands that run code, act outside the project or open other applications. +/// Commands that run code, act outside the project or open other applications, besides the +/// commands that run scripts ([`effectcraft_engine::commands::scripts::RUNS_SCRIPTS`]). const REFUSED: &[&str] = &[ - "script.run", - "file.runScript", "file.installScript", "file.installScriptUIPanel", "file.uninstallScript", + // A ScriptUI panel's script runs when the panel opens, when an event reaches one of its + // controls, and when one of its `app.scheduleTask` tasks comes due. + "window.scriptPanel", + "window.restoreScriptPanels", + "script.runTasks", + "scriptui.click", + "scriptui.set", + "scriptui.mouse", + "scriptui.close", "file.executeFile", "file.revealInFinder", "layer.openSource", @@ -64,15 +84,38 @@ 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"]), + // Looks up what the file at `path` is (writable roots are readable too). + ("file.checkOutput", &["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"]), ("file.saveCopyAsXml", &["path"]), ("file.collectFiles", &["folder"]), + // This query checks a proposed write destination, so it follows write confinement. + ("file.checkOutput", &["path"]), ("file.exportLottie", &["path"]), ("comp.saveFrameAs", &["path"]), ("comp.saveFrameAsPsd", &["path"]), @@ -84,11 +127,17 @@ 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. const IMPLICIT_WRITES: &[&str] = &["file.save", "file.incrementAndSave", "renderQueue.render", "render.preRender"]; +/// Commands that queue a render, whose output defaults to the project's folder when not given. +const QUEUES_AN_OUTPUT: &[&str] = &["renderQueue.add", "render.preRender", "file.createProxy"]; + /// What must be looked up before an implicitly writing command may run. #[derive(Debug, PartialEq, Eq)] pub enum Lookup { @@ -96,6 +145,10 @@ pub enum Lookup { ProjectFile, /// 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 { @@ -134,24 +187,34 @@ impl Confinement { /// Check engine command `id` with `params` before it runs. Returns what must still be looked /// up and passed to [`Confinement::check_lookup`] for commands that write implicitly. - pub fn check_command(&self, id: &str, params: &Value) -> Result> { - if REFUSED.contains(&id) { + pub fn check_command(&self, id: &str, params: &Value) -> Result> { + if REFUSED.contains(&id) || effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id) { return Err(Error::BadArgs(format!("`{id}` is not available while file access is confined (--automation-read-root/--automation-write-root)"))); } + // Scripts can't grant themselves the access the user withheld + // (`effectcraft_engine::prefs::Scripting::same_permissions`), and neither may a confined + // agent: `prefs.set` saves the settings file, so a script run long after this session + // would still have it. (`prefs.reset` only puts the defaults back, which grant nothing.) + if id == "prefs.set" && prefs_keys(params).iter().any(|k| is_scripting_pref(k)) { + return Err(Error::BadArgs( + "`prefs.set`: the Scripting settings decide what a script may do, so they can't be changed while file access is confined".into(), + )); + } if id == "engine.batch" { for step in params.get("steps").and_then(Value::as_array).into_iter().flatten() { let Some(inner) = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str) else { continue }; let inner_params = step.get("params").cloned().unwrap_or(Value::Null); let named = keys_of(READS, inner).or_else(|| keys_of(WRITES, inner)).unwrap_or(&[]); + let mixed = keys_of(MIXED_WRITES, inner).unwrap_or(&[]); // A step's `$N` reference is resolved only as the batch runs: never for a file. - if named.iter().filter_map(|k| inner_params.get(*k)).any(has_reference) { + if named.iter().chain(mixed).filter_map(|k| inner_params.get(*k)).any(has_reference) { return Err(Error::BadArgs(format!("`{inner}` in a batch: file paths can't be `$N` references while file access is confined"))); } - if self.check_command(inner, &inner_params)?.is_some() { + if !self.check_command(inner, &inner_params)?.is_empty() { return Err(Error::BadArgs(format!("`{inner}` writes to its project's or queue's files: run it on its own while file access is confined"))); } } - return Ok(None); + return Ok(vec![]); } let (named, access) = match (keys_of(WRITES, id), keys_of(READS, id)) { (Some(k), _) => (k, Access::Write), @@ -160,6 +223,10 @@ impl Confinement { (None, None) => (&[][..], Access::Read), }; let mut given = false; + // Whether one of them is a file: not a Render Queue template, whose own output is relative + // and resolved like one the agent never gave, and not an array, which the commands that + // take a single output read as nothing at all. + let mut gave_a_file = false; for key in named { let Some(v) = params.get(*key).filter(|v| !v.is_null()) else { continue }; given = true; @@ -170,17 +237,27 @@ 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)?; } None => return Err(Error::BadArgs(format!("`{id}`: `{key}` must be a path"))), } } } + // 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)?; } @@ -188,10 +265,19 @@ impl Confinement { if matches!(id, "file.openRecent" | "file.importRecent" | "anim.applyRecentPreset") && !given { return Err(Error::BadArgs(format!("`{id}`: give `path` while file access is confined"))); } - if IMPLICIT_WRITES.contains(&id) && !(given && id == "file.save") { - return Ok(Some(if id == "file.save" || id == "file.incrementAndSave" { Lookup::ProjectFile } else { Lookup::RenderOutputs })); + let mut lookups = vec![]; + // An output the agent did not name is derived from where the project sits: the Default + // Output Folder preference when that is set, beside the project file otherwise — and the + // home folder when the project has never been saved, outside every root (#2479). Only + // 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::QueueDefault); } - Ok(None) + if IMPLICIT_WRITES.contains(&id) && !(given && id == "file.save") { + lookups.push(if id == "file.save" || id == "file.incrementAndSave" { Lookup::ProjectFile } else { Lookup::RenderOutputs }); + } + Ok(lookups) } /// Check what a [`Lookup`] found: `project.summary` or `renderQueue.list`'s result. @@ -202,6 +288,13 @@ 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())), }, + // 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 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 => { let items = found.get("items").or(Some(found)).and_then(Value::as_array).cloned().unwrap_or_default(); for it in items.iter().filter(|it| it.get("render").and_then(Value::as_bool) != Some(false)) { @@ -218,6 +311,142 @@ impl Confinement { } } +/// The session's footage, read only inside the read roots (#2131 F-11, "Indirect reads"). The +/// commands an agent runs are checked by the paths they are given, but a project opened or +/// imported from a read root names its own footage, which is read when it renders: a file +/// outside the roots shows as missing footage instead. +pub struct ConfinedFootage { + inner: Arc, + confinement: Confinement, +} + +impl ConfinedFootage { + pub fn new(inner: Arc, confinement: Confinement) -> Self { + Self { inner, confinement } + } + + /// Whether every file of `f` (its file and each frame of a sequence) is inside the roots. + /// Checked on every read, not remembered: a symbolic link inside a root can be pointed + /// elsewhere after it was first read. + fn inside(&self, f: &Footage) -> bool { + std::iter::once(&f.path).chain(&f.sequence).filter(|p| !p.is_empty()).all(|p| self.confinement.check(p, Access::Read).is_ok()) + } +} + +impl FootageSource for ConfinedFootage { + fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option> { + self.inside(footage).then(|| self.inner.frame(item, footage, t)).flatten() + } + fn frame_at_size(&self, item: ItemId, footage: &Footage, t: Tick, width: u32, height: u32) -> Option> { + self.inside(footage).then(|| self.inner.frame_at_size(item, footage, t, width, height)).flatten() + } + fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option> { + self.inside(footage).then(|| self.inner.frame_with(item, footage, t, size, float)).flatten() + } + fn audio(&self, item: ItemId, footage: &Footage, t: Tick, frames: usize, rate: u32) -> Option> { + self.inside(footage).then(|| self.inner.audio(item, footage, t, frames, rate)).flatten() + } + fn aux(&self, item: ItemId, footage: &Footage, t: Tick) -> Option> { + self.inside(footage).then(|| self.inner.aux(item, footage, t)).flatten() + } + fn model(&self, item: ItemId, footage: &Footage) -> Option> { + self.inside(footage).then(|| self.inner.model(item, footage)).flatten() + } + fn vector_frame(&self, item: ItemId, footage: &Footage, scale: f64) -> Option> { + self.inside(footage).then(|| self.inner.vector_frame(item, footage, scale)).flatten() + } + /// Says why footage outside the roots shows nothing, so the agent can act on it. + fn problem(&self, footage: &Footage) -> Option { + if !self.inside(footage) { + return Some(format!("{} is outside the automation read roots, so it is not read", footage.path)); + } + self.inner.problem(footage) + } + fn set_cache_budget(&self, bytes: usize) { + self.inner.set_cache_budget(bytes) + } + fn purge(&self) { + self.inner.purge() + } + fn forget(&self, path: &str) { + self.inner.forget(path) + } + fn set_conform_folder(&self, folder: Option) { + self.inner.set_conform_folder(folder) + } + fn cache_budget(&self) -> Option { + self.inner.cache_budget() + } + fn cancellable(&self, cancel: &Arc, f: &mut dyn FnMut()) { + self.inner.cancellable(cancel, f) + } + fn called_off(&self) -> bool { + self.inner.called_off() + } +} + +/// The session's file reads, only inside the read roots: what a project or an imported file +/// pulls in by itself (an SVG's or Lottie's images, a timeline's media) is checked like a path +/// the agent gives. Writes are left to the command checks. +pub struct ConfinedServices { + inner: Arc, + confinement: Confinement, +} + +impl ConfinedServices { + pub fn new(inner: Arc, confinement: Confinement) -> Self { + Self { inner, confinement } + } + + fn inside(&self, path: &str) -> bool { + self.confinement.check(path, Access::Read).is_ok() + } +} + +impl Services for ConfinedServices { + fn read_file(&self, path: &str) -> std::io::Result> { + self.confinement.check(path, Access::Read).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e.to_string()))?; + self.inner.read_file(path) + } + fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> { + self.inner.write_file(path, data) + } + fn exists(&self, path: &str) -> bool { + self.inside(path) && self.inner.exists(path) + } + fn stamp(&self, path: &str) -> Option<(u64, u128)> { + if self.inside(path) { self.inner.stamp(path) } else { None } + } + fn store_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> { + self.inner.store_file(path, data) + } + fn list_dir(&self, dir: &str) -> Vec { + if self.inside(dir) { self.inner.list_dir(dir) } else { vec![] } + } + fn is_dir(&self, path: &str) -> bool { + self.inside(path) && self.inner.is_dir(path) + } + fn full_path(&self, path: &str) -> Option { + self.inner.full_path(path) + } +} + +/// Whether a `prefs.set` key reaches the Scripting page. `Prefs::set` turns the key into a JSON +/// pointer (`key.replace('.', "/")`), so `scripting/allowScriptsWriteFiles` resolves as well as +/// the dotted spelling, and the page on its own replaces every setting on it. +fn is_scripting_pref(key: &str) -> bool { + key.split(['.', '/']).next().is_some_and(|page| page.eq_ignore_ascii_case("scripting")) +} + +/// The settings `prefs.set` is given: `key`, and the keys of `values`. +fn prefs_keys(params: &Value) -> Vec<&str> { + let mut keys: Vec<&str> = params.get("key").and_then(Value::as_str).into_iter().collect(); + if let Some(Value::Object(m)) = params.get("values") { + keys.extend(m.keys().map(String::as_str)); + } + keys +} + fn keys_of(table: &[(&str, &'static [&'static str])], id: &str) -> Option<&'static [&'static str]> { table.iter().find(|(c, _)| *c == id).map(|(_, k)| *k) } @@ -274,3 +503,119 @@ fn resolve(p: &Path) -> Option { at = at.parent()?; } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Commands whose parameters are named like files but aren't: property paths + /// (`path|prop`), project folders, layers to track or target. + const NOT_FILES: &[&str] = &[ + "camera.createFromSolve", + "edit.label", + "edit.pasteReversedKeyframes", + "effect.editDropdown", + "essential.addProperty", + "essential.canAdd", + "essential.linkProperty", + "essential.unlinkProperty", + "keys.paste", + "keys.set", + "keys.setEase", + "keys.setSpatialTangents", + "paths.nullsFollowPoints", + "paths.pointsFollowNulls", + "paths.tracePath", + "project.move", + "prop.addKey", + "prop.convertExpressionToKeyframes", + "prop.deselect", + "prop.editGradient", + "prop.get", + "prop.pickWhip", + "prop.reset", + "prop.select", + "prop.set", + "prop.setExpression", + "prop.toggleAnimation", + "prop.toggleKey", + "track.new", + "track.property", + "track.setTarget", + ]; + + /// Every engine command with a parameter named like a file is classified, so a new one + /// can't silently skip confinement (#1243): it reads or writes the files it is given, is + /// refused, writes where it isn't told (checked by a lookup), sets Render Queue outputs + /// (checked when the queue renders), or is listed in `NOT_FILES`. + #[test] + fn every_command_with_file_parameters_is_classified() { + const FILE_KEYS: &[&str] = + &["path", "paths", "folder", "file", "files", "dir", "directory", "output", "outputs", "source", "target", "projectPath", "footageDir"]; + let mut missing = vec![]; + for spec in effectcraft_engine::command_specs() { + let Some(params) = effectcraft_engine::commands::accepted_params(spec.params) else { continue }; + let id = spec.id; + let classified = keys_of(READS, id).is_some() + || keys_of(WRITES, id).is_some() + || REFUSED.contains(&id) + || effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id) + || IMPLICIT_WRITES.contains(&id) + || id.starts_with("renderQueue.") + || id == "render.addOutputModule" + || NOT_FILES.contains(&id); + if params.iter().any(|p| FILE_KEYS.contains(&p.as_str())) && !classified { + missing.push(format!("{id} {}", spec.params)); + } + } + assert!(missing.is_empty(), "add these to READS / WRITES / REFUSED or NOT_FILES:\n{}", missing.join("\n")); + // And every listed command exists. + for id in READS.iter().chain(WRITES).chain(MIXED_WRITES).map(|(c, _)| *c).chain(NOT_FILES.iter().copied()) { + assert!(effectcraft_engine::command_specs().iter().any(|s| s.id == id), "{id} is not a command"); + } + } + + #[test] + fn relative_and_outside_paths_of_newly_listed_commands_are_refused() { + let base = std::env::temp_dir().join(format!("ec-confine-1243-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (read, write, outside) = (base.join("in"), base.join("out"), base.join("elsewhere")); + for d in [&read, &write, &outside] { + std::fs::create_dir_all(d).unwrap(); + } + let c = Confinement::new(std::slice::from_ref(&read), std::slice::from_ref(&write)).unwrap(); + let p = |d: &std::path::Path, f: &str| d.join(f).to_string_lossy().into_owned(); + let up = format!("{}/../elsewhere/t.ectemplate", read.display()); + for (id, key) in [ + ("essential.templateInfo", "path"), + ("file.importProject", "path"), + ("footage.relink", "path"), + ("layer.newModel", "path"), + ("mediaBrowser.fileInfo", "path"), + ("mediaBrowser.list", "path"), + ("face.model.install", "path"), + ("templates.create", "path"), + ] { + for bad in ["t.ectemplate".to_string(), "../elsewhere/t.ectemplate".to_string(), up.clone(), p(&outside, "t.ectemplate")] { + assert!(c.check_command(id, &serde_json::json!({key: bad})).is_err(), "{id} {key}={bad}"); + } + assert!(c.check_command(id, &serde_json::json!({key: p(&read, "t.ectemplate")})).is_ok(), "{id} in a read root"); + } + // Batched too. + let steps = serde_json::json!({"steps": [{"command": "essential.templateInfo", "params": {"path": "t.ectemplate"}}]}); + assert!(c.check_command("engine.batch", &steps).is_err()); + // Writes: into a write root only. + for id in ["file.exportTimeline", "file.checkOutput"] { + for bad in ["cut.xml".to_string(), p(&read, "cut.xml"), p(&outside, "cut.xml")] { + assert!(c.check_command(id, &serde_json::json!({"path": bad})).is_err(), "{id} {bad}"); + } + assert!(c.check_command(id, &serde_json::json!({"path": p(&write, "cut.xml")})).is_ok()); + } + let create = |project: String| serde_json::json!({"path": p(&read, "t.ectemplate"), "projectPath": project}); + assert!(c.check_command("templates.create", &create(p(&read, "new.ecproj"))).is_err(), "templates.create writes projectPath"); + assert!(c.check_command("templates.create", &create(p(&write, "new.ecproj"))).is_ok()); + assert!(c.check_command("file.createProxy", &serde_json::json!({"output": "[compName]_proxy"})).is_ok(), "a template name"); + assert!(c.check_command("file.createProxy", &serde_json::json!({"output": p(&outside, "proxy.mov")})).is_err()); + let _ = std::fs::remove_dir_all(&base); + } +} diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 6e622b377..bb09e244b 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -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::>()); + + 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(); @@ -442,6 +486,21 @@ fn autosave_flushes_on_read_errors_and_broken_output() { std::fs::remove_dir_all(dir).unwrap(); } +fn fake_render_frame(session: &Session, params: &Value) -> Value { + let png = crate::encode_png(4, 2, vec![255; 32], 0).unwrap(); + if params["write"] == true { + let Some(path) = params["path"].as_str() else { return json!({"ok": false, "error": "missing output path"}) }; + if let Err(error) = session.write_output(path, &png) { + return json!({"ok": false, "error": error.to_string()}); + } + } + let mut result = json!({"comp": 1, "time": 0.5, "width": 4, "height": 2, "png": base64::encode(&png)}); + if params["write"] == true { + result["path"] = params["path"].clone(); + } + json!({"ok": true, "result": result}) +} + /// A fake desktop control channel: answers `engine.execute` from a real session, and canned /// `render.frame` / `ui.screenshot` replies. fn fake_app() -> u16 { @@ -459,10 +518,7 @@ fn fake_app() -> u16 { Ok(v) => json!({"ok": true, "result": v}), Err(e) => json!({"ok": false, "error": e.to_string()}), }, - "render.frame" => { - let png = crate::encode_png(4, 2, vec![255; 32], 0).unwrap(); - json!({"ok": true, "result": {"comp": 1, "time": 0.5, "width": 4, "height": 2, "png": base64::encode(&png)}}) - } + "render.frame" => fake_render_frame(&session, p), "ui.screenshot" => { let png = crate::encode_png(8, 8, vec![128; 256], 0).unwrap(); std::fs::write(p["path"].as_str().unwrap(), png).unwrap(); @@ -491,6 +547,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 +796,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 +1428,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,7 +1474,383 @@ 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); } + +/// #2479: a queued output the agent didn't name is derived from where the project sits, so on a +/// project that has never been saved it lands in the home folder, outside every automation root. +/// Queueing it there is refused, rather than leaving an item that can never be rendered. +#[test] +fn automation_roots_refuse_a_queued_output_from_an_unsaved_project() { + use crate::confine::Confinement; + let base = std::env::temp_dir().join(format!("ec-mcp-default-out-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let write = base.join("out"); + std::fs::create_dir_all(&write).unwrap(); + let p = |f: &str| write.join(f).to_string_lossy().into_owned(); + let mut s = server(); + s.backend().confine(Confinement::new(&[], std::slice::from_ref(&write)).unwrap()); + call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "A", "width": 64, "height": 64}})); + // Never saved: neither way of queueing a render may fall back to the home folder. + for command in ["renderQueue.add", "render.preRender", "file.createProxy"] { + let (c, err) = call(&mut s, "execute_command", json!({"command": command, "params": {"comp": "A"}})); + assert!(err, "`{command}` without an output should be refused: {c:?}"); + let msg = c[0]["text"].as_str().unwrap_or_default(); + assert!(msg.contains("the project has no file yet"), "`{command}`: {msg}"); + } + // An array where a path goes: the engine reads a scalar, so this is no output either. + let (c, err) = call(&mut s, "execute_command", json!({"command": "file.createProxy", "params": {"comp": "A", "path": [p("arr.mov")]}})); + assert!(err, "an array where the output goes should be refused: {c:?}"); + assert!(c[0]["text"].as_str().unwrap_or_default().contains("the project has no file yet"), "{c:?}"); + // Naming an Output Module template instead of a file is the same: its own output is relative. + let (c, err) = call(&mut s, "execute_command", json!({"command": "render.preRender", "params": {"comp": "A", "output": "Lossless"}})); + assert!(err, "a template output should be refused too: {c:?}"); + assert!(c[0]["text"].as_str().unwrap_or_default().contains("the project has no file yet"), "{c:?}"); + let queue = call_json(&mut s, "execute_command", json!({"command": "renderQueue.list"})); + assert_eq!(queue["items"].as_array().map(Vec::len), Some(0), "nothing was queued: {queue}"); + // Naming an output inside a write root is how it is done. + call_json(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"comp": "A", "output": p("named.mp4")}})); + // A proxy named outside the roots is refused. Whether that reads as a write or a read is + // #1347's to classify, so this asserts the refusal and not its wording. The path is a sibling + // of the write root rather than `/elsewhere/…`, which is not absolute on Windows and would be + // refused for that instead. + let outside = base.join("elsewhere").join("proxy.mov").to_string_lossy().into_owned(); + let (c, err) = call(&mut s, "execute_command", json!({"command": "file.createProxy", "params": {"comp": "A", "path": outside}})); + assert!(err, "a proxy outside the roots should be refused: {c:?}"); + assert!(c[0]["text"].as_str().unwrap_or_default().contains("outside the automation roots"), "{c:?}"); + // Once the project is saved there, the default output (beside it) is queued as before. + call_json(&mut s, "save_project", json!({"path": p("proj.ecproj")})); + // A name that is not a path is an Output Module template, and the output it resolves to lands + // beside the saved project: inside the write root, so it is allowed (#1347 relies on this). + call_json(&mut s, "execute_command", json!({"command": "file.createProxy", "params": {"comp": "A", "path": "ProxyTemplate"}})); + call_json(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"comp": "A"}})); + let queue = call_json(&mut s, "execute_command", json!({"command": "renderQueue.list"})); + let out = queue["items"][1]["outputPath"].as_str().unwrap_or_default(); + 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(); +} + +/// `render_frame` writes the frame to the `path` it is given, and never over a file the open +/// project reads (#1743): footage, a proxy or the project file. The refusal comes before the +/// frame is rendered, and the file is untouched. +#[test] +fn render_frame_never_writes_over_a_file_the_project_reads() { + use effectcraft_engine::{ + color::Label, + project::{Footage, FootageKind, ItemKind}, + }; + use std::sync::Arc; + let dir = std::env::temp_dir().join(format!("effectcraft-mcp-overwrite-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (clip, project) = (dir.join("clip.png"), dir.join("Project.ecproj")); + std::fs::write(&clip, b"SOURCE FOOTAGE").unwrap(); + let mut session = Session::default(); + session.execute("comp.new", json!({"width": 16, "height": 16})).unwrap(); + session.execute("file.saveAs", json!({"path": project.to_string_lossy()})).unwrap(); + Arc::make_mut(&mut session.project).add_item( + "clip.png", + Label::Aqua, + None, + ItemKind::Footage(Footage { + path: clip.to_string_lossy().into_owned(), + kind: FootageKind::Still, + width: 16, + height: 16, + has_video: true, + ..Default::default() + }), + ); + let saved = std::fs::read(&project).unwrap(); + let mut s = McpServer::new(Backend::headless(session)); + for (file, what) in [(&clip, "footage"), (&project, "project")] { + let (c, err) = call(&mut s, "render_frame", json!({"path": file.to_string_lossy(), "inline": false})); + let text = c[0]["text"].as_str().unwrap(); + assert!(err && text.contains(&*file.to_string_lossy()) && text.contains(what), "{what}: {text}"); + } + assert_eq!(std::fs::read(&clip).unwrap(), b"SOURCE FOOTAGE", "the footage is as it was"); + assert_eq!(std::fs::read(&project).unwrap(), saved, "the project file is as it was"); + // Anywhere else it writes the PNG. + let free = dir.join("frame.png"); + let (c, err) = call(&mut s, "render_frame", json!({"path": free.to_string_lossy(), "inline": false})); + assert!(!err, "{c:?}"); + assert_eq!(&std::fs::read(&free).unwrap()[..8], b"\x89PNG\r\n\x1a\n"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The same through the desktop app: the app knows the project's files, so the tool asks it +/// (`file.checkOutput`) before it writes anything. +#[test] +fn bridged_render_frame_asks_the_app_before_it_writes() { + use effectcraft_engine::project::{Footage, FootageKind}; + struct Probe; + impl effectcraft_engine::Importer for Probe { + fn probe(&self, path: &str) -> Result { + Ok(Footage { path: path.to_string(), kind: FootageKind::Still, width: 4, height: 4, has_video: true, ..Default::default() }) + } + } + let dir = std::env::temp_dir().join(format!("effectcraft-mcp-bridge-overwrite-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let clip = dir.join("clip.png"); + std::fs::write(&clip, b"SOURCE FOOTAGE").unwrap(); + + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + std::thread::spawn(move || { + let (stream, _) = l.accept().unwrap(); + let mut out = stream.try_clone().unwrap(); + let mut session = Session { importer: Some(std::sync::Arc::new(Probe)), ..Default::default() }; + for line in BufReader::new(stream).lines() { + let msg: Value = serde_json::from_str(&line.unwrap()).unwrap(); + let p = &msg["params"]; + let mut reply = match msg["method"].as_str().unwrap() { + "engine.execute" => match session.execute(p["command"].as_str().unwrap(), p["params"].clone()) { + Ok(v) => json!({"ok": true, "result": v}), + Err(e) => json!({"ok": false, "error": e.to_string()}), + }, + "render.frame" if p["write"] == true && p["path"].is_string() => fake_render_frame(&session, p), + "render.frame" => json!({"ok": false, "error": "a frame save must be one app-owned write"}), + m => json!({"ok": false, "error": format!("unknown method `{m}`")}), + }; + reply["id"] = msg["id"].clone(); + writeln!(out, "{reply}").unwrap(); + } + }); + let mut s = McpServer::new(Backend::bridge(&port.to_string()).unwrap()); + call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"width": 16, "height": 16}})); + call_json(&mut s, "execute_command", json!({"command": "file.import", "params": {"paths": [clip.to_string_lossy()]}})); + + let (c, err) = call(&mut s, "render_frame", json!({"path": clip.to_string_lossy(), "inline": false})); + let text = c[0]["text"].as_str().unwrap(); + assert!(err && text.contains("clip.png") && text.contains("footage"), "{text}"); + assert_eq!(std::fs::read(&clip).unwrap(), b"SOURCE FOOTAGE", "the footage is as it was"); + // Anywhere else it writes the PNG. + let free = dir.join("frame.png"); + let (c, err) = call(&mut s, "render_frame", json!({"path": free.to_string_lossy(), "inline": false})); + assert!(!err, "{c:?}"); + assert_eq!(&std::fs::read(&free).unwrap()[..8], b"\x89PNG\r\n\x1a\n"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// #2131 F-11 ("Indirect reads"): a project opened or imported from inside a read root names its +/// footage, and those files are read when it renders. Footage outside the roots (an absolute +/// path, `../`, a symbolic link to a file or a folder out of the root) must not reach the agent: +/// its layer renders as missing footage, while footage inside the roots still renders. +#[test] +fn automation_roots_confine_the_footage_a_project_names() { + use crate::confine::Confinement; + let base = std::env::temp_dir().join(format!("ec-mcp-footage-{}", 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 red = image::RgbaImage::from_pixel(8, 8, image::Rgba([255, 0, 0, 255])); + red.save(outside.join("secret.png")).unwrap(); + red.save(read.join("own.png")).unwrap(); + let p = |d: &std::path::Path, f: &str| d.join(f).to_string_lossy().into_owned(); + // A project in the read root showing `footage`, saved unconfined. + let project = |name: &str, footage: &str| { + let mut s = McpServer::new(Backend::headless(effectcraft_host::session())); + call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "C", "width": 8, "height": 8}})); + let imported = call_json(&mut s, "execute_command", json!({"command": "file.import", "params": {"paths": [footage]}})); + let item = imported["items"][0].as_u64().or_else(|| imported["item"].as_u64()).unwrap_or_else(|| panic!("{imported}")); + call_json(&mut s, "execute_command", json!({"command": "layer.addItem", "params": {"comp": "C", "item": item}})); + let path = p(&read, name); + call_json(&mut s, "save_project", json!({"path": path})); + path + }; + // The middle pixel of comp C: red when the footage was read. + let shows_red = |s: &mut McpServer| { + let (c, err) = call(s, "render_frame", json!({"comp": "C"})); + assert!(!err, "{c:?}"); + let png = base64::decode(c[0]["data"].as_str().unwrap()).unwrap(); + let px = image::load_from_memory(&png).unwrap().to_rgba8().get_pixel(4, 4).0; + px[0] > 200 && px[3] > 200 + }; + let mut projects = vec![("absolute", project("absolute.ecproj", &p(&outside, "secret.png")))]; + #[cfg(unix)] + { + std::os::unix::fs::symlink(outside.join("secret.png"), read.join("link.png")).unwrap(); + std::os::unix::fs::symlink(&outside, read.join("dir")).unwrap(); + projects.push(("linked file", project("linked-file.ecproj", &p(&read, "link.png")))); + projects.push(("linked folder", project("linked-folder.ecproj", &p(&read.join("dir"), "secret.png")))); + } + // Relative to the project: `../elsewhere/secret.png`, written into a copy of a project. + let own = project("own.ecproj", &p(&read, "own.png")); + let text = std::fs::read_to_string(&own).unwrap(); + assert!(text.contains(&p(&read, "own.png")) || text.contains("own.png"), "{text}"); + let relative = text.replace(&p(&read, "own.png"), "../elsewhere/secret.png").replace("\"own.png\"", "\"../elsewhere/secret.png\""); + std::fs::write(read.join("relative.ecproj"), relative).unwrap(); + projects.push(("relative", p(&read, "relative.ecproj"))); + + let confined = || { + let mut s = McpServer::new(Backend::headless(effectcraft_host::session())); + s.backend().confine(Confinement::new(std::slice::from_ref(&read), std::slice::from_ref(&write)).unwrap()); + s + }; + // Footage inside the read root renders. + let mut s = confined(); + call_json(&mut s, "open_project", json!({"path": own})); + assert!(shows_red(&mut s), "footage inside the read root"); + for (how, path) in &projects { + // Unconfined, the project shows the outside file. + let mut s = McpServer::new(Backend::headless(effectcraft_host::session())); + call_json(&mut s, "open_project", json!({"path": path})); + assert!(shows_red(&mut s), "{how}: unconfined shows the footage"); + // Confined: opened, or imported into the open project. + let mut s = confined(); + call_json(&mut s, "open_project", json!({"path": path})); + assert!(!shows_red(&mut s), "{how}: opened, the footage outside the roots was read"); + let mut s = confined(); + call_json(&mut s, "execute_command", json!({"command": "file.importProject", "params": {"path": path}})); + assert!(!shows_red(&mut s), "{how}: imported, the footage outside the roots was read"); + } + let _ = std::fs::remove_dir_all(&base); +} + +/// A symbolic link inside the read root, read once while it pointed inside, is checked again +/// on every read: pointed outside afterwards, its footage must not come back once the frame +/// cache is emptied. +#[cfg(unix)] +#[test] +fn automation_roots_check_relinked_footage_again() { + use crate::confine::Confinement; + let base = std::env::temp_dir().join(format!("ec-mcp-relink-{}", 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(); + } + image::RgbaImage::from_pixel(8, 8, image::Rgba([0, 0, 255, 255])).save(read.join("own.png")).unwrap(); + image::RgbaImage::from_pixel(8, 8, image::Rgba([255, 0, 0, 255])).save(outside.join("secret.png")).unwrap(); + let link = read.join("link.png"); + std::os::unix::fs::symlink(read.join("own.png"), &link).unwrap(); + let link_path = link.to_string_lossy().into_owned(); + let project = read.join("relink.ecproj").to_string_lossy().into_owned(); + { + let mut s = McpServer::new(Backend::headless(effectcraft_host::session())); + call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "C", "width": 8, "height": 8}})); + let imported = call_json(&mut s, "execute_command", json!({"command": "file.import", "params": {"paths": [link_path]}})); + let item = imported["items"][0].as_u64().or_else(|| imported["item"].as_u64()).unwrap_or_else(|| panic!("{imported}")); + call_json(&mut s, "execute_command", json!({"command": "layer.addItem", "params": {"comp": "C", "item": item}})); + call_json(&mut s, "save_project", json!({"path": project})); + } + // The middle pixel of comp C. + let pixel = |s: &mut McpServer| { + let (c, err) = call(s, "render_frame", json!({"comp": "C"})); + assert!(!err, "{c:?}"); + let png = base64::decode(c[0]["data"].as_str().unwrap()).unwrap(); + image::load_from_memory(&png).unwrap().to_rgba8().get_pixel(4, 4).0 + }; + let mut s = McpServer::new(Backend::headless(effectcraft_host::session())); + s.backend().confine(Confinement::new(std::slice::from_ref(&read), std::slice::from_ref(&write)).unwrap()); + call_json(&mut s, "open_project", json!({"path": project})); + let px = pixel(&mut s); + assert!(px[2] > 200 && px[0] < 50, "the link pointing inside renders: {px:?}"); + std::fs::remove_file(&link).unwrap(); + std::os::unix::fs::symlink(outside.join("secret.png"), &link).unwrap(); + call_json(&mut s, "execute_command", json!({"command": "edit.purge", "params": {"what": "memory"}})); + let px = pixel(&mut s); + assert!(!(px[0] > 200 && px[3] > 200), "the link pointed outside afterwards was read: {px:?}"); + let _ = std::fs::remove_dir_all(&base); +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index a83e0d1ec..cb1a3cdab 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -175,16 +175,21 @@ fn params_of(v: Option<&Value>) -> Result { // ------------------------------------------------------------------ engine tools +/// `list_commands` tool: the engine command list, minus empty fields; with `compact`, only `id` and `label`. fn list_commands(b: &mut Backend, a: &Value) -> Result { - let schemas = get(a, "schemas").and_then(Value::as_bool).unwrap_or(false).then_some(json!(true)); + let compact = get(a, "compact").and_then(Value::as_bool).unwrap_or(false); + let schemas = (!compact && get(a, "schemas").and_then(Value::as_bool).unwrap_or(false)).then_some(json!(true)); let r = b.exec("command.list", obj(&[("filter", get(a, "filter")), ("enabledOnly", get(a, "enabled_only")), ("schemas", schemas.as_ref())]))?; - // Drop empty fields to keep the listing compact. let list: Vec = r .as_array() .cloned() .unwrap_or_default() .into_iter() .map(|mut c| { + if compact { + return json!({"id": c["id"], "label": c["label"]}); + } + // Drop empty fields. if let Some(o) = c.as_object_mut() { o.retain(|_, v| !(v.is_null() || v.as_array().is_some_and(Vec::is_empty) || v.as_str() == Some("{}"))); } @@ -205,8 +210,13 @@ fn describe_command(b: &mut Backend, a: &Value) -> Result { json_reply(b.exec("command.describe", json!({"command": id}))?) } +/// `list_effects` tool: the effect registry; with `compact`, only `id`, `name` and `category`. fn list_effects(b: &mut Backend, a: &Value) -> Result { - json_reply(b.exec("effect.list", obj(&[("filter", get(a, "filter"))]))?) + let mut r = b.exec("effect.list", obj(&[("filter", get(a, "filter"))]))?; + if get(a, "compact").and_then(Value::as_bool).unwrap_or(false) { + r = r.as_array().into_iter().flatten().map(|e| json!({"id": e["id"], "name": e["name"], "category": e["category"]})).collect(); + } + json_reply(r) } fn list_fonts(b: &mut Backend, a: &Value) -> Result { @@ -275,7 +285,7 @@ fn get_comp(b: &mut Backend, a: &Value) -> Result { 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) { // 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) { 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); } } @@ -393,13 +406,15 @@ fn add_keyframe(b: &mut Backend, a: &Value) -> Result { fn render_frame(b: &mut Backend, a: &Value) -> Result { let max_side = get(a, "max_side").and_then(Value::as_u64).unwrap_or(960) as u32; let transparent = get(a, "transparent").and_then(Value::as_bool).unwrap_or(false); - let f = b.render_with(get(a, "comp"), get(a, "time").and_then(Value::as_f64), max_side, transparent)?; + let path = get(a, "path").and_then(Value::as_str); + let comp = get(a, "comp"); + let time = get(a, "time").and_then(Value::as_f64); + let f = match path { + Some(path) => b.render_to_file(comp, time, max_side, transparent, path)?, + None => b.render_with(comp, time, max_side, transparent)?, + }; let mut info = json!({"comp": f.comp, "time": f.time, "width": f.width, "height": f.height}); - if let Some(p) = get(a, "path").and_then(Value::as_str) { - if let Some(c) = b.confinement() { - c.check(p, crate::confine::Access::Write)?; - } - std::fs::write(p, &f.png).map_err(|e| Error::Other(format!("cannot write {p}: {e}")))?; + if let Some(p) = path { info["path"] = json!(p); } if get(a, "inline").and_then(Value::as_bool) == Some(false) { @@ -542,14 +557,15 @@ fn control(b: &mut Backend, a: &Value) -> Result { static TOOLS: &[ToolDef] = &[ ToolDef { name: "list_commands", - description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`).", + description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`); `compact: true` lists only ids and labels.", bridge_only: false, schema: || { schema( json!({ "filter": {"type": "string", "description": "Only commands whose id or label contains this text (case-insensitive)."}, "enabled_only": {"type": "boolean", "description": "Only commands that can run right now."}, - "schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."} + "schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."}, + "compact": {"type": "boolean", "description": "Return only each command's `id` and `label` (overrides `schemas`)."} }), &[], ) @@ -615,7 +631,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 +648,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 +712,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 +935,15 @@ static TOOLS: &[ToolDef] = &[ }, ToolDef { name: "command_list", - description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`).", + description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`); `compact: true` lists only ids and labels.", bridge_only: false, schema: || { schema( json!({ "filter": {"type": "string", "description": "Only commands whose id or label contains this text (case-insensitive)."}, "enabled_only": {"type": "boolean", "description": "Only commands that can run right now."}, - "schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."} + "schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."}, + "compact": {"type": "boolean", "description": "Return only each command's `id` and `label` (overrides `schemas`)."} }), &[], ) diff --git a/crates/automation/tests/qa/compositing.rs b/crates/automation/tests/qa/compositing.rs index 52d5e4fb2..d5de8f687 100644 --- a/crates/automation/tests/qa/compositing.rs +++ b/crates/automation/tests/qa/compositing.rs @@ -7,6 +7,10 @@ use serde_json::{Value, json}; use crate::harness::{Img, Qa, coverage, luma, mean_diff}; +fn lum(i: &Img, x: u32, y: u32) -> f64 { + luma(i.get_pixel(x, y)) +} + fn px(img: &Img, x: u32, y: u32) -> [u8; 4] { img.get_pixel(x, y).0 } @@ -25,14 +29,13 @@ fn write_sequence(qa: &Qa) -> String { qa.path("seq/ball_000.png") } -#[test] -fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { - let mut qa = Qa::new("compositing"); - +/// Import the sequence (reinterpreted at 12 fps) and a PSD written by the app; returns the +/// sequence item and the PSD's composition. +fn import_footage(qa: &mut Qa) -> (Value, Value) { // Footage: the sequence, and a layered PSD written by the app itself (Save Frame As ▸ // Photoshop Layers) from a small comp. - let seq = write_sequence(&qa); - let imp = qa.exec("file.import", json!({"paths": [seq]})); + let seq = write_sequence(qa); + let imp = qa.exec("file.import", json!({"paths": [seq], "sequence": true})); let seq_item = imp["items"][0].clone(); let project = qa.tool("get_project", json!({})); let it = project["items"].as_array().unwrap().iter().find(|i| i["id"] == seq_item).unwrap().clone(); @@ -55,10 +58,14 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { let pc = qa.tool("get_comp", json!({"comp": psd_comp.clone()})); let names: Vec<&str> = pc["layers"].as_array().unwrap().iter().map(|l| l["name"].as_str().unwrap()).collect(); assert_eq!(names, ["Bar", "Gray"], "the PSD's layers, top first: {pc}"); + (seq_item, psd_comp) +} +/// The main comp: the sequence plays, then a Screen-blended PSD; returns the Normal-mode frame. +fn main_comp_and_blend(qa: &mut Qa, seq_item: &Value, psd_comp: &Value) -> Img { // The main comp. - let main = qa.exec("comp.new", json!({"name": "Main", "width": 160, "height": 90, "frameRate": 12, "duration": 1}))["comp"].clone(); - qa.exec("layer.addItem", json!({"item": seq_item})); + qa.exec("comp.new", json!({"name": "Main", "width": 160, "height": 90, "frameRate": 12, "duration": 1})); + qa.exec("layer.addItem", json!({"item": seq_item.clone()})); let a = qa.frame(None, 0.0); let b = qa.frame(None, 0.5); assert!(mean_diff(&a, &b) > 1.0, "the sequence plays"); @@ -75,7 +82,10 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { for c in 0..3 { assert!(s[c] > n[c] && s[c] > [32, 64, 96][c], "Screen brightens channel {c}: {s:?}"); } + normal +} +fn track_matte(qa: &mut Qa) { // Track matte: a magenta solid seen only through text. qa.exec("layer.newText", json!({"text": "MATTE", "name": "Matte", "size": 40, "fill": "#ffffff"})); qa.exec("layer.newSolid", json!({"name": "Fill", "color": "#ff00ff", "width": 160, "height": 90})); @@ -91,7 +101,9 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { let fill = info["layers"].as_array().unwrap().iter().find(|l| l["name"] == "Fill").unwrap().clone(); assert_eq!(fill["trackMatte"]["kind"], "Alpha Matte"); qa.exec("layer.setSwitch", json!({"layers": ["Fill", "Matte"], "switch": "video", "value": false})); +} +fn adjustment_layer(qa: &mut Qa, normal: &Img) { // Adjustment layer: Lumetri exposure, inside a feathered, expanded elliptical mask; then Glow. qa.exec("layer.newAdjustment", json!({"name": "Grade"})); let fx = qa.exec("effect.apply", json!({"effect": "Lumetri Color", "layers": ["Grade"]})); @@ -103,7 +115,6 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { let mk = qa.exec("layer.addMask", json!({"layer": "Grade", "shape": "ellipse", "rect": [40, 20, 80, 50]})); assert!(mk["mask"].is_u64(), "{mk}"); let hard = qa.frame(None, 0.5); - let lum = |i: &Img, x, y| luma(i.get_pixel(x, y)); assert!(lum(&hard, 80, 45) > lum(&before, 80, 45) - 1.0 && lum(&hard, 5, 5) < lum(&before, 5, 5) - 10.0, "the mask limits the grade to the ellipse"); qa.exec("layer.mask.set", json!({"layer": "Grade", "field": "expansion", "value": 8})); let grown = qa.frame(None, 0.5); @@ -116,7 +127,7 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { } n }; - assert!(bright(&grown, &normal) > bright(&hard, &normal) + 100, "expansion grows the graded area"); + assert!(bright(&grown, normal) > bright(&hard, normal) + 100, "expansion grows the graded area"); qa.exec("layer.mask.set", json!({"layer": "Grade", "field": "feather", "value": 20})); let soft = qa.frame(None, 0.5); // At the ellipse's edge (x = 40 − 8): feathered is in between none and full. @@ -125,7 +136,10 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { qa.exec("effect.apply", json!({"effect": "Glow", "layers": ["Grade"]})); let glow = qa.frame(None, 0.5); assert!(mean_diff(&soft, &glow) > 0.5, "Glow changes the frame"); +} +/// The 3D stage with depth of field and a light; returns the stage comp. +fn stage_3d(qa: &mut Qa) -> Value { // 3D stage: a near and a far card, a camera with depth of field, a light. let stage = qa.exec("comp.new", json!({"name": "Stage", "width": 160, "height": 90, "frameRate": 12, "duration": 1}))["comp"].clone(); qa.exec("layer.newSolid", json!({"name": "Near", "color": "#ff4040", "width": 40, "height": 40})); @@ -155,7 +169,10 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { let advanced = qa.frame(None, 0.0); assert!(mean_diff(&classic, &advanced) > 0.2, "Advanced 3D renders the stage its own way"); assert!(lum(&advanced, 50, 45) > 60.0, "Advanced 3D still lights the card"); + stage +} +fn alpha_renders(qa: &mut Qa, stage: &Value) { // Renders with alpha: the stage has no background layer, so most of it is transparent. let mov = qa.path("stage.mov"); let webm = qa.path("stage.webm"); @@ -165,7 +182,7 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { assert!(done["items"].as_array().unwrap().iter().all(|i| i["status"] == "Done"), "{done}"); let reference = qa.tool("render_frame", json!({"comp": stage.clone(), "time": 0, "transparent": true, "inline": false})); assert_eq!(reference["width"], 160); - let refa = alpha_frame(&mut qa, stage.clone()); + let refa = alpha_frame(qa, stage.clone()); assert!(px(&refa, 2, 2)[3] < 40 && px(&refa, 50, 45)[3] == 255, "render_frame transparent keeps alpha: {:?} {:?}", px(&refa, 2, 2), px(&refa, 50, 45)); // ProRes 4444 carries alpha in the frame; WebM VP9 as a second VP9 stream in BlockAdditions // (AlphaMode 1), which FilmCraft's Matroska reader decodes into the frame's alpha plane. @@ -174,14 +191,24 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { assert_eq!(r["errors"], json!([])); assert_eq!(r["unlabeledAlpha"], r["items"], "{path}: the importer sees an alpha channel"); let c = qa.exec("file.newCompFromSelection", json!({}))["comps"][0].clone(); - let f = alpha_frame(&mut qa, c.clone()); + let f = alpha_frame(qa, c.clone()); // The opaque card decodes to the stage's colours (lossy codecs, so loosely). let (got, want) = (px(&f, 50, 45), px(&refa, 50, 45)); assert!((0..3).all(|c| got[c].abs_diff(want[c]) < 12), "{path} decodes to the stage: {got:?} vs {want:?}"); assert!(mean_diff(&qa.frame(Some(c.clone()), 0.0), &qa.frame(Some(stage.clone()), 0.0)) < 8.0, "{path} composites like the stage"); assert!(px(&f, 2, 2)[3] < 40 && px(&f, 50, 45)[3] > 250, "{path}: transparent corners, opaque card: {:?} {:?}", px(&f, 2, 2), px(&f, 50, 45)); } - let _ = main; +} + +#[test] +fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { + let mut qa = Qa::new("compositing"); + let (seq_item, psd_comp) = import_footage(&mut qa); + let normal = main_comp_and_blend(&mut qa, &seq_item, &psd_comp); + track_matte(&mut qa); + adjustment_layer(&mut qa, &normal); + let stage = stage_3d(&mut qa); + alpha_renders(&mut qa, &stage); } fn alpha_frame(qa: &mut Qa, comp: Value) -> Img { diff --git a/crates/automation/tests/qa/tracking.rs b/crates/automation/tests/qa/tracking.rs index 837872658..ee6bbb2e9 100644 --- a/crates/automation/tests/qa/tracking.rs +++ b/crates/automation/tests/qa/tracking.rs @@ -52,7 +52,7 @@ fn shake_offset(f: u32) -> (i64, i64) { /// Import a sequence at 12 fps and make a comp of it; returns (comp, layer). fn plate_comp(qa: &mut Qa, first: &str, name: &str) -> (Value, Value) { - let item = qa.exec("file.import", json!({"paths": [first]}))["items"][0].clone(); + let item = qa.exec("file.import", json!({"paths": [first], "sequence": true}))["items"][0].clone(); qa.exec("file.interpretFootage", json!({"items": [item.clone()], "frameRate": 12})); let comp = qa.exec("comp.new", json!({"name": name, "width": 160, "height": 90, "frameRate": 12, "duration": 1}))["comp"].clone(); let layer = qa.exec("layer.addItem", json!({"item": item}))["layer"].clone(); diff --git a/crates/av1enc/README.md b/crates/av1enc/README.md index c922c95eb..dcc343693 100644 --- a/crates/av1enc/README.md +++ b/crates/av1enc/README.md @@ -48,8 +48,10 @@ reconstruction, so prediction never drifts. reconstruction loop; full coefficient coding (all_zero, tx type, eob_pt, eob_extra, coeff_base_eob, coeff_base, coeff_br, dc_sign, Golomb) with the exact contexts. - **Rate control**: `ConstantQ(base_q_idx)`; `Bitrate { kbps }` adapts `base_q_idx` per frame - towards the average target (key frames get a lower quantizer and a larger share; the first - key frame is searched in a few passes). + towards the average target (key frames get a lower quantizer and a larger share of the budget of + their group of `keyint` frames, the inter frames the rest; the first key frame is searched in a + few passes, a later key frame that misses its budget by more than 19 % is coded again; the + deviation from the budget is paid back over 16 frames). ## Limits diff --git a/crates/av1enc/src/cdf.rs b/crates/av1enc/src/cdf.rs index 0466a1592..777164d1a 100644 --- a/crates/av1enc/src/cdf.rs +++ b/crates/av1enc/src/cdf.rs @@ -2,7 +2,15 @@ //! init_coeff_cdfs that its syntax uses). Layout as in filmcraft-av1: each innermost array //! holds N cumulative values (the last is 32768) plus the adaptation counter. -use crate::tables::*; +use crate::tables::{ + DEFAULT_ANGLE_DELTA_CDF, DEFAULT_COEFF_BASE_CDF, DEFAULT_COEFF_BASE_EOB_CDF, DEFAULT_COEFF_BR_CDF, DEFAULT_DC_SIGN_CDF, DEFAULT_DRL_MODE_CDF, + DEFAULT_EOB_EXTRA_CDF, DEFAULT_EOB_PT_16_CDF, DEFAULT_EOB_PT_32_CDF, DEFAULT_EOB_PT_64_CDF, DEFAULT_EOB_PT_128_CDF, DEFAULT_EOB_PT_256_CDF, + DEFAULT_EOB_PT_512_CDF, DEFAULT_EOB_PT_1024_CDF, DEFAULT_INTER_TX_TYPE_SET3_CDF, DEFAULT_INTRA_TX_TYPE_SET2_CDF, DEFAULT_IS_INTER_CDF, DEFAULT_MV_BIT_CDF, + DEFAULT_MV_CLASS_CDF, DEFAULT_MV_CLASS0_BIT_CDF, DEFAULT_MV_CLASS0_FR_CDF, DEFAULT_MV_FR_CDF, DEFAULT_MV_JOINT_CDF, DEFAULT_MV_SIGN_CDF, + DEFAULT_NEW_MV_CDF, DEFAULT_PARTITION_W8_CDF, DEFAULT_PARTITION_W16_CDF, DEFAULT_PARTITION_W32_CDF, DEFAULT_PARTITION_W64_CDF, DEFAULT_REF_MV_CDF, + DEFAULT_SINGLE_REF_CDF, DEFAULT_SKIP_CDF, DEFAULT_TXB_SKIP_CDF, DEFAULT_UV_MODE_CFL_ALLOWED_CDF, DEFAULT_UV_MODE_CFL_NOT_ALLOWED_CDF, DEFAULT_Y_MODE_CDF, + DEFAULT_ZERO_MV_CDF, +}; #[derive(Clone)] pub(crate) struct Cdfs { diff --git a/crates/av1enc/src/decide.rs b/crates/av1enc/src/decide.rs index 494e914c0..d8bc1f825 100644 --- a/crates/av1enc/src/decide.rs +++ b/crates/av1enc/src/decide.rs @@ -3,8 +3,12 @@ //! processes, so the reconstruction built here is exactly what a decoder produces; the syntax //! is written afterwards by `writer.rs`. -use crate::pred::*; -use crate::tables::*; +use crate::pred::{INTRA_MODES_USED, InterBlock, IntraEdge, Plane, edges, half_planes, predict_inter, predict_intra}; +use crate::tables::{ + AC_QLOOKUP, BLOCK_8X8, BLOCK_64X64, DC_PRED, DC_QLOOKUP, DCT_DCT, DEFAULT_SCAN_4X4, DEFAULT_SCAN_8X8, DEFAULT_SCAN_16X16, DEFAULT_SCAN_32X32, MODE_TO_TXFM, + NEWMV, NUM_4X4_BLOCKS_WIDE, PARTITION_SPLIT, PARTITION_SUBSIZE, TX_4X4, TX_8X8, TX_16X16, TX_32X32, TX_64X64, TX_SET_DCTONLY, TX_SET_INTER_3, + TX_SET_INTRA_2, TX_SIZE_SQR_UP, TX_TYPE_IN_SET_INTER, TX_TYPE_IN_SET_INTRA, +}; use crate::transform::{forward_2d, inverse_2d}; /// Decisions for one coded block. @@ -100,6 +104,17 @@ pub(crate) fn intra_uv_tx_type(uv_mode: usize, uv_tx: usize) -> usize { if tx_in_set(tx_set(uv_tx, false), false, t) { t } else { DCT_DCT } } +/// Geometry and coding mode of one square transform block. +#[derive(Clone, Copy)] +struct TransformBlock { + plane: usize, + x: usize, + y: usize, + log2: u32, + tx_type: usize, + intra: bool, +} + struct TxResult { levels: Vec, nonzero: bool, @@ -107,6 +122,38 @@ struct TxResult { bits: f64, } +/// The output and scratch buffers of one reconstruction, each `n * n` entries long. +struct ReconBuffers<'a> { + deq: &'a mut [i32], + resid: &'a mut [i32], + recon: &'a mut [u16], +} + +/// Outcome of trying one transform block; the levels and the reconstruction stay in `TxScratch`. +#[derive(Clone, Copy)] +struct TxEval { + nonzero: bool, + dist: f64, + bits: f64, +} + +/// Buffers of the transform path, sized for the largest coded transform block (32x32). Only the +/// first `n * n` entries are used; they are reused across candidates instead of being allocated. +struct TxScratch { + res: [i32; 1024], + coef: [f32; 1024], + levels: [i32; 1024], + deq: [i32; 1024], + resid: [i32; 1024], + recon: [u16; 1024], +} + +impl Default for TxScratch { + fn default() -> Self { + TxScratch { res: [0; 1024], coef: [0.0; 1024], levels: [0; 1024], deq: [0; 1024], resid: [0; 1024], recon: [0; 1024] } + } +} + /// Saved encoder state of a block area (reconstruction, BlockDecoded flags). struct Snapshot { planes: [Vec; 3], @@ -127,13 +174,20 @@ pub(crate) struct Decider<'a> { ac_q: f32, dc_qi: i64, ac_qi: i64, + /// Smallest magnitude that quantises to a non-zero level, `[dc, ac][intra]`. + dead_zone: [[f32; 2]; 2], lambda: f64, lambda_sad: f64, /// Half-sample interpolations of the reference luma (visible area): (8, 0), (0, 8), (8, 8). half: [Vec; 3], + /// Transform buffers, taken out while a block is being tried (see `code_tx`). + scratch: Option>, } const MODE_BITS_INTRA: f64 = 3.0; +/// Dead-zone quantiser rounding offsets (below one half) for inter and intra blocks. +const INTER_RND: f32 = 0.30; +const INTRA_RND: f32 = 0.38; impl<'a> Decider<'a> { pub fn new(g: &'a FrameGeom, bd: u32, qidx: u8, intra_only: bool, src: &'a [Plane; 3], refr: Option<&'a RefFrame>) -> Self { @@ -160,12 +214,14 @@ impl<'a> Decider<'a> { ac_q: ac_qi as f32, dc_qi, ac_qi, + dead_zone: [dc_qi, ac_qi].map(|q| [INTER_RND, INTRA_RND].map(|rnd| zero_threshold(q as f32, rnd))), lambda, lambda_sad: lambda.sqrt(), half: match refr { - Some(rf) => [(8, 0), (0, 8), (8, 8)].map(|(fx, fy)| subpel_plane(&rf.planes[0], g.width, g.height, fx, fy, bd)), + Some(rf) => half_planes(&rf.planes[0], g.width, g.height, bd), None => Default::default(), }, + scratch: None, } } @@ -324,8 +380,10 @@ impl<'a> Decider<'a> { let log2 = (n4 * 4).trailing_zeros(); let (sbr, sbc) = ((r & 15) as isize, (c & 15) as isize); let bd = self.bd; - let mut pred = vec![0u16; 32 * 32]; - let mut best_pred = vec![0u16; 32 * 32]; + let mut pred = [0u16; 32 * 32]; + let mut best_pred = [0u16; 32 * 32]; + let mut best_levels = [0i32; 32 * 32]; + let mut best_recon = [0u16; 32 * 32]; let n = 1usize << log2; // luma let e = IntraEdge { @@ -343,26 +401,26 @@ impl<'a> Decider<'a> { let ed = edges(&self.rec[0], &e); // modes ranked by SATD, then a rate-distortion choice of mode and transform type among // the best three - let mut ranked: Vec<(f64, usize)> = INTRA_MODES_USED - .iter() - .map(|&mode| { - predict_intra(&ed, &e, mode, &mut pred); - let sad = self.satd(0, c * 4, r * 4, n, &pred[..n * n]) as f64; - (sad + self.lambda_sad * if mode == DC_PRED { 1.0 } else { MODE_BITS_INTRA }, mode) - }) - .collect(); + let mut ranked: [(f64, usize); INTRA_MODES_USED.len()] = INTRA_MODES_USED.map(|mode| { + predict_intra(&ed, &e, mode, &mut pred); + let sad = self.satd(0, c * 4, r * 4, n, &pred[..n * n]) as f64; + (sad + self.lambda_sad * if mode == DC_PRED { 1.0 } else { MODE_BITS_INTRA }, mode) + }); ranked.sort_by(|a, b| a.0.total_cmp(&b.0)); let tx_y = tx_size_for(log2); - let mut best: Option<(f64, usize, usize, TxResult, Option>)> = None; + let mut best: Option<(f64, usize, usize, TxEval, bool)> = None; + let mut sc = self.scratch.take().unwrap_or_default(); for &(_, mode) in ranked.iter().take(3) { predict_intra(&ed, &e, mode, &mut pred); - let mut types = vec![DCT_DCT]; + let mut types = [DCT_DCT; 2]; + let mut n_types = 1; let alt = MODE_TO_TXFM[mode] as usize; if alt != DCT_DCT && tx_in_set(tx_set(tx_y, false), false, alt) { - types.push(alt); + types[1] = alt; + n_types = 2; } - for &t in &types { - let (res, recon) = self.try_tx(0, c * 4, r * 4, log2, t, &pred[..n * n], true); + for &t in &types[..n_types] { + let (res, has_recon) = self.try_tx(&mut sc, TransformBlock { plane: 0, x: c * 4, y: r * 4, log2, tx_type: t, intra: true }, &pred[..n * n]); let type_bits = if !res.nonzero || tx_set(tx_y, false) == TX_SET_DCTONLY { 0.0 } else if t == DCT_DCT { @@ -374,18 +432,24 @@ impl<'a> Decider<'a> { let cost = res.dist + self.lambda * (res.bits + type_bits + mode_bits); if best.as_ref().is_none_or(|b| cost < b.0) { best_pred[..n * n].copy_from_slice(&pred[..n * n]); - best = Some((cost, mode, t, res, recon)); + best_levels[..n * n].copy_from_slice(&sc.levels[..n * n]); + if has_recon { + best_recon[..n * n].copy_from_slice(&sc.recon[..n * n]); + } + best = Some((cost, mode, t, res, has_recon)); } } } + self.scratch = Some(sc); // Every ranked mode tries DCT_DCT, so `best` is always set; without a candidate the block // can't be coded intra (an infinite cost keeps it from being chosen). - let Some((_, y_mode, tx_type_y, ty, recon)) = best else { + let Some((_, y_mode, tx_type_y, ty, has_recon)) = best else { let leaf = Leaf { bsize, is_inter: false, y_mode: DC_PRED, uv_mode: DC_PRED, tx_type_y: DCT_DCT, mv: [0, 0], skip: true, coefs: Default::default() }; return (leaf, f64::INFINITY); }; - self.put(0, c * 4, r * 4, n, recon.as_deref().unwrap_or(&best_pred[..n * n])); + self.put(0, c * 4, r * 4, n, if has_recon { &best_recon[..n * n] } else { &best_pred[..n * n] }); + let ty = TxResult { levels: best_levels[..n * n].to_vec(), nonzero: ty.nonzero, dist: ty.dist, bits: ty.bits }; let tx_type_y = if ty.nonzero { tx_type_y } else { DCT_DCT }; self.mark_decoded(0, r, c, n4); // chroma (one mode for both planes) @@ -422,7 +486,7 @@ impl<'a> Decider<'a> { let uv_type = intra_uv_tx_type(uv_mode, uv_tx); let mut code_uv = |i: usize| { predict_intra(&ced[i], &ce[i], uv_mode, &mut pred); - let t = self.code_tx(1 + i, c * 2, r * 2, clog2, uv_type, &pred[..cn * cn], true); + let t = self.code_tx(TransformBlock { plane: 1 + i, x: c * 2, y: r * 2, log2: clog2, tx_type: uv_type, intra: true }, &pred[..cn * cn]); self.mark_decoded(1 + i, r, c, n4); t }; @@ -450,13 +514,17 @@ impl<'a> Decider<'a> { let (vw, vh) = self.g.visible(p); let pn = n >> s; let (x, y) = ((c * 4) >> s, (r * 4) >> s); - predict_inter(&refr.planes[p], vw as i32 - 1, vh as i32 - 1, x, y, pn, pn, mv, s, self.bd, &mut pred[..pn * pn]); + predict_inter( + &refr.planes[p], + &InterBlock { last_x: vw as i32 - 1, last_y: vh as i32 - 1, x, y, width: pn, height: pn, mv, subsampling: s, bit_depth: self.bd }, + &mut pred[..pn * pn], + ); if bsize == BLOCK_64X64 { // coded as a skip block: the prediction is the reconstruction dist += self.sse(p, x, y, pn, &pred[..pn * pn]); self.put(p, x, y, pn, &pred[..pn * pn]); } else { - let t = self.code_tx(p, x, y, log2 - s as u32, DCT_DCT, &pred[..pn * pn], false); + let t = self.code_tx(TransformBlock { plane: p, x, y, log2: log2 - s as u32, tx_type: DCT_DCT, intra: false }, &pred[..pn * pn]); dist += t.dist; tx.push(t); } @@ -515,8 +583,9 @@ impl<'a> Decider<'a> { let (dy, dx) = (m[0] >> 3, m[1] >> 3); dx >= lim_x.0 && dx <= lim_x.1 && dy >= lim_y.0 && dy <= lim_y.1 }; + let lg = log2_table(); let mv_bits = |m: [i32; 2]| -> f64 { - let comp = |d: i32| if d == 0 { 0.5 } else { 2.0 + 2.0 * ((d.abs() / 8) as f64 + 1.0).log2() }; + let comp = |d: i32| if d == 0 { 0.5 } else { 2.0 + 2.0 * log2_of(lg, (d.abs() / 8) as usize + 1) }; comp(m[0] - pred_mv[0]) + comp(m[1] - pred_mv[1]) }; let cost = @@ -771,36 +840,48 @@ impl<'a> Decider<'a> { /// Transform, quantise and reconstruct one square transform block of `plane` at (x, y) /// over the prediction `pred`; the reconstruction is written to `rec`. - fn code_tx(&mut self, p: usize, x: usize, y: usize, log2: u32, tx_type: usize, pred: &[u16], intra: bool) -> TxResult { - let (res, recon) = self.try_tx(p, x, y, log2, tx_type, pred, intra); + fn code_tx(&mut self, block: TransformBlock, pred: &[u16]) -> TxResult { + let TransformBlock { plane: p, x, y, log2, .. } = block; + let mut sc = self.scratch.take().unwrap_or_default(); + let (res, has_recon) = self.try_tx(&mut sc, block, pred); let n = 1usize << log2; - self.put(p, x, y, n, recon.as_deref().unwrap_or(pred)); - res + self.put(p, x, y, n, if has_recon { &sc.recon[..n * n] } else { pred }); + let levels = sc.levels[..n * n].to_vec(); + self.scratch = Some(sc); + TxResult { levels, nonzero: res.nonzero, dist: res.dist, bits: res.bits } } - /// Transform and quantise one block; returns the result and its reconstruction (None: the - /// prediction) without writing it. - fn try_tx(&self, p: usize, x: usize, y: usize, log2: u32, tx_type: usize, pred: &[u16], intra: bool) -> (TxResult, Option>) { + /// Transform and quantise one block without writing it. The levels are left in `sc.levels`; + /// the flag tells whether `sc.recon` holds the reconstruction (false: it is the prediction). + fn try_tx(&self, sc: &mut TxScratch, block: TransformBlock, pred: &[u16]) -> (TxEval, bool) { + let TransformBlock { plane: p, x, y, log2, tx_type, intra } = block; let n = 1usize << log2; + let nn = n * n; let tx_sz = tx_size_for(log2); let bd = self.bd; let src = &self.src[p]; - let mut res = vec![0i32; n * n]; + let TxScratch { res, coef, levels, deq, resid, recon } = sc; + let (res, coef, levels) = (&mut res[..nn], &mut coef[..nn], &mut levels[..nn]); for i in 0..n { let row = &src.row(y + i)[x..x + n]; for j in 0..n { res[i * n + j] = row[j] as i32 - pred[i * n + j] as i32; } } - let mut coef = vec![0f32; n * n]; - forward_2d(&res, &mut coef, n, tx_type); + forward_2d(res, coef, n, tx_type); // dead-zone quantiser: rounding offset below one half - let rnd = if intra { 0.38f32 } else { 0.30f32 }; - let mut levels = vec![0i32; n * n]; + let rnd = if intra { INTRA_RND } else { INTER_RND }; + let [dc_thr, ac_thr] = [self.dead_zone[0][intra as usize], self.dead_zone[1][intra as usize]]; + levels.fill(0); let mut nonzero = false; for (k, (&cf, l)) in coef.iter().zip(levels.iter_mut()).enumerate() { - let q = if k == 0 { self.dc_q } else { self.ac_q }; - let a = (cf.abs() / q + rnd).floor() as i32; + let (q, thr) = if k == 0 { (self.dc_q, dc_thr) } else { (self.ac_q, ac_thr) }; + // dead zone: below the exact threshold the level is zero, no division needed + if cf.abs() < thr { + continue; + } + // the operand is >= 0, so truncation equals floor + let a = (cf.abs() / q + rnd) as i32; if a != 0 { *l = if cf < 0.0 { -a.min(1 << 16) } else { a.min(1 << 16) }; nonzero = true; @@ -808,23 +889,26 @@ impl<'a> Decider<'a> { } let zero_dist = self.sse(p, x, y, n, pred); if !nonzero { - return (TxResult { levels, nonzero, dist: zero_dist, bits: 1.0 }, None); + return (TxEval { nonzero, dist: zero_dist, bits: 1.0 }, false); } - let recon = self.reconstruct(&levels, tx_sz, tx_type, pred, n, bd); - let dist = self.sse(p, x, y, n, &recon); - let bits = estimate_bits(&levels, tx_sz); + let recon = &mut recon[..nn]; + self.reconstruct(levels, ReconBuffers { deq: &mut deq[..nn], resid: &mut resid[..nn], recon }, tx_sz, tx_type, pred, bd); + let dist = self.sse(p, x, y, n, recon); + let bits = estimate_bits(levels, tx_sz); if dist + self.lambda * bits >= zero_dist + self.lambda { - levels.iter_mut().for_each(|l| *l = 0); - return (TxResult { levels, nonzero: false, dist: zero_dist, bits: 1.0 }, None); + levels.fill(0); + return (TxEval { nonzero: false, dist: zero_dist, bits: 1.0 }, false); } - (TxResult { levels, nonzero: true, dist, bits }, Some(recon)) + (TxEval { nonzero: true, dist, bits }, true) } - /// Dequantisation (7.12.3) and the inverse transform, added to the prediction. - fn reconstruct(&self, levels: &[i32], tx_sz: usize, tx_type: usize, pred: &[u16], n: usize, bd: u32) -> Vec { + /// Dequantisation (7.12.3) and the inverse transform, added to the prediction; the result is + /// written to `recon`. `deq` and `resid` are scratch buffers of `n * n` entries. + fn reconstruct(&self, levels: &[i32], bufs: ReconBuffers, tx_sz: usize, tx_type: usize, pred: &[u16], bd: u32) { + let ReconBuffers { deq, resid, recon } = bufs; let denom: i64 = if tx_sz == TX_32X32 { 2 } else { 1 }; let lim = 1i64 << (7 + bd); - let mut deq = vec![0i32; n * n]; + deq.fill(0); for (k, (&l, d)) in levels.iter().zip(deq.iter_mut()).enumerate() { if l == 0 { continue; @@ -834,13 +918,41 @@ impl<'a> Decider<'a> { let mag = (dq.abs() & 0xFF_FFFF) / denom; *d = (if dq < 0 { -mag } else { mag }).clamp(-lim, lim - 1) as i32; } - let mut resid = vec![0i32; n * n]; - inverse_2d(&deq, &mut resid, tx_sz, tx_type, bd); + inverse_2d(deq, resid, tx_sz, tx_type, bd); let max = (1i32 << bd) - 1; - pred.iter().zip(&resid).map(|(&p, &r)| (p as i32 + r).clamp(0, max) as u16).collect() + for ((o, &p), &r) in recon.iter_mut().zip(pred).zip(resid.iter()) { + *o = (p as i32 + r).clamp(0, max) as u16; + } } } +/// Smallest `x >= 0` with `(x / q + rnd) as i32 >= 1` in f32 arithmetic. The expression is +/// monotone in `x`, so a binary search over the bit patterns finds the exact boundary. +fn zero_threshold(q: f32, rnd: f32) -> f32 { + let (mut lo, mut hi) = (0u32, f32::MAX.to_bits()); + while lo < hi { + let mid = lo + (hi - lo) / 2; + if (f32::from_bits(mid) / q + rnd) as i32 >= 1 { + hi = mid; + } else { + lo = mid + 1; + } + } + f32::from_bits(lo) +} + +const LOG2_TABLE_LEN: usize = 1025; + +/// `log2(i)` for `i < LOG2_TABLE_LEN`, computed with the same `f64::log2` call as the direct form. +fn log2_table() -> &'static [f64; LOG2_TABLE_LEN] { + static TABLE: std::sync::OnceLock<[f64; LOG2_TABLE_LEN]> = std::sync::OnceLock::new(); + TABLE.get_or_init(|| std::array::from_fn(|i| (i as f64).log2())) +} + +fn log2_of(table: &[f64; LOG2_TABLE_LEN], v: usize) -> f64 { + if v < LOG2_TABLE_LEN { table[v] } else { (v as f64).log2() } +} + /// Unnormalised Walsh-Hadamard transform of 4 or 8 values, in place. fn hadamard(v: &mut [i32]) { let n = v.len(); @@ -869,13 +981,14 @@ fn estimate_bits(levels: &[i32], tx_sz: usize) -> f64 { if eob == 0 { return 1.0; } - let mut bits = 3.0 + (eob as f64).log2(); + let lg = log2_table(); + let mut bits = 3.0 + log2_of(lg, eob); for &pos in &scan[..eob] { let a = levels[pos as usize].unsigned_abs(); bits += match a { 0 => 0.7, 1 => 2.5, - _ => 3.5 + 1.6 * (a as f64).log2(), + _ => 3.5 + 1.6 * log2_of(lg, a as usize), }; } bits @@ -889,3 +1002,64 @@ pub(crate) fn scan_for(tx_sz: usize) -> &'static [u16] { _ => &DEFAULT_SCAN_32X32, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::tables::{AC_QLOOKUP, DC_QLOOKUP}; + + /// The quantiser as it was before the dead-zone threshold and the truncation. + fn quant_ref(cf: f32, q: f32, rnd: f32) -> i32 { + (cf.abs() / q + rnd).floor() as i32 + } + + fn quant_new(cf: f32, q: f32, rnd: f32, thr: f32) -> i32 { + if cf.abs() < thr { + return 0; + } + (cf.abs() / q + rnd) as i32 + } + + #[test] + fn dead_zone_threshold_is_exact() { + let mut state = 0x1234_5678_9abc_def0u64; + let mut next = move || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state + }; + for table in AC_QLOOKUP.iter().chain(DC_QLOOKUP.iter()) { + for &qi in table.iter() { + let q = qi as f32; + for rnd in [INTER_RND, INTRA_RND] { + let thr = zero_threshold(q, rnd); + // the boundary itself and its neighbours + let b = thr.to_bits(); + for bits in b.saturating_sub(3)..=b + 3 { + let cf = f32::from_bits(bits); + assert_eq!(quant_ref(cf, q, rnd), quant_new(cf, q, rnd, thr), "q {q} rnd {rnd} cf {cf}"); + assert_eq!(quant_ref(-cf, q, rnd), quant_new(-cf, q, rnd, thr)); + } + for _ in 0..200 { + let cf = f32::from_bits((next() >> 32) as u32 & 0x7fff_ffff); + if cf.is_nan() { + continue; + } + assert_eq!(quant_ref(cf, q, rnd), quant_new(cf, q, rnd, thr)); + let cf = (next() % 100_000) as f32 / 100.0; + assert_eq!(quant_ref(cf, q, rnd), quant_new(cf, q, rnd, thr)); + } + } + } + } + } + + #[test] + fn log2_table_matches_direct_log2() { + let t = log2_table(); + for v in 1..3000usize { + assert_eq!(log2_of(t, v).to_bits(), (v as f64).log2().to_bits(), "{v}"); + } + } +} diff --git a/crates/av1enc/src/lib.rs b/crates/av1enc/src/lib.rs index 6b1edfbe8..b86db0fa2 100644 --- a/crates/av1enc/src/lib.rs +++ b/crates/av1enc/src/lib.rs @@ -24,6 +24,7 @@ use bitw::{BitWriter, obu}; use decide::{Decider, FrameGeom, RefFrame}; use loopfilter::{LfInfo, loop_filter}; use pred::Plane; +use tables::AC_QLOOKUP; use writer::TileWriter; /// Rate control. @@ -136,10 +137,118 @@ struct RateState { frame_bits: f64, /// Quantizer index for inter frames (continuous). q: f64, + /// Key frame quantizer index minus the inter frame one. + key_off: f64, + /// Quantizer index steps per halving of the frame size, measured from consecutive inter + /// frames (the size/quantizer curve is much steeper than the default on noisy content). + slope: f64, + /// Quantizer index and size (in bits) of the previous inter frame. + prev_inter: Option<(f64, f64)>, /// Accumulated (spent - budget) bits. debt: f64, } +/// Quantizer index steps per halving of the frame size before any measurement. +const RC_SLOPE: f64 = 28.0; +/// Fraction of the quantizer step the size error asks for that is applied per frame. +const RC_GAIN: f64 = 0.8; +/// Frames over which an accumulated deviation from the budget is paid back. +const RC_PAYBACK: f64 = 16.0; + +impl RateState { + /// The nominal bit budget and the starting quantizer index of a key or inter frame. A key + /// frame gets a larger share, and the inter frames of the group get the rest, so that a whole + /// group of `keyint` frames adds up to `keyint * frame_bits`. + fn frame_budget(&self, keyint: u32, key: bool) -> (f64, f64) { + let keyint = keyint.max(1) as f64; + let boost = (1.0 + (keyint.clamp(1.0, 60.0) - 1.0).sqrt() * 0.75).min(0.5 * keyint + 0.5); + if key { + (self.frame_bits * boost, (self.q + self.key_off).clamp(1.0, 255.0)) + } else if keyint > 1.0 { + (self.frame_bits * (keyint - boost) / (keyint - 1.0), self.q) + } else { + (self.frame_bits, self.q) + } + } + + /// Adapts the quantizers to the size of the frame just coded at `q_used` against `target`. + fn update(&mut self, key: bool, first: bool, q_used: f64, bits: f64, target: f64) { + let err = (bits / target).log2().clamp(-1.5, 1.5); + if first && key { + // four passes ran: the key frame quantizer is known, the inter one follows from the offset + self.q = (q_used - self.key_off).clamp(1.0, 255.0); + } else if key { + self.key_off = q_used - self.q + RC_GAIN * self.slope * err; + } else { + if let Some((pq, pbits)) = self.prev_inter { + let (dq, dr) = (q_used - pq, (pbits / bits).log2()); + if dq.abs() >= 3.0 && dr.abs() >= 0.15 && dq * dr > 0.0 { + self.slope = (0.6 * self.slope + 0.4 * (dq / dr)).clamp(3.0, 45.0); + } + } + self.prev_inter = Some((q_used, bits)); + self.q = (q_used + RC_GAIN * self.slope * err).clamp(1.0, 255.0); + } + } +} + +/// Most passes on the first frame, and how far from the target (octaves, 3 %) its size may be +/// before it is accepted. +const RC_FIRST_PASSES: usize = 4; +const RC_FIRST_TOLERANCE: f64 = 0.04; +/// Quantizer index steps per halving of the size, for the first correction of the first frame +/// (the starting quantizer comes from the bits per sample; the first pass shows how far off it +/// is, and from there on the passes are placed by `next_probe_q`). +const RC_FIRST_SLOPE: f64 = 30.0; +/// The same for a key frame after the first: coded again when its size is more than 0.25 octaves +/// (19 %) off the target. +const RC_KEY_PASSES: usize = 3; +const RC_KEY_TOLERANCE: f64 = 0.25; +/// Octaves of frame size per octave of quantizer step assumed for the first correction of a frame. +const RC_PROBE_SLOPE: f64 = 1.0; +/// Largest change of the quantizer step (octaves) between two passes on one frame. +const RC_PROBE_MAX_STEP: f64 = 2.0; + +/// The quantizer index whose step size is closest to `2^x`. +fn q_for_step(bit_depth: u8, x: f64) -> u8 { + let table = &AC_QLOOKUP[((bit_depth - 8) / 2) as usize]; + let mut best = 1; + for q in 1..=255usize { + if ((table[q] as f64).log2() - x).abs() < ((table[best] as f64).log2() - x).abs() { + best = q; + } + } + best as u8 +} + +/// The next quantizer to try for a frame, from the `(quantizer, log2(size / target))` +/// pairs coded so far. The size follows the quantizer step (not the index, which is linear in the +/// step only below about 60) in a near log-log line: false position between the closest pair of +/// passes that enclose the target, otherwise a step from the latest one with the slope of the +/// last two. `None` when no untried quantizer lies between the enclosing passes. +fn next_probe_q(bit_depth: u8, tried: &[(u8, f64)]) -> Option { + let table = &AC_QLOOKUP[((bit_depth - 8) / 2) as usize]; + let x = |q: u8| (table[q as usize] as f64).log2(); + let over = tried.iter().filter(|t| t.1 > 0.0).max_by_key(|t| t.0); + let under = tried.iter().filter(|t| t.1 <= 0.0).min_by_key(|t| t.0); + if let (Some(&(q_lo, e_lo)), Some(&(q_hi, e_hi))) = (over, under) { + if q_hi - q_lo < 2 { + return None; + } + let next = x(q_lo) + (x(q_hi) - x(q_lo)) * e_lo / (e_lo - e_hi); + return Some(q_for_step(bit_depth, next).clamp(q_lo + 1, q_hi - 1)); + } + let &(q, err) = tried.last()?; + let mut slope = RC_PROBE_SLOPE; + if let [.., (q0, e0), (q1, e1)] = tried + && (x(*q1) - x(*q0)).abs() > 0.05 + && (e0 - e1) / (x(*q1) - x(*q0)) > 0.0 + { + slope = ((e0 - e1) / (x(*q1) - x(*q0))).clamp(0.25, 3.0); + } + Some(q_for_step(bit_depth, x(q) + (err / slope).clamp(-RC_PROBE_MAX_STEP, RC_PROBE_MAX_STEP))) +} + /// AV1 encoder. Frames come out in input order (no hidden/reordered frames). pub struct Encoder { cfg: EncoderConfig, @@ -185,7 +294,7 @@ impl Encoder { // initial guess from the bits per sample let bpp = frame_bits / (cfg.width as f64 * cfg.height as f64); let q = (110.0 - 40.0 * (bpp / 0.1).log2()).clamp(20.0, 230.0); - Some(RateState { frame_bits, q, debt: 0.0 }) + Some(RateState { frame_bits, q, key_off: -24.0, slope: RC_SLOPE, prev_inter: None, debt: 0.0 }) } }; let mut enc = Encoder { cfg, geom, level, seq_obu: Vec::new(), frame_num: 0, refr: None, rc }; @@ -409,11 +518,13 @@ impl Encoder { }; let bd = self.cfg.bit_depth as u32; let mut cache: Vec<(u32, u64)> = vec![(0, sse(&rec[0]))]; + // one working copy of the luma plane for all candidate levels + let mut planes = [rec[0].clone(), Plane::new(0, 0), Plane::new(0, 0)]; let mut eval = |lvl: u32| -> u64 { if let Some(&(_, e)) = cache.iter().find(|(l, _)| *l == lvl) { return e; } - let mut planes = [rec[0].clone(), Plane::new(0, 0), Plane::new(0, 0)]; + planes[0].data.copy_from_slice(&rec[0].data); loop_filter(&mut planes, info, [lvl; 4], bd, true, sb_row_step); let e = sse(&planes[0]); cache.push((lvl, e)); @@ -469,38 +580,52 @@ impl Encoder { fn encode_rate_controlled(&mut self, src: &[Plane; 3], key: bool, rc: &mut RateState) -> (Vec, RefFrame) { let first = self.frame_num == 0; - let (frame_bits, debt, q_inter) = (rc.frame_bits, rc.debt, rc.q); - // key frames get a lower quantizer and a larger share of the budget - let key_boost = 1.0 + (self.cfg.keyint.clamp(1, 60) as f64 - 1.0).sqrt() * 0.75; - let budget = if key { frame_bits * key_boost } else { frame_bits }; - // pay back a fraction of the accumulated deviation - let target = (budget - debt * 0.25).max(budget * 0.25); - let mut q = if key { q_inter - 24.0 } else { q_inter }; - let mut best: Option<(Vec, RefFrame, f64)> = None; - let passes = if first { 4 } else { 1 }; - for pass in 0..passes { + let (budget, q_start) = rc.frame_budget(self.cfg.keyint, key); + // pay back the accumulated deviation over the next RC_PAYBACK frames + let target = (budget - rc.debt / RC_PAYBACK).clamp(budget * 0.35, budget * 2.0); + let mut q = q_start; + let mut best: Option<(Vec, RefFrame, f64, f64)> = None; + // The first frame is coded up to four times to find its quantizer (it fixes the + // quantizers of everything after it), a later key frame up to three times (it is a large + // part of the budget of its group). A frame is coded again only while its size is far from + // the target and an untried quantizer is left to try; other frames are coded once. + let (max_passes, tolerance) = if first { + (RC_FIRST_PASSES, RC_FIRST_TOLERANCE) + } else if key { + (RC_KEY_PASSES, RC_KEY_TOLERANCE) + } else { + (1, 0.0) + }; + let mut tried: Vec<(u8, f64)> = Vec::new(); + for _ in 0..max_passes { let qi = q.round().clamp(1.0, 255.0) as u8; + if tried.iter().any(|t| t.0 == qi) { + break; + } let (obu, rf) = self.encode_frame(src, key, qi); let bits = obu.len() as f64 * 8.0; let err = (bits / target).log2(); - let better = best.as_ref().is_none_or(|b| err.abs() < (b.2 / target).log2().abs()); - if better { - best = Some((obu, rf, bits)); + tried.push((qi, err)); + if best.as_ref().is_none_or(|b| err.abs() < (b.2 / target).log2().abs()) { + best = Some((obu, rf, bits, qi as f64)); } - if pass + 1 < passes { - q = (q + 30.0 * err).clamp(1.0, 255.0); + if err.abs() <= tolerance { + break; + } + let next = if first && tried.len() == 1 { + Some((q + RC_FIRST_SLOPE * err).clamp(1.0, 255.0)) + } else { + next_probe_q(self.cfg.bit_depth, &tried).map(f64::from) + }; + match next { + Some(next) => q = next, + None => break, } } // At least one pass ran, so `best` is set. - let Some((obu, rf, bits)) = best else { return self.encode_frame(src, key, q.round().clamp(1.0, 255.0) as u8) }; + let Some((obu, rf, bits, q_used)) = best else { return self.encode_frame(src, key, q.round().clamp(1.0, 255.0) as u8) }; rc.debt += bits - budget; - // adapt the inter quantizer: about 30 index steps per doubling of the rate - let err = (bits / target).log2().clamp(-1.0, 1.0); - if first && key { - rc.q = (q + 24.0).clamp(1.0, 255.0); - } else { - rc.q = (rc.q + 12.0 * err).clamp(1.0, 255.0); - } + rc.update(key, first, q_used, bits, target); (obu, rf) } diff --git a/crates/av1enc/src/loopfilter.rs b/crates/av1enc/src/loopfilter.rs index 2add41edc..cf350fbd2 100644 --- a/crates/av1enc/src/loopfilter.rs +++ b/crates/av1enc/src/loopfilter.rs @@ -3,7 +3,7 @@ //! is 0 in the frames this encoder writes. use crate::pred::Plane; -use crate::tables::*; +use crate::tables::{MAX_LOOP_FILTER, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, SUBSAMPLED_SIZE, TX_HEIGHT, TX_WIDTH}; /// Mode info the loop filter reads (per 4x4 luma unit, row stride `mi_cols`), and the /// transform size per 4x4 unit of each plane (`LoopfilterTxSizes`, row stride `mi_cols + 32`). @@ -39,7 +39,7 @@ pub(crate) fn loop_filter(planes: &mut [Plane; 3], info: &LfInfo, level: [u32; 4 } let mut col = 0; while col < info.mi_cols { - edge(pl, info, level, bit_depth, plane, pass, row, col); + edge(pl, info, level, bit_depth, plane, pass, (row, col)); col += step; } row += step; @@ -48,7 +48,8 @@ pub(crate) fn loop_filter(planes: &mut [Plane; 3], info: &LfInfo, level: [u32; 4 } } -fn edge(pl: &mut Plane, info: &LfInfo, level: [u32; 4], bd: u32, plane: usize, pass: usize, row: usize, col: usize) { +fn edge(pl: &mut Plane, info: &LfInfo, level: [u32; 4], bd: u32, plane: usize, pass: usize, position: (usize, usize)) { + let (row, col) = position; let s = (plane > 0) as usize; let (dx, dy) = if pass == 0 { (1usize, 0usize) } else { (0, 1) }; let x = col * 4; @@ -88,13 +89,24 @@ fn edge(pl: &mut Plane, info: &LfInfo, level: [u32; 4], bd: u32, plane: usize, p let limit = lvl.max(1); let blimit = 2 * (lvl + 2) + limit; let thresh = lvl >> 4; + let strength = FilterStrength { limit, blimit, thresh }; for k in 0..4 { - sample_filter(pl, xp + dy * k, yp + dx * k, plane, limit, blimit, thresh, dx as isize, dy as isize, filter_size, bd); + sample_filter(pl, (xp + dy * k, yp + dx * k), plane, &strength, (dx as isize, dy as isize), filter_size, bd); } } +/// Thresholds derived from the loop-filter level (spec 7.14.4). +struct FilterStrength { + limit: i32, + blimit: i32, + thresh: i32, +} + #[inline] -fn sample_filter(pl: &mut Plane, x: usize, y: usize, plane: usize, limit: i32, blimit: i32, thresh: i32, dx: isize, dy: isize, filter_size: usize, bd: u32) { +fn sample_filter(pl: &mut Plane, position: (usize, usize), plane: usize, strength: &FilterStrength, direction: (isize, isize), filter_size: usize, bd: u32) { + let (x, y) = position; + let (dx, dy) = direction; + let FilterStrength { limit, blimit, thresh } = *strength; let stride = pl.stride as isize; let step = dy * stride + dx; let base = y as isize * stride + x as isize; diff --git a/crates/av1enc/src/pred.rs b/crates/av1enc/src/pred.rs index d61a6fa94..b899202f4 100644 --- a/crates/av1enc/src/pred.rs +++ b/crates/av1enc/src/pred.rs @@ -2,7 +2,10 @@ //! modes plus V_PRED / H_PRED without edge filtering, 7.11.3 block inter prediction without //! reference scaling) so that the encoder predicts exactly like a decoder. -use crate::tables::*; +use crate::tables::{ + DC_PRED, EIGHTTAP, H_PRED, PAETH_PRED, SM_WEIGHTS_TX_4X4, SM_WEIGHTS_TX_8X8, SM_WEIGHTS_TX_16X16, SM_WEIGHTS_TX_32X32, SMOOTH_H_PRED, SMOOTH_PRED, + SMOOTH_V_PRED, SUBPEL_FILTERS, V_PRED, +}; use crate::transform::round2; /// A plane buffer (padded to whole superblocks). @@ -184,19 +187,20 @@ pub(crate) fn predict_intra(ed: &Edges, e: &IntraEdge, mode: usize, pred: &mut [ /// final rounding is the identity) of the `w x h` block at plane position (x, y) displaced by /// `mv` (1/8 luma sample units, [row, col]). `last_x` / `last_y` are the largest sample /// coordinates of the reference plane; the result goes to `pred` (row stride `w`). -pub(crate) fn predict_inter( - refp: &Plane, - last_x: i32, - last_y: i32, - x: usize, - y: usize, - w: usize, - h: usize, - mv: [i32; 2], - ss: usize, - bit_depth: u32, - pred: &mut [u16], -) { +pub(crate) struct InterBlock { + pub last_x: i32, + pub last_y: i32, + pub x: usize, + pub y: usize, + pub width: usize, + pub height: usize, + pub mv: [i32; 2], + pub subsampling: usize, + pub bit_depth: u32, +} + +pub(crate) fn predict_inter(refp: &Plane, block: &InterBlock, pred: &mut [u16]) { + let InterBlock { last_x, last_y, x, y, width: w, height: h, mv, subsampling: ss, bit_depth } = *block; // positions in 1/16 sample units ((startX >> 6) & 15 is the filter phase) let px = ((x as i32) << 4) + ((2 * mv[1]) >> ss); let py = ((y as i32) << 4) + ((2 * mv[0]) >> ss); @@ -293,7 +297,8 @@ pub(crate) fn predict_inter( /// The visible `w x h` area of a reference plane interpolated at the sub-sample phase /// (`fx`, `fy`) (1/16 units) with the EIGHTTAP filter, as `predict_inter` computes it for -/// blocks wider and taller than 4. Used for motion search. +/// blocks wider and taller than 4. The reference for `half_planes`, which the encoder uses. +#[cfg(test)] pub(crate) fn subpel_plane(refp: &Plane, w: usize, h: usize, fx: usize, fy: usize, bit_depth: u32) -> Vec { let kh = &SUBPEL_FILTERS[EIGHTTAP][fx]; let kv = &SUBPEL_FILTERS[EIGHTTAP][fy]; @@ -323,6 +328,66 @@ pub(crate) fn subpel_plane(refp: &Plane, w: usize, h: usize, fx: usize, fy: usiz out } +/// The three half-sample planes (8, 0), (0, 8), (8, 8) of `subpel_plane` in one pass: the +/// horizontal half-sample pass is shared by (8, 0) and (8, 8), and the phase-0 kernels reduce to +/// exact shifts (Round2( 128 * v, 11 ) = Round2( v, 4 ), Round2( 16 * s, 11 ) = Round2( s, 7 )), +/// so the result equals three `subpel_plane` calls bit for bit. +pub(crate) fn half_planes(refp: &Plane, w: usize, h: usize, bit_depth: u32) -> [Vec; 3] { + let k = &SUBPEL_FILTERS[EIGHTTAP][8]; + let max = (1i32 << bit_depth) - 1; + let (lx, ly) = (w as i32 - 1, h as i32 - 1); + // horizontal pass at phase 8: Round2( sum, 3 ) + let mut hp = vec![0i32; w * h]; + for y in 0..h { + let row = &refp.row(y)[..w]; + let out = &mut hp[y * w..y * w + w]; + let tap = |x: i32| -> i32 { + let mut s = 0i32; + for t in 0..8 { + s += k[t] as i32 * row[(x + t as i32 - 3).clamp(0, lx) as usize] as i32; + } + round2(s, 3) + }; + if w >= 8 { + for x in 0..3 { + out[x] = tap(x as i32); + } + for (x, o) in out.iter_mut().enumerate().take(w - 4).skip(3) { + let seg = &row[x - 3..x + 5]; + let mut s = 0i32; + for t in 0..8 { + s += k[t] as i32 * seg[t] as i32; + } + *o = round2(s, 3); + } + for x in w - 4..w { + out[x] = tap(x as i32); + } + } else { + for (x, o) in out.iter_mut().enumerate() { + *o = tap(x as i32); + } + } + } + let p80: Vec = hp.iter().map(|&v| round2(v, 4).clamp(0, max) as u16).collect(); + let mut p08 = vec![0u16; w * h]; + let mut p88 = vec![0u16; w * h]; + for y in 0..h { + let rows: [usize; 8] = std::array::from_fn(|t| (y as i32 + t as i32 - 3).clamp(0, ly) as usize); + let (o08, o88) = (&mut p08[y * w..y * w + w], &mut p88[y * w..y * w + w]); + for x in 0..w { + let (mut a, mut b) = (0i32, 0i32); + for t in 0..8 { + a += k[t] as i32 * refp.data[rows[t] * refp.stride + x] as i32; + b += k[t] as i32 * hp[rows[t] * w + x]; + } + o08[x] = round2(a, 7).clamp(0, max) as u16; + o88[x] = round2(b, 11).clamp(0, max) as u16; + } + } + [p80, p08, p88] +} + #[cfg(test)] mod tests { use super::*; @@ -340,7 +405,11 @@ mod tests { for (bx, by) in [(0i32, 0i32), (8, 8), (24, 16), (-5, 20), (30, -3)] { let mut pred = vec![0u16; 64]; let mv = [by * 8 + fy as i32 / 2, bx * 8 + fx as i32 / 2]; - predict_inter(&p, w as i32 - 1, h as i32 - 1, 0, 0, 8, 8, mv, 0, 8, &mut pred); + predict_inter( + &p, + &InterBlock { last_x: w as i32 - 1, last_y: h as i32 - 1, x: 0, y: 0, width: 8, height: 8, mv, subsampling: 0, bit_depth: 8 }, + &mut pred, + ); for r in 0..8 { for c in 0..8 { let (x, y) = ((bx + c).clamp(0, w as i32 - 1), (by + r).clamp(0, h as i32 - 1)); @@ -353,6 +422,20 @@ mod tests { } } + #[test] + fn half_planes_match_subpel_plane() { + for (w, h) in [(37usize, 29usize), (5, 3), (1, 1), (64, 64)] { + let mut p = Plane::new(64, 64); + for (i, v) in p.data.iter_mut().enumerate() { + *v = ((i * 7919) % 1024) as u16; + } + let hp = half_planes(&p, w, h, 10); + for (i, (fx, fy)) in [(8, 0), (0, 8), (8, 8)].into_iter().enumerate() { + assert_eq!(hp[i], subpel_plane(&p, w, h, fx, fy, 10), "{w}x{h} phase ({fx}, {fy})"); + } + } + } + #[test] fn phase_zero_filters_are_impulses() { for f in SUBPEL_FILTERS.iter() { diff --git a/crates/av1enc/src/transform.rs b/crates/av1enc/src/transform.rs index 90042e26a..9d81ead0b 100644 --- a/crates/av1enc/src/transform.rs +++ b/crates/av1enc/src/transform.rs @@ -4,7 +4,7 @@ use std::sync::OnceLock; -use crate::tables::*; +use crate::tables::{ADST_ADST, ADST_DCT, COS128_LOOKUP, DCT_ADST, DCT_DCT, TRANSFORM_ROW_SHIFT, TX_WIDTH_LOG2}; #[inline(always)] fn round2_64(x: i64, n: u32) -> i32 { @@ -32,6 +32,7 @@ fn sin128(angle: i32) -> i64 { cos128(angle - 64) } +#[inline(always)] fn brev(num_bits: u32, x: usize) -> usize { let mut t = 0; for i in 0..num_bits { @@ -63,14 +64,24 @@ fn hd(t: &mut [i32], a: usize, b: usize, flip: bool, r: u32) { t[b] = (x - y).clamp(lo, hi); } -/// Inverse DCT of length 2^n, n = 2..=5 (7.13.2.3), in place. -fn inverse_dct(t: &mut [i32], n: u32, r: u32) { +/// Inverse DCT of length 2^N, N = 2..=5 (7.13.2.3), in place. `N` is a const generic so that the +/// bit-reversal permutations and loop bounds fold at compile time. +#[inline(always)] +fn inverse_dct_n(t: &mut [i32], r: u32) { + let n = N; let len = 1usize << n; let mut copy = [0i32; 32]; copy[..len].copy_from_slice(&t[..len]); for i in 0..len { t[i] = copy[brev(n, i)]; } + inverse_dct_initial_stages(t, n, r); + inverse_dct_final_stages(t, n, r); +} + +/// Rotate the high-frequency branches before the four-point core. +#[inline(always)] +fn inverse_dct_initial_stages(t: &mut [i32], n: u32, r: u32) { if n >= 5 { for i in 0..8 { bf(t, 16 + i, 31 - i, 6 + ((brev(3, 7 - i) as i32) << 3), false); @@ -103,6 +114,11 @@ fn inverse_dct(t: &mut [i32], n: u32, r: u32) { } } } +} + +/// Evaluate the four-point core and merge progressively larger branches. +#[inline(always)] +fn inverse_dct_final_stages(t: &mut [i32], n: u32, r: u32) { for i in 0..2 { bf(t, 2 * i, 2 * i + 1, 32 + 16 * i as i32, i == 0); } @@ -180,6 +196,7 @@ const SINPI_2_9: i64 = 2482; const SINPI_3_9: i64 = 3344; const SINPI_4_9: i64 = 3803; +#[inline(always)] fn inverse_adst4(t: &mut [i32]) { let (t0, t1, t2, t3) = (t[0] as i64, t[1] as i64, t[2] as i64, t[3] as i64); let mut s = [SINPI_1_9 * t0, SINPI_2_9 * t0, SINPI_3_9 * t1, SINPI_4_9 * t2, SINPI_1_9 * t2, SINPI_2_9 * t3, SINPI_4_9 * t3]; @@ -197,7 +214,9 @@ fn inverse_adst4(t: &mut [i32]) { } } -fn adst_in_permute(t: &mut [i32], n: u32) { +#[inline(always)] +fn adst_in_permute(t: &mut [i32]) { + let n = N; let n0 = 1usize << n; let mut copy = [0i32; 16]; copy[..n0].copy_from_slice(&t[..n0]); @@ -207,7 +226,9 @@ fn adst_in_permute(t: &mut [i32], n: u32) { } } -fn adst_out_permute(t: &mut [i32], n: u32) { +#[inline(always)] +fn adst_out_permute(t: &mut [i32]) { + let n = N; let n0 = 1usize << n; let mut copy = [0i32; 16]; copy[..n0].copy_from_slice(&t[..n0]); @@ -221,8 +242,9 @@ fn adst_out_permute(t: &mut [i32], n: u32) { } } +#[inline(always)] fn inverse_adst8(t: &mut [i32], r: u32) { - adst_in_permute(t, 3); + adst_in_permute::<3>(t); for i in 0..4 { bf(t, 2 * i, 2 * i + 1, 60 - 16 * i as i32, true); } @@ -240,11 +262,12 @@ fn inverse_adst8(t: &mut [i32], r: u32) { for i in 0..2 { bf(t, 2 + 4 * i, 3 + 4 * i, 32, true); } - adst_out_permute(t, 3); + adst_out_permute::<3>(t); } +#[inline(always)] fn inverse_adst16(t: &mut [i32], r: u32) { - adst_in_permute(t, 4); + adst_in_permute::<4>(t); for i in 0..8 { bf(t, 2 * i, 2 * i + 1, 62 - 8 * i as i32, true); } @@ -273,7 +296,7 @@ fn inverse_adst16(t: &mut [i32], r: u32) { for i in 0..4 { bf(t, 2 + 4 * i, 3 + 4 * i, 32, true); } - adst_out_permute(t, 4); + adst_out_permute::<4>(t); } /// 1-D transform kind. @@ -298,6 +321,17 @@ pub(crate) fn kinds(tx_type: usize) -> (Kind, Kind) { } } +#[inline(always)] +fn inverse_dct(t: &mut [i32], n: u32, r: u32) { + match n { + 2 => inverse_dct_n::<2>(t, r), + 3 => inverse_dct_n::<3>(t, r), + 4 => inverse_dct_n::<4>(t, r), + _ => inverse_dct_n::<5>(t, r), + } +} + +#[inline(always)] fn inverse_1d(t: &mut [i32], kind: Kind, n: u32, r: u32) { match kind { Kind::Dct => inverse_dct(t, n, r), @@ -312,7 +346,17 @@ fn inverse_1d(t: &mut [i32], kind: Kind, n: u32, r: u32) { /// 2-D inverse transform (7.13.3) of a square `n x n` block (n = 4..=32) of dequantised /// coefficients (row stride `n`) into `residual` (row stride `n`). pub(crate) fn inverse_2d(dequant: &[i32], residual: &mut [i32], tx_sz: usize, tx_type: usize, bit_depth: u32) { - let log2 = TX_WIDTH_LOG2[tx_sz] as u32; + match TX_WIDTH_LOG2[tx_sz] { + 2 => inverse_2d_n::<2>(dequant, residual, tx_sz, tx_type, bit_depth), + 3 => inverse_2d_n::<3>(dequant, residual, tx_sz, tx_type, bit_depth), + 4 => inverse_2d_n::<4>(dequant, residual, tx_sz, tx_type, bit_depth), + _ => inverse_2d_n::<5>(dequant, residual, tx_sz, tx_type, bit_depth), + } +} + +/// `inverse_2d` for a block of width `1 << LOG2`. +fn inverse_2d_n(dequant: &[i32], residual: &mut [i32], tx_sz: usize, tx_type: usize, bit_depth: u32) { + let log2 = LOG2; let n = 1usize << log2; let row_shift = TRANSFORM_ROW_SHIFT[tx_sz] as u32; let row_clamp = bit_depth + 8; @@ -419,6 +463,51 @@ fn basis(kind: Kind, n: usize) -> &'static [f32] { /// Forward 2-D transform of the `n x n` residual (row stride `n`) into orthonormal-domain /// coefficients scaled by 8 (so that `level = out / q` with the spec's dequantiser). pub(crate) fn forward_2d(residual: &[i32], out: &mut [f32], n: usize, tx_type: usize) { + match n { + 4 => forward_2d_n::<4, 16>(residual, out, tx_type), + 8 => forward_2d_n::<8, 64>(residual, out, tx_type), + 16 => forward_2d_n::<16, 256>(residual, out, tx_type), + _ => forward_2d_dyn(residual, out, n, tx_type), + } +} + +/// `forward_2d` for a fixed size, so `tmp` is exactly `NN = N * N` floats (no 4 KiB zeroing). +#[inline(always)] +fn forward_2d_n(residual: &[i32], out: &mut [f32], tx_type: usize) { + let n = N; + let (ck, rk) = kinds(tx_type); + let bc = &basis(ck, n)[..NN]; + let brt = &basis_t(rk, n)[..NN]; + let residual = &residual[..NN]; + let out = &mut out[..NN]; + let mut tmp = [0f32; NN]; + for i in 0..n { + let t = &mut tmp[i * n..i * n + n]; + for j in 0..n { + let r = residual[i * n + j]; + if r == 0 { + continue; + } + let r = r as f32; + for (o, &b) in t.iter_mut().zip(&brt[j * n..j * n + n]) { + *o += r * b; + } + } + } + out.iter_mut().for_each(|v| *v = 0.0); + for k in 0..n { + let o = &mut out[k * n..k * n + n]; + for i in 0..n { + let b = 8.0 * bc[k * n + i]; + for (v, &t) in o.iter_mut().zip(&tmp[i * n..i * n + n]) { + *v += b * t; + } + } + } +} + +/// 32x32 (and any other size): a const-generic 32 version measured slower. +fn forward_2d_dyn(residual: &[i32], out: &mut [f32], n: usize, tx_type: usize) { let (ck, rk) = kinds(tx_type); let bc = basis(ck, n); let brt = basis_t(rk, n); @@ -454,6 +543,7 @@ pub(crate) fn forward_2d(residual: &[i32], out: &mut [f32], n: usize, tx_type: u #[cfg(test)] mod tests { use super::*; + use crate::tables::{TX_4X4, TX_8X8, TX_16X16, TX_32X32}; /// forward (float) then the normative inverse reproduces the residual: the forward basis /// and scale match the decoder's inverse transforms for every supported size and type. diff --git a/crates/av1enc/src/writer.rs b/crates/av1enc/src/writer.rs index 54c545fa4..a10bd2e9a 100644 --- a/crates/av1enc/src/writer.rs +++ b/crates/av1enc/src/writer.rs @@ -8,7 +8,15 @@ use crate::cdf::Cdfs; use crate::decide::{FrameGeom, Leaf, Node, intra_uv_tx_type, scan_for, tx_in_set, tx_set, tx_size_for}; use crate::loopfilter::LfInfo; use crate::symbol::SymbolWriter; -use crate::tables::*; +use crate::tables::{ + ADST_ADST, ADST_DCT, ALTREF_FRAME, ALTREF2_FRAME, BLOCK_8X8, BLOCK_64X64, BR_CDF_SIZE, BWDREF_FRAME, CLASS0_SIZE, COEFF_BASE_CTX_OFFSET, COEFF_BASE_RANGE, + D67_PRED, DC_PRED, DCT_ADST, DCT_DCT, DEFAULT_INTRA_FRAME_Y_MODE_CDF, GLOBALMV, GOLDEN_FRAME, INTRA_FRAME, INTRA_MODE_CONTEXT, LAST_FRAME, LAST2_FRAME, + LAST3_FRAME, MAG_REF_OFFSET_WITH_TX_CLASS, MAX_ANGLE_DELTA, MAX_REF_MV_STACK_SIZE, MI_HEIGHT_LOG2, MI_WIDTH_LOG2, MV_BORDER, MV_CLASS_0, NEARESTMV, NEARMV, + NEWMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, NUM_BASE_LEVELS, PARTITION_HORZ, PARTITION_HORZ_4, PARTITION_HORZ_A, PARTITION_HORZ_B, PARTITION_NONE, + PARTITION_SPLIT, PARTITION_SUBSIZE, PARTITION_VERT, PARTITION_VERT_4, PARTITION_VERT_A, PARTITION_VERT_B, REF_CAT_LEVEL, SIG_REF_DIFF_OFFSET, SIZE_GROUP, + SUBSAMPLED_SIZE, TX_32X32, TX_CLASS_2D, TX_HEIGHT, TX_HEIGHT_LOG2, TX_SIZE_SQR, TX_SIZE_SQR_UP, TX_TYPE_INTER_INV_SET3, TX_TYPE_INTRA_INV_SET2, TX_WIDTH, + TX_WIDTH_LOG2, V_PRED, +}; type MvP = [i32; 2]; @@ -62,6 +70,8 @@ pub(crate) struct TileWriter<'a> { left_dc: [Vec; 3], /// LoopfilterTxSizes per plane (row stride mi_cols + 32). lf_tx: [Vec; 3], + /// Scratch for the levels coded so far in `coeffs`. + quant: Vec, // current block r: usize, c: usize, @@ -114,6 +124,7 @@ impl<'a> TileWriter<'a> { left_level: std::array::from_fn(|_| vec![0; g.mi_rows + 64]), left_dc: std::array::from_fn(|_| vec![0; g.mi_rows + 64]), lf_tx: std::array::from_fn(|_| vec![0; (g.mi_cols + 32) * (g.mi_rows + 32)]), + quant: Vec::new(), r: 0, c: 0, bsize: 0, @@ -181,10 +192,10 @@ impl<'a> TileWriter<'a> { self.sw.symbol(partition, cdf); } else if has_cols || has_rows { assert!(split, "blocks crossing the frame edge are split"); - let pcdf: Vec = match bsl { - 2 => self.cdf.partition_w16[ctx].to_vec(), - 3 => self.cdf.partition_w32[ctx].to_vec(), - _ => self.cdf.partition_w64[ctx].to_vec(), + let pcdf: &[u16] = match bsl { + 2 => &self.cdf.partition_w16[ctx], + 3 => &self.cdf.partition_w32[ctx], + _ => &self.cdf.partition_w64[ctx], }; let pr = |k: usize| pcdf[k] as i32 - if k == 0 { 0 } else { pcdf[k - 1] as i32 }; let psum = if has_cols { @@ -455,23 +466,7 @@ impl<'a> TileWriter<'a> { } } else { if plane == 0 { - // transform_type( ) - let set = tx_set(tx_sz, b.is_inter); - let t = b.tx_type_y; - if set > 0 && self.qidx > 0 { - let sqr = TX_SIZE_SQR[tx_sz] as usize; - if b.is_inter { - // The decision only picks types of this set (DCT_DCT otherwise). - let s = TX_TYPE_INTER_INV_SET3.iter().position(|&v| v as usize == t).unwrap_or_default(); - self.sw.symbol(s, &mut self.cdf.inter_tx_type_set3[sqr]); - } else { - let s = TX_TYPE_INTRA_INV_SET2.iter().position(|&v| v as usize == t).unwrap_or_default(); - self.sw.symbol(s, &mut self.cdf.intra_tx_type_set2[sqr][b.y_mode]); - } - } else { - assert_eq!(t, DCT_DCT); - } - self.set_tx_types(x4, y4, tx_sz, t); + self.write_transform_type(x4, y4, tx_sz, b); } let plane_tx_type = self.compute_tx_type(plane, tx_sz, x4, y4, b); debug_assert!(plane_tx_type == DCT_DCT || matches!(plane_tx_type, ADST_DCT | DCT_ADST | ADST_ADST)); @@ -501,7 +496,9 @@ impl<'a> TileWriter<'a> { let height = TX_HEIGHT[tx_sz] as usize; let width = 1usize << bwl; // Quant[] as the decoder sees it while parsing: the levels coded so far (capped) - let mut quant = vec![0i32; width * height]; + let mut quant = std::mem::take(&mut self.quant); + quant.clear(); + quant.resize(width * height, 0); for c in (0..eob).rev() { let pos = scan[c] as usize; let a = levels[pos].unsigned_abs().min(15) as i32; @@ -526,6 +523,7 @@ impl<'a> TileWriter<'a> { } quant[pos] = a; } + self.quant = quant; for c in 0..eob { let pos = scan[c] as usize; let v = levels[pos]; @@ -571,6 +569,26 @@ impl<'a> TileWriter<'a> { } } + fn write_transform_type(&mut self, x4: usize, y4: usize, tx_sz: usize, b: &Leaf) { + // transform_type( ) + let set = tx_set(tx_sz, b.is_inter); + let t = b.tx_type_y; + if set > 0 && self.qidx > 0 { + let sqr = TX_SIZE_SQR[tx_sz] as usize; + if b.is_inter { + // The decision only picks types of this set (DCT_DCT otherwise). + let s = TX_TYPE_INTER_INV_SET3.iter().position(|&v| v as usize == t).unwrap_or_default(); + self.sw.symbol(s, &mut self.cdf.inter_tx_type_set3[sqr]); + } else { + let s = TX_TYPE_INTRA_INV_SET2.iter().position(|&v| v as usize == t).unwrap_or_default(); + self.sw.symbol(s, &mut self.cdf.intra_tx_type_set2[sqr][b.y_mode]); + } + } else { + assert_eq!(t, DCT_DCT); + } + self.set_tx_types(x4, y4, tx_sz, t); + } + fn set_tx_types(&mut self, x4: usize, y4: usize, tx_sz: usize, t: usize) { for i in 0..(TX_WIDTH[tx_sz] as usize >> 2) { for j in 0..(TX_HEIGHT[tx_sz] as usize >> 2) { diff --git a/crates/av1enc/tests/it/roundtrip.rs b/crates/av1enc/tests/it/roundtrip.rs index 282880e08..edc98c370 100644 --- a/crates/av1enc/tests/it/roundtrip.rs +++ b/crates/av1enc/tests/it/roundtrip.rs @@ -80,6 +80,23 @@ fn bitrate_mode_tracks_target() { assert!(kbps > 400.0 / 2.0 && kbps < 400.0 * 2.0, "{kbps} kbit/s"); } +/// Over several key frame intervals the average rate stays within 5 % of the target, for a +/// range of targets (the key frame budget is taken out of the inter frames of its group). +#[test] +fn bitrate_mode_is_accurate_over_many_groups() { + let n = 180; + for (kbps, keyint, bd) in [(40, 30, 8), (100, 30, 8), (200, 60, 8), (150, 20, 10)] { + let pics: Vec = (0..n).map(|t| synth(192, 128, bd, t)).collect(); + let mut c = cfg(192, 128, bd as u8, 0, keyint); + c.rate = RateControl::Bitrate { kbps }; + let mut enc = effectcraft_av1enc::Encoder::new(c).unwrap(); + let bits: usize = pics.iter().map(|p| enc.encode(&p.frame()).data.len() * 8).sum(); + let got = bits as f64 / (n as f64 / 30.0) / 1000.0; + eprintln!("{bd} bit, keyint {keyint}: {got:.1} kbit/s for {kbps}"); + assert!((got / kbps as f64 - 1.0).abs() < 0.05, "{got:.1} kbit/s for a {kbps} kbit/s target ({bd} bit, keyint {keyint})"); + } +} + #[test] fn headers() { let enc = effectcraft_av1enc::Encoder::new(cfg(1920, 1080, 10, 100, 60)).unwrap(); diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 2a0dfc50e..71eb43296 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -97,7 +97,9 @@ pub fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) { return (0.0, 0.0, l); } let d = max - min; - let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) }; + let denom = if l > 0.5 { 2.0 - max - min } else { max + min }; + // Over-range (HDR) or negative inputs can make the denominator zero or negative. + let s = if denom > 0.0 { d / denom } else { 1.0 }; let h = if max == r { (((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0).rem_euclid(1.0) } else if max == g { @@ -262,6 +264,20 @@ impl Label { mod tests { use super::*; + #[test] + fn hsl_round_trip_is_finite_for_out_of_range_colours() { + for (r, g, b) in [(1.5, 0.5, 0.5), (2.0, 0.1, 0.3), (-0.5, 0.2, 0.9)] { + let (h, s, l) = rgb_to_hsl(r, g, b); + assert!(h.is_finite() && s.is_finite() && l.is_finite(), "{h} {s} {l}"); + let (r2, g2, b2) = hsl_to_rgb(h, s, l); + assert!(r2.is_finite() && g2.is_finite() && b2.is_finite(), "{r2} {g2} {b2}"); + } + // A 32 bpc colour whose denominator stays positive still round-trips exactly. + let (h, s, l) = rgb_to_hsl(1.2, 0.5, 0.5); + let (r, g, b) = hsl_to_rgb(h, s, l); + assert!((r - 1.2).abs() < 1e-5 && (g - 0.5).abs() < 1e-5 && (b - 0.5).abs() < 1e-5, "{r} {g} {b}"); + } + #[test] fn hsv_to_rgb_tiny_negative_hue_is_red() { let (r, g, b) = hsv_to_rgb(-1e-9, 1.0, 1.0); diff --git a/crates/effects/src/analysis_cache.rs b/crates/effects/src/analysis_cache.rs new file mode 100644 index 000000000..98764d205 --- /dev/null +++ b/crates/effects/src/analysis_cache.rs @@ -0,0 +1,97 @@ +//! Bounded parsed-JSON caches. Keep the content key with the owned source text so repeated +//! renders do not rehash long analyses. Compare contents on lookup: evaluated parameter +//! strings can be cloned, edited in place, or allocated again at an old address. + +use std::sync::{Arc, Mutex, OnceLock}; + +struct Entry { + json: Box, + key: u64, + value: Arc, +} + +pub(crate) struct AnalysisCache { + entries: OnceLock>>>, +} + +pub(crate) fn fnv(bytes: &[u8]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for b in bytes { + h ^= *b as u64; + h = h.wrapping_mul(0x0100_0000_01b3); + } + h +} + +impl AnalysisCache { + pub(crate) const fn new() -> Self { + Self { entries: OnceLock::new() } + } + + pub(crate) fn get(&self, json: &str, parse: impl FnOnce(&str) -> Option) -> Option<(u64, Arc)> { + let entries = self.entries.get_or_init(Default::default); + { + let m = entries.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(e) = m.iter().rev().find(|e| e.json.as_ref() == json) { + return Some((e.key, e.value.clone())); + } + } + let value = Arc::new(parse(json)?); + let key = fnv(json.as_bytes()); + let mut m = entries.lock().unwrap_or_else(|e| e.into_inner()); + // Another render may have filled the entry while this one parsed it. + if let Some(e) = m.iter().rev().find(|e| e.json.as_ref() == json) { + return Some((e.key, e.value.clone())); + } + // Preserve the existing nine-entry bound and clear-on-overflow policy. + if m.len() > 8 { + m.clear(); + } + m.push(Entry { json: json.into(), key, value: value.clone() }); + Some((key, value)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + + #[test] + fn reuses_content_and_invalidates_same_allocation_edits() { + let cache = AnalysisCache::new(); + let calls = Cell::new(0); + let parse = |s: &str| { + calls.set(calls.get() + 1); + Some(s.to_owned()) + }; + let mut json = String::from("analysis-1"); + let (key, a) = cache.get(&json, parse).unwrap(); + let copy = json.clone(); + assert_ne!(json.as_ptr(), copy.as_ptr()); + let (same_key, same) = cache.get(©, parse).unwrap(); + assert_eq!(key, fnv(json.as_bytes())); + assert_eq!(key, same_key); + assert!(Arc::ptr_eq(&a, &same)); + assert_eq!(calls.get(), 1); + let ptr = json.as_ptr(); + json.replace_range(9..10, "2"); + assert_eq!(ptr, json.as_ptr()); + let (changed_key, changed) = cache.get(&json, parse).unwrap(); + assert_ne!(key, changed_key); + assert_eq!(&*changed, "analysis-2"); + assert_eq!(&*a, "analysis-1"); + assert_eq!(calls.get(), 2); + assert!(cache.get("invalid", |_| None).is_none()); + assert!(Arc::ptr_eq(&cache.get(&json, parse).unwrap().1, &changed)); + } + + #[test] + fn source_text_retention_is_bounded() { + let cache = AnalysisCache::new(); + for i in 0..30 { + cache.get(&i.to_string(), |s| Some(s.to_owned())).unwrap(); + assert!(cache.entries.get().unwrap().lock().unwrap().len() <= 9); + } + } +} diff --git a/crates/effects/src/audio_fx.rs b/crates/effects/src/audio_fx.rs index cd9153d66..2aab70309 100644 --- a/crates/effects/src/audio_fx.rs +++ b/crates/effects/src/audio_fx.rs @@ -206,6 +206,12 @@ pub fn reverses(id: &str) -> bool { id == "ec.audio.backwards" } +/// Does each output sample depend only on the input sample at the same time (no state), so the +/// mixer may run the effect in short pieces with parameters animated between them? +pub fn per_sample(id: &str) -> bool { + matches!(id, "ec.audio.stereomixer" | "ec.audio.backwards") +} + /// Seconds of input history the effect needs before a block to reach the state it would have /// had when processing continuously. pub fn preroll(id: &str, params: &Params) -> f64 { diff --git a/crates/effects/src/camera_tracker.rs b/crates/effects/src/camera_tracker.rs index ba7878abf..c58df3593 100644 --- a/crates/effects/src/camera_tracker.rs +++ b/crates/effects/src/camera_tracker.rs @@ -16,8 +16,9 @@ //! on it draws the solved points (as the viewer shows them) into the frame. The viewer overlay, target and the Create //! commands live in the engine and the UI. -use std::collections::HashMap; -use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::Arc; + +use crate::analysis_cache::AnalysisCache; use effectcraft_keyframe::Value; use effectcraft_project::ParamUi; @@ -122,50 +123,24 @@ pub fn solve_key(st: &SolveSettings) -> String { format!("{:?}|{hfov}|{:?}|{:?}{lens}", st.shot, st.method, st.deleted) } -fn fnv(bytes: &[u8]) -> u64 { - let mut h: u64 = 0xcbf2_9ce4_8422_2325; - for b in bytes { - h ^= *b as u64; - h = h.wrapping_mul(0x0100_0000_01b3); - } - h -} - -type Cache = Mutex>>; - -fn cached(c: &'static OnceLock>, json: &str, parse: impl Fn(&str) -> Option) -> Option> { - let key = fnv(json.as_bytes()); - let c = c.get_or_init(Default::default); - if let Some(a) = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) { - return Some(a.clone()); - } - let a = Arc::new(parse(json)?); - let mut m = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if m.len() > 8 { - m.clear(); - } - m.insert(key, a.clone()); - Some(a) -} - /// The stored feature tracks of an instance (`None` when not analysed). pub fn tracks(params: &Params) -> Option> { - static C: OnceLock> = OnceLock::new(); + static C: AnalysisCache = AnalysisCache::new(); let j = s(params, TRACKS); if j.is_empty() { return None; } - cached(&C, j, CameraTracks::from_json).filter(|t| !t.is_empty()) + C.get(j, CameraTracks::from_json).map(|(_, a)| a).filter(|t| !t.is_empty()) } /// The stored camera solve of an instance (`None` when not solved). pub fn solve(params: &Params) -> Option> { - static C: OnceLock> = OnceLock::new(); + static C: AnalysisCache = AnalysisCache::new(); let j = s(params, SOLVE); if j.is_empty() { return None; } - cached(&C, j, CameraSolve::from_json).filter(|t| !t.is_empty()) + C.get(j, CameraSolve::from_json).map(|(_, a)| a).filter(|t| !t.is_empty()) } /// Screen size (layer pixels) of a target drawn for a point at depth `z`, relative to the @@ -338,6 +313,27 @@ mod tests { assert!(!st.lens_distortion && !solve_key(&st).contains("lens")); } + #[test] + fn tracks_and_solve_follow_analysis_replacements() { + let mut t = CameraTracks { frames: 3, start: 1.0, ..Default::default() }; + let mut sol = CameraSolve { frames: vec![SolvedFrame::default()], start: 1.0, ..Default::default() }; + let mut ps = params_with(&sol, false); + ps.values.insert(TRACKS.into(), Value::Str(t.to_json())); + assert_eq!(*tracks(&ps).unwrap(), t); + assert_eq!(*solve(&ps).unwrap(), sol); + t.start = 2.0; + sol.start = 2.0; + ps.values.insert(TRACKS.into(), Value::Str(t.to_json())); + ps.values.insert(SOLVE.into(), Value::Str(sol.to_json())); + assert_eq!(*tracks(&ps).unwrap(), t); + assert_eq!(*solve(&ps).unwrap(), sol); + assert_eq!(*tracks(&ps.clone()).unwrap(), t); + ps.values.insert(TRACKS.into(), Value::Str("invalid".into())); + ps.values.insert(SOLVE.into(), Value::Str(String::new())); + assert!(tracks(&ps).is_none()); + assert!(solve(&ps).is_none()); + } + #[test] fn undistort_footage_remaps_through_the_solved_lens() { use effectcraft_track::camtrack::Distortion; diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 0be2b04f5..7fcb024bf 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -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 }; @@ -126,7 +128,8 @@ pub fn effects_markdown() -> String { s.push_str("# Effects\n\n"); s.push_str("\n\n"); - let params: usize = r.iter().map(|e| e.params.len()).sum(); + // The parameters the table's rows list: the shown ones, not the hidden bookkeeping ones. + let params: usize = r.iter().map(|e| e.params.iter().filter(|p| !matches!(p.ui, effectcraft_project::ParamUi::Hidden)).count()).sum(); let gpu = r.iter().filter(|e| e.gpu).count(); let float = r.iter().filter(|e| e.float).count(); let partial = r.iter().filter(|e| PARTIAL.iter().any(|(id, _)| *id == e.id)).count(); @@ -182,6 +185,20 @@ mod tests { assert!(have == want, "docs/effects.md is stale: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current"); } + /// The README quotes the number of effects (#2565): every count it gives is the registry's. + #[test] + fn readme_effect_counts_are_current() { + let readme = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../README.md")).unwrap_or_default(); + let words: Vec<&str> = readme.split_whitespace().collect(); + let counts: Vec<&str> = words + .windows(3) + .filter(|w| w[0].parse::().is_ok() && (w[1].starts_with("effects") || (w[1] == "built-in" && w[2].starts_with("effects")))) + .map(|w| w[0]) + .collect(); + let n = super::registry().len().to_string(); + assert!(!counts.is_empty() && counts.iter().all(|c| *c == n), "README.md says {counts:?} effects; the registry has {n}"); + } + #[test] fn cpu_only_controls_keep_effects_off_the_gpu() { use effectcraft_keyframe::Value; @@ -210,6 +227,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))])); diff --git a/crates/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs index 39a1c49b1..dc65c3255 100644 --- a/crates/effects/src/channel3d.rs +++ b/crates/effects/src/channel3d.rs @@ -7,6 +7,11 @@ //! pass for a precomp of a 3D comp. Without auxiliary channels they pass the layer through //! (EXtractoR still maps the layer's own R, G, B and A). //! +//! 3D Layer Instance (Create 3D Layer Instance's own effect, Layer ▸ Create) is not an aux +//! reader: it replaces the layer's pixels with a referenced layer's render — for a 3D model +//! layer, its render on its own through the comp's camera — so 2D effects can be applied to a +//! 3D layer's render. +//! //! Aux pixels are looked up nearest-neighbour (IDs must not be interpolated) at each buffer //! pixel's layer position, so render scale and layer padding are respected. @@ -359,7 +364,7 @@ pub(crate) fn selection_names(sel: &str) -> Vec { out } -/// Parse a selection: comma-separated names (optionally quoted, see [`selection_names`]) or +/// Parse a selection: comma-separated names (optionally quoted, see `selection_names`) or /// `` hashes / wildcards. pub fn selection_hashes(sel: &str, manifest: Option<&[(String, u32)]>) -> Vec { let mut out = vec![]; @@ -582,6 +587,19 @@ fn extractor(ctx: &EffectCtx, mut b: Buf) -> Buf { b } +/// A 2D copy of a referenced layer (Create 3D Layer Instance's solid): the layer's pixels +/// replaced with the referenced layer's render, alpha included — the copy is transparent +/// where the referenced layer isn't. A 3D model or primitive layer is rendered on its own +/// through the comp's camera, as After Effects renders 3D layers; another layer is read with +/// its masks and effects. With no layer chosen the effect passes the layer through, as a layer +/// parameter with nothing chosen does. +fn instance_3d(ctx: &EffectCtx, mut b: Buf) -> Buf { + if let Some(o) = ctx.layer_param("layer", true) { + b.img = crate::util::fit_layer(ctx, &b, &o, false); + } + b +} + pub fn specs() -> Vec { let text = |id: &'static str, name: &'static str, d: &str| p(id, name, Value::Str(d.into()), ParamUi::Text); vec![ @@ -682,388 +700,9 @@ pub fn specs() -> Vec { ], identifier, ), + spec("ec.3d.instance3d", "3D Layer Instance", vec![p("layer", "Layer", Value::Layer(None), ParamUi::Layer)], instance_3d), ] } #[cfg(test)] -mod tests { - use super::*; - use crate::{EffectEnv, EffectHost, LayerPixels, run_fx}; - use effectcraft_raster::channels3d::crypto_float; - - /// A 20×10 layer: left half object 1 at depth 100, right half object 2 at depth 500; the - /// top-right corner is background. Cryptomatte: "left" / "right". - struct AuxHost(Arc); - impl EffectHost for AuxHost { - fn layer(&self, _: u64, _: bool) -> Option { - None - } - fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { - None - } - fn aux(&self) -> Option> { - Some(self.0.clone()) - } - } - - fn scene() -> AuxHost { - let (w, h) = (20usize, 10usize); - let mut a = AuxChannels::new(w as u32, h as u32, 1.0); - let (mut z, mut id, mut r, mut g) = (vec![0.0; w * h], vec![0.0; w * h], vec![0.0; w * h], vec![0.0; w * h]); - let (hl, hr) = (crypto_hash("left"), crypto_hash("right")); - for y in 0..h { - for x in 0..w { - let i = y * w + x; - if x >= 15 && y < 3 { - z[i] = BACKGROUND_DEPTH; - } else if x < 10 { - z[i] = 100.0; - id[i] = 1.0; - r[i] = crypto_float(hl); - g[i] = 1.0; - } else { - z[i] = 500.0; - id[i] = 2.0; - r[i] = crypto_float(hr); - g[i] = 1.0; - } - } - } - a.channels = vec![("Z".into(), z), ("ObjectID".into(), id), ("CryptoObject00.R".into(), r), ("CryptoObject00.G".into(), g)]; - a.manifests = vec![("CryptoObject".into(), vec![("left".into(), hl), ("right".into(), hr)])]; - AuxHost(Arc::new(a)) - } - - fn img() -> Image { - Image::filled(20, 10, [0.5, 0.5, 0.5, 1.0]) - } - - fn run(id: &str, vals: &[(&str, Value)], host: &AuxHost) -> Image { - run_fx(id, vals, img(), 0.0, EffectEnv { host: Some(host), ..Default::default() }).img - } - - #[test] - fn passes_through_without_aux_channels() { - for s in specs() { - let out = run_fx(s.id, &[], img(), 0.0, EffectEnv::default()); - assert_eq!(out.img.data, img().data, "{}", s.id); - } - } - - #[test] - fn depth_matte_thresholds() { - let h = scene(); - let o = run("ec.3d.depthmatte", &[("depth", num(300.0))], &h); - assert_eq!(o.get(2, 5)[3], 0.0); - assert_eq!(o.get(12, 5)[3], 1.0); - let inv = run("ec.3d.depthmatte", &[("depth", num(300.0)), ("invert", Value::Bool(true))], &h); - assert_eq!(inv.get(2, 5)[3], 1.0); - assert_eq!(inv.get(12, 5)[3], 0.0); - // Feather: a ramp of 800 units centred on 300 → depth 100 at 0.25, 500 at 0.75. - let f = run("ec.3d.depthmatte", &[("depth", num(300.0)), ("feather", num(800.0))], &h); - assert!((f.get(2, 5)[3] - 0.25).abs() < 1e-5 && (f.get(12, 5)[3] - 0.75).abs() < 1e-5); - } - - #[test] - fn channel_extract_maps_depth_and_ids() { - let h = scene(); - let o = run("ec.3d.channelextract", &[("blackPoint", num(0.0)), ("whitePoint", num(1000.0))], &h); - assert!((o.get(2, 5)[0] - 0.1).abs() < 1e-6 && (o.get(12, 5)[0] - 0.5).abs() < 1e-6); - assert_eq!(o.get(17, 1)[0], 1.0); - let ids = run("ec.3d.channelextract", &[("channel", Value::Enum(1)), ("whitePoint", num(2.0))], &h); - assert_eq!(ids.get(2, 5)[0], 0.5); - // Invert Depth Map flips the ramp; without Clamp Output values run past 1. - let inv = run("ec.3d.channelextract", &[("blackPoint", num(0.0)), ("whitePoint", num(1000.0)), ("invertDepthMap", Value::Bool(true))], &h); - assert!((inv.get(2, 5)[0] - 0.9).abs() < 1e-6); - let raw = run("ec.3d.channelextract", &[("blackPoint", num(0.0)), ("whitePoint", num(100.0)), ("clampOutput", Value::Bool(false))], &h); - assert!(raw.get(12, 5)[0] > 1.5); - } - - #[test] - fn id_matte_and_identifier() { - let h = scene(); - let o = run("ec.3d.idmatte", &[("idSelection", num(2.0))], &h); - assert_eq!(o.get(2, 5)[3], 0.0); - assert_eq!(o.get(12, 5)[3], 1.0); - let l = run("ec.3d.identifier", &[("display", Value::Enum(1)), ("id", num(1.0))], &h); - assert_eq!(l.get(2, 5)[0], 1.0); - assert_eq!(l.get(12, 5)[0], 0.0); - let c = run("ec.3d.identifier", &[], &h); - assert_ne!(c.get(2, 5), c.get(12, 5)); - } - - /// The parallel aux image (rank planes resolved once) is bit-identical to the per-pixel - /// reduction over `crypto_ranks` it replaces. - #[test] - fn crypto_aux_image_matches_the_per_pixel_reduction() { - let (w, h) = (7u32, 5u32); - let n = (w * h) as usize; - let mut a = AuxChannels::new(w, h, 1.0); - let ids = [crypto_hash("cube"), crypto_hash("sphere"), crypto_hash("ring")].map(effectcraft_raster::channels3d::crypto_float); - for (k, ch) in ["00.R", "00.G", "00.B", "00.A", "01.R", "01.G", "01.B", "01.A"].iter().enumerate() { - let v: Vec = (0..n).map(|i| if k % 2 == 0 { ids[(i + k) % 3] } else { ((i * 7 + k * 3) % 11) as f32 / 10.0 }).collect(); - a.channels.push((format!("CryptoObject{ch}"), v)); - } - let sel = vec![crypto_hash("sphere"), crypto_hash("ring")]; - let img = crypto_aux_image(&a, "CryptoObject", &sel); - for i in 0..n { - let ranks = a.crypto_ranks("CryptoObject", i); - let m: f32 = ranks.iter().filter(|(id, c)| *c > 0.0 && sel.contains(&id.to_bits())).map(|(_, c)| *c).sum::().clamp(0.0, 1.0); - let mut c = [0.0f32; 3]; - for (id, cov) in &ranks { - let k = id_color(*id); - for j in 0..3 { - c[j] += k[j] * cov; - } - } - let want = [c[0], c[1], c[2], m]; - assert_eq!(img.data[i].map(f32::to_bits), want.map(f32::to_bits), "pixel {i}"); - } - } - - /// The lookup before #473, kept as the reference: the rank channels formatted and searched - /// by name for every pixel. - fn ranks_by_name(a: &AuxChannels, layer: &str, i: usize) -> Vec<(f32, f32)> { - let mut out = vec![]; - for k in 0.. { - let get = |c: &str| a.get(&format!("{layer}{k:02}.{c}")); - let (Some(r), Some(g)) = (get("R"), get("G")) else { break }; - out.push((r[i], g[i])); - if let (Some(b), Some(al)) = (get("B"), get("A")) { - out.push((b[i], al[i])); - } - } - out - } - - /// The reduction before #473 over those ranks: (ID colours, selection coverage). - fn reduce_by_name(a: &AuxChannels, layer: &str, sel: &[u32], i: usize) -> ([f32; 3], f32) { - let ranks = ranks_by_name(a, layer, i); - let m: f32 = ranks.iter().filter(|(id, c)| *c > 0.0 && sel.contains(&id.to_bits())).map(|(_, c)| *c).sum::().clamp(0.0, 1.0); - let mut c = [0.0f32; 3]; - for (id, cov) in &ranks { - let k = id_color(*id); - for j in 0..3 { - c[j] += k[j] * cov; - } - } - (c, m) - } - - /// A 6×2 Cryptomatte layer with awkward values: NaN, ±inf, negative and -0.0 coverage, NaN - /// and -0.0 IDs; rank 01 has no B/A pair, rank 02 is missing, so rank 03 (filled with values - /// that would change every pixel) must be ignored as before. - fn edge_aux(with_rank_after_gap: bool) -> AuxChannels { - let (w, h) = (6u32, 2u32); - let nan_payload = f32::from_bits(0x7fc0_1234); - let (hs, hc) = (crypto_float(crypto_hash("sphere")), crypto_float(crypto_hash("cube"))); - let cov = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.5, -0.0, 0.0, 0.25, 1.0, 2.0, f32::MIN_POSITIVE, nan_payload, 0.5]; - let ids = [hs, f32::NAN, hc, -0.0, 0.0, nan_payload, hs, hc, f32::NAN, hs, hc, -0.0]; - let rot = |v: &[f32; 12], k: usize| -> Vec { (0..12).map(|i| v[(i + k) % 12]).collect() }; - let mut a = AuxChannels::new(w, h, 1.0); - let mut push = |n: &str, v: Vec| a.channels.push((format!("CryptoObject{n}"), v)); - push("00.R", rot(&ids, 0)); - push("00.G", rot(&cov, 0)); - push("00.B", rot(&ids, 3)); - push("00.A", rot(&cov, 5)); - push("01.R", rot(&ids, 7)); - push("01.G", rot(&cov, 2)); - if with_rank_after_gap { - push("03.R", vec![hs; 12]); - push("03.G", vec![1.0; 12]); - push("03.B", vec![hc; 12]); - push("03.A", vec![1.0; 12]); - } - a - } - - /// The memo hands out exactly `id_color`'s bits for every rank and pixel of the edge values - /// (NaN IDs with and without a payload, -0.0 and 0.0 are keyed by their bits). - #[test] - fn id_color_memo_matches_id_color() { - let a = edge_aux(true); - let planes = a.crypto_rank_planes("CryptoObject"); - let mut memo = IdColorMemo::default(); - for i in 0..12 { - for (r, (id, _)) in planes.iter().enumerate() { - assert_eq!(memo.get(r, id[i]).map(f32::to_bits), id_color(id[i]).map(f32::to_bits), "pixel {i} rank {r}"); - } - } - } - - fn edge_selection() -> Vec { - vec![crypto_hash("sphere"), f32::NAN.to_bits(), 0x7fc0_1234, (-0.0f32).to_bits()] - } - - /// Edge values give bit-identical results to the lookup by name, the missing B/A pair is - /// skipped and a rank after a gap is ignored. - #[test] - fn crypto_aux_image_is_bit_exact_on_edge_values() { - let sel = edge_selection(); - let a = edge_aux(true); - assert_eq!(a.crypto_rank_planes("CryptoObject").len(), 3, "00.R/G, 00.B/A, 01.R/G; not 03 after the gap"); - let img = crypto_aux_image(&a, "CryptoObject", &sel); - let without_gap_rank = crypto_aux_image(&edge_aux(false), "CryptoObject", &sel); - for i in 0..12 { - let (c, m) = reduce_by_name(&a, "CryptoObject", &sel, i); - let want = [c[0], c[1], c[2], m]; - assert_eq!(img.data[i].map(f32::to_bits), want.map(f32::to_bits), "pixel {i}"); - assert_eq!(img.data[i].map(f32::to_bits), without_gap_rank.data[i].map(f32::to_bits), "pixel {i}: the rank after the gap was used"); - } - } - - /// Without the ID colours the matte (A) is bit-identical and the RGB is 0, on the edge values. - #[test] - fn crypto_aux_image_without_colors_keeps_the_matte() { - let a = edge_aux(true); - let sel = edge_selection(); - let full = crypto_aux_image(&a, "CryptoObject", &sel); - let matte = crypto_aux_image_with(&a, "CryptoObject", &sel, CryptoAux::MatteOnly); - for (f, m) in full.data.iter().zip(&matte.data) { - assert_eq!(f[3].to_bits(), m[3].to_bits()); - assert_eq!(m[..3], [0.0; 3]); - } - } - - /// Only the Colors display without Matte Only reads the ID colours. - #[test] - fn crypto_aux_for_display_reads_colors_only_for_the_colors_display() { - assert_eq!(CryptoAux::for_display(0, false), CryptoAux::ColorsAndMatte); - assert_eq!(CryptoAux::for_display(0, true), CryptoAux::MatteOnly); - for display in [1, 2, 3, u32::MAX] { - for matte_only in [false, true] { - assert_eq!(CryptoAux::for_display(display, matte_only), CryptoAux::MatteOnly, "display {display}"); - } - } - } - - /// The CPU effect on a 20×10 layer over the 6×2 edge aux: pixels inside the aux area match - /// the lookup by name bit for bit in every display mode, pixels outside it (no aux index) - /// get an empty matte and no ID colour. - #[test] - fn cryptomatte_cpu_path_is_bit_exact_inside_and_outside_the_aux_area() { - let a = edge_aux(true); - let host = AuxHost(Arc::new(a.clone())); - let names = "sphere, <7fc00000>, <7fc01234>, <80000000>"; - assert_eq!(selection_hashes(names, None), edge_selection()); - let src = img().data[0]; - for (display, matte_only) in [(0, false), (1, false), (2, false), (0, true)] { - let out = run( - "ec.3d.cryptomatte", - &[("selection", Value::Str(names.into())), ("display", Value::Enum(display)), ("matteOnly", Value::Bool(matte_only))], - &host, - ); - let expect = |c: [f32; 3], m: f32| -> [f32; 4] { - if matte_only { - return [m; 4]; - } - match display { - 0 => { - let c = c.map(|v| v * (1.0 - 0.5 * m) + 0.5 * m); - [c[0], c[1], c[2], 1.0] - } - 1 => src.map(|v| v * m), - _ => [src[0] * (1.0 - 0.5 * m) + 0.5 * m * src[3], src[1] * (1.0 - 0.5 * m) + 0.5 * m * src[3], src[2] * (1.0 - 0.5 * m), src[3]], - } - }; - for (x, y) in [(0u32, 0u32), (1, 0), (5, 0), (0, 1), (3, 1), (5, 1)] { - let (c, m) = reduce_by_name(&a, "CryptoObject", &edge_selection(), (y * 6 + x) as usize); - assert_eq!( - out.get(x as i64, y as i64).map(f32::to_bits), - expect(c, m).map(f32::to_bits), - "display {display} matteOnly {matte_only} at ({x}, {y})" - ); - } - for (x, y) in [(6u32, 0u32), (19, 0), (0, 2), (12, 9)] { - assert_eq!( - out.get(x as i64, y as i64).map(f32::to_bits), - expect([0.0; 3], 0.0).map(f32::to_bits), - "display {display} matteOnly {matte_only} outside the aux at ({x}, {y})" - ); - } - } - } - - #[test] - fn cryptomatte_selects_by_name_and_wildcard() { - let h = scene(); - let m = run("ec.3d.cryptomatte", &[("selection", Value::Str("right".into())), ("matteOnly", Value::Bool(true))], &h); - assert_eq!(m.get(2, 5)[3], 0.0); - assert_eq!(m.get(12, 5)[3], 1.0); - let w = run("ec.3d.cryptomatte", &[("selection", Value::Str("l*".into())), ("display", Value::Enum(1))], &h); - assert_eq!(w.get(2, 5)[3], 1.0); - assert_eq!(w.get(12, 5)[3], 0.0); - assert!(wild("*ight", "right") && wild("r*t", "right") && !wild("x*", "right")); - let hex = format!("<{:08x}>", crypto_hash("left")); - assert_eq!(selection_hashes(&hex, None), vec![crypto_hash("left")]); - } - - /// #473: a name with a comma in it (Blender allows `Green, Glossy`) is selected when it is - /// quoted or its comma escaped, instead of being cut into two names that match nothing. - #[test] - fn cryptomatte_selection_keeps_quoted_names_with_commas_whole() { - let manifest = vec![("Green, Glossy".to_string(), 7), ("Green".to_string(), 1), ("Glossy".to_string(), 2), ("ring".to_string(), 3)]; - let m = Some(manifest.as_slice()); - assert_eq!(selection_hashes(r#""Green, Glossy""#, m), vec![7]); - assert_eq!(selection_hashes("'Green, Glossy', ring", m), vec![7, 3]); - assert_eq!(selection_hashes(r"Green\, Glossy", m), vec![7]); - assert_eq!(selection_hashes(r#"ring , "Green, Glossy""#, m), vec![3, 7]); - // Without a manifest the whole name is hashed. - assert_eq!(selection_hashes(r#""Green, Glossy""#, None), vec![crypto_hash("Green, Glossy")]); - // Plain lists, apostrophes inside names and an unclosed quote still parse. - assert_eq!(selection_hashes("Green, Glossy", m), vec![1, 2]); - assert_eq!(selection_names("Bob's mat, ring"), vec!["Bob's mat", "ring"]); - assert_eq!(selection_names(r#""Green, Glossy"#), vec!["Green, Glossy"]); - assert_eq!(selection_names(r#"say \"hi\""#), vec![r#"say "hi""#]); - } - - #[test] - fn fog_and_depth_of_field_follow_depth() { - let h = scene(); - let f = run("ec.3d.fog3d", &[("fogColor", col(1.0, 0.0, 0.0)), ("fogEndDepth", num(1000.0)), ("scatteringDensity", num(0.0))], &h); - // Linear fog: 10 % at depth 100, 50 % at 500, full on the background. - assert!((f.get(2, 5)[0] - 0.55).abs() < 1e-4, "{:?}", f.get(2, 5)); - assert!((f.get(12, 5)[0] - 0.75).abs() < 1e-4); - assert!((f.get(17, 1)[0] - 1.0).abs() < 1e-4 && f.get(17, 1)[1] < 1e-4); - assert!((dof_radius(100.0, 100.0, 0.0, 10.0, 50.0)).abs() < 1e-6); - assert!((dof_radius(200.0, 100.0, 0.0, 10.0, 50.0) - 10.0).abs() < 1e-6); - assert_eq!(dof_radius(140.0, 100.0, 100.0, 10.0, 50.0), 0.0); - // A sharp edge at the far object blurs when focused near. - let mut pic = Image::new(20, 10); - for y in 0..10 { - for x in 0..20 { - let v = if x % 2 == 0 { 1.0 } else { 0.0 }; - pic.set(x, y, [v, v, v, 1.0]); - } - } - let env = EffectEnv { host: Some(&h), ..Default::default() }; - let d = run_fx("ec.3d.depthoffield", &[("focalPlane", num(100.0)), ("maximumRadius", num(4.0))], pic.clone(), 0.0, env).img; - assert_eq!(d.get(4, 5), pic.get(4, 5)); - assert!((d.get(12, 5)[0] - 0.5).abs() < 0.3 && d.get(12, 5) != pic.get(12, 5)); - } - - #[test] - fn extractor_maps_named_channels() { - let h = scene(); - let o = run("ec.3d.extractor", &[("red", Value::Str("Z".into())), ("whitePoint", num(1000.0))], &h); - assert!((o.get(2, 5)[0] - 0.1).abs() < 1e-6); - assert!((o.get(2, 5)[1] - 0.0005).abs() < 1e-6); - // Without aux: the layer's own channels can be swapped. - let s = run_fx("ec.3d.extractor", &[("red", Value::Str("A".into()))], Image::filled(2, 2, [0.2, 0.3, 0.4, 1.0]), 0.0, EffectEnv::default()); - assert_eq!(s.img.get(0, 0), [1.0, 0.3, 0.4, 1.0]); - } - - #[test] - fn channel_extract_antialias_averages_sub_pixels() { - // Depth at twice the layer resolution, alternating 0 / 1000 per aux column. - let mut a = AuxChannels::new(40, 20, 2.0); - a.channels = vec![("Z".into(), (0..800).map(|i| if i % 2 == 0 { 0.0 } else { 1000.0 }).collect())]; - let host = AuxHost(Arc::new(a)); - let vals = [("blackPoint", num(0.0)), ("whitePoint", num(1000.0))]; - let hard = run("ec.3d.channelextract", &vals, &host); - let v = hard.get(5, 5)[0]; - assert!(v == 0.0 || v == 1.0, "{v}"); - 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)); - } -} +mod tests; diff --git a/crates/effects/src/channel3d/tests.rs b/crates/effects/src/channel3d/tests.rs new file mode 100644 index 000000000..e69c2a6aa --- /dev/null +++ b/crates/effects/src/channel3d/tests.rs @@ -0,0 +1,401 @@ +use super::*; +use crate::{EffectEnv, EffectHost, LayerPixels, run_fx}; +use effectcraft_raster::channels3d::crypto_float; + +/// A 20×10 layer: left half object 1 at depth 100, right half object 2 at depth 500; the +/// top-right corner is background. Cryptomatte: "left" / "right". +struct AuxHost(Arc); +impl EffectHost for AuxHost { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn aux(&self) -> Option> { + Some(self.0.clone()) + } +} + +fn scene() -> AuxHost { + let (w, h) = (20usize, 10usize); + let mut a = AuxChannels::new(w as u32, h as u32, 1.0); + let (mut z, mut id, mut r, mut g) = (vec![0.0; w * h], vec![0.0; w * h], vec![0.0; w * h], vec![0.0; w * h]); + let (hl, hr) = (crypto_hash("left"), crypto_hash("right")); + for y in 0..h { + for x in 0..w { + let i = y * w + x; + if x >= 15 && y < 3 { + z[i] = BACKGROUND_DEPTH; + } else if x < 10 { + z[i] = 100.0; + id[i] = 1.0; + r[i] = crypto_float(hl); + g[i] = 1.0; + } else { + z[i] = 500.0; + id[i] = 2.0; + r[i] = crypto_float(hr); + g[i] = 1.0; + } + } + } + a.channels = vec![("Z".into(), z), ("ObjectID".into(), id), ("CryptoObject00.R".into(), r), ("CryptoObject00.G".into(), g)]; + a.manifests = vec![("CryptoObject".into(), vec![("left".into(), hl), ("right".into(), hr)])]; + AuxHost(Arc::new(a)) +} + +fn img() -> Image { + Image::filled(20, 10, [0.5, 0.5, 0.5, 1.0]) +} + +fn run(id: &str, vals: &[(&str, Value)], host: &AuxHost) -> Image { + run_fx(id, vals, img(), 0.0, EffectEnv { host: Some(host), ..Default::default() }).img +} + +#[test] +fn passes_through_without_aux_channels() { + for s in specs() { + let out = run_fx(s.id, &[], img(), 0.0, EffectEnv::default()); + assert_eq!(out.img.data, img().data, "{}", s.id); + } +} + +#[test] +fn depth_matte_thresholds() { + let h = scene(); + let o = run("ec.3d.depthmatte", &[("depth", num(300.0))], &h); + assert_eq!(o.get(2, 5)[3], 0.0); + assert_eq!(o.get(12, 5)[3], 1.0); + let inv = run("ec.3d.depthmatte", &[("depth", num(300.0)), ("invert", Value::Bool(true))], &h); + assert_eq!(inv.get(2, 5)[3], 1.0); + assert_eq!(inv.get(12, 5)[3], 0.0); + // Feather: a ramp of 800 units centred on 300 → depth 100 at 0.25, 500 at 0.75. + let f = run("ec.3d.depthmatte", &[("depth", num(300.0)), ("feather", num(800.0))], &h); + assert!((f.get(2, 5)[3] - 0.25).abs() < 1e-5 && (f.get(12, 5)[3] - 0.75).abs() < 1e-5); +} + +#[test] +fn channel_extract_maps_depth_and_ids() { + let h = scene(); + let o = run("ec.3d.channelextract", &[("blackPoint", num(0.0)), ("whitePoint", num(1000.0))], &h); + assert!((o.get(2, 5)[0] - 0.1).abs() < 1e-6 && (o.get(12, 5)[0] - 0.5).abs() < 1e-6); + assert_eq!(o.get(17, 1)[0], 1.0); + let ids = run("ec.3d.channelextract", &[("channel", Value::Enum(1)), ("whitePoint", num(2.0))], &h); + assert_eq!(ids.get(2, 5)[0], 0.5); + // Invert Depth Map flips the ramp; without Clamp Output values run past 1. + let inv = run("ec.3d.channelextract", &[("blackPoint", num(0.0)), ("whitePoint", num(1000.0)), ("invertDepthMap", Value::Bool(true))], &h); + assert!((inv.get(2, 5)[0] - 0.9).abs() < 1e-6); + let raw = run("ec.3d.channelextract", &[("blackPoint", num(0.0)), ("whitePoint", num(100.0)), ("clampOutput", Value::Bool(false))], &h); + assert!(raw.get(12, 5)[0] > 1.5); +} + +#[test] +fn id_matte_and_identifier() { + let h = scene(); + let o = run("ec.3d.idmatte", &[("idSelection", num(2.0))], &h); + assert_eq!(o.get(2, 5)[3], 0.0); + assert_eq!(o.get(12, 5)[3], 1.0); + let l = run("ec.3d.identifier", &[("display", Value::Enum(1)), ("id", num(1.0))], &h); + assert_eq!(l.get(2, 5)[0], 1.0); + assert_eq!(l.get(12, 5)[0], 0.0); + let c = run("ec.3d.identifier", &[], &h); + assert_ne!(c.get(2, 5), c.get(12, 5)); +} + +/// The parallel aux image (rank planes resolved once) is bit-identical to the per-pixel +/// reduction over `crypto_ranks` it replaces. +#[test] +fn crypto_aux_image_matches_the_per_pixel_reduction() { + let (w, h) = (7u32, 5u32); + let n = (w * h) as usize; + let mut a = AuxChannels::new(w, h, 1.0); + let ids = [crypto_hash("cube"), crypto_hash("sphere"), crypto_hash("ring")].map(effectcraft_raster::channels3d::crypto_float); + for (k, ch) in ["00.R", "00.G", "00.B", "00.A", "01.R", "01.G", "01.B", "01.A"].iter().enumerate() { + let v: Vec = (0..n).map(|i| if k % 2 == 0 { ids[(i + k) % 3] } else { ((i * 7 + k * 3) % 11) as f32 / 10.0 }).collect(); + a.channels.push((format!("CryptoObject{ch}"), v)); + } + let sel = vec![crypto_hash("sphere"), crypto_hash("ring")]; + let img = crypto_aux_image(&a, "CryptoObject", &sel); + for i in 0..n { + let ranks = a.crypto_ranks("CryptoObject", i); + let m: f32 = ranks.iter().filter(|(id, c)| *c > 0.0 && sel.contains(&id.to_bits())).map(|(_, c)| *c).sum::().clamp(0.0, 1.0); + let mut c = [0.0f32; 3]; + for (id, cov) in &ranks { + let k = id_color(*id); + for j in 0..3 { + c[j] += k[j] * cov; + } + } + let want = [c[0], c[1], c[2], m]; + assert_eq!(img.data[i].map(f32::to_bits), want.map(f32::to_bits), "pixel {i}"); + } +} + +/// The lookup before #473, kept as the reference: the rank channels formatted and searched +/// by name for every pixel. +fn ranks_by_name(a: &AuxChannels, layer: &str, i: usize) -> Vec<(f32, f32)> { + let mut out = vec![]; + for k in 0.. { + let get = |c: &str| a.get(&format!("{layer}{k:02}.{c}")); + let (Some(r), Some(g)) = (get("R"), get("G")) else { break }; + out.push((r[i], g[i])); + if let (Some(b), Some(al)) = (get("B"), get("A")) { + out.push((b[i], al[i])); + } + } + out +} + +/// The reduction before #473 over those ranks: (ID colours, selection coverage). +fn reduce_by_name(a: &AuxChannels, layer: &str, sel: &[u32], i: usize) -> ([f32; 3], f32) { + let ranks = ranks_by_name(a, layer, i); + let m: f32 = ranks.iter().filter(|(id, c)| *c > 0.0 && sel.contains(&id.to_bits())).map(|(_, c)| *c).sum::().clamp(0.0, 1.0); + let mut c = [0.0f32; 3]; + for (id, cov) in &ranks { + let k = id_color(*id); + for j in 0..3 { + c[j] += k[j] * cov; + } + } + (c, m) +} + +/// A 6×2 Cryptomatte layer with awkward values: NaN, ±inf, negative and -0.0 coverage, NaN +/// and -0.0 IDs; rank 01 has no B/A pair, rank 02 is missing, so rank 03 (filled with values +/// that would change every pixel) must be ignored as before. +fn edge_aux(with_rank_after_gap: bool) -> AuxChannels { + let (w, h) = (6u32, 2u32); + let nan_payload = f32::from_bits(0x7fc0_1234); + let (hs, hc) = (crypto_float(crypto_hash("sphere")), crypto_float(crypto_hash("cube"))); + let cov = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.5, -0.0, 0.0, 0.25, 1.0, 2.0, f32::MIN_POSITIVE, nan_payload, 0.5]; + let ids = [hs, f32::NAN, hc, -0.0, 0.0, nan_payload, hs, hc, f32::NAN, hs, hc, -0.0]; + let rot = |v: &[f32; 12], k: usize| -> Vec { (0..12).map(|i| v[(i + k) % 12]).collect() }; + let mut a = AuxChannels::new(w, h, 1.0); + let mut push = |n: &str, v: Vec| a.channels.push((format!("CryptoObject{n}"), v)); + push("00.R", rot(&ids, 0)); + push("00.G", rot(&cov, 0)); + push("00.B", rot(&ids, 3)); + push("00.A", rot(&cov, 5)); + push("01.R", rot(&ids, 7)); + push("01.G", rot(&cov, 2)); + if with_rank_after_gap { + push("03.R", vec![hs; 12]); + push("03.G", vec![1.0; 12]); + push("03.B", vec![hc; 12]); + push("03.A", vec![1.0; 12]); + } + a +} + +/// The memo hands out exactly `id_color`'s bits for every rank and pixel of the edge values +/// (NaN IDs with and without a payload, -0.0 and 0.0 are keyed by their bits). +#[test] +fn id_color_memo_matches_id_color() { + let a = edge_aux(true); + let planes = a.crypto_rank_planes("CryptoObject"); + let mut memo = IdColorMemo::default(); + for i in 0..12 { + for (r, (id, _)) in planes.iter().enumerate() { + assert_eq!(memo.get(r, id[i]).map(f32::to_bits), id_color(id[i]).map(f32::to_bits), "pixel {i} rank {r}"); + } + } +} + +fn edge_selection() -> Vec { + vec![crypto_hash("sphere"), f32::NAN.to_bits(), 0x7fc0_1234, (-0.0f32).to_bits()] +} + +/// Edge values give bit-identical results to the lookup by name, the missing B/A pair is +/// skipped and a rank after a gap is ignored. +#[test] +fn crypto_aux_image_is_bit_exact_on_edge_values() { + let sel = edge_selection(); + let a = edge_aux(true); + assert_eq!(a.crypto_rank_planes("CryptoObject").len(), 3, "00.R/G, 00.B/A, 01.R/G; not 03 after the gap"); + let img = crypto_aux_image(&a, "CryptoObject", &sel); + let without_gap_rank = crypto_aux_image(&edge_aux(false), "CryptoObject", &sel); + for i in 0..12 { + let (c, m) = reduce_by_name(&a, "CryptoObject", &sel, i); + let want = [c[0], c[1], c[2], m]; + assert_eq!(img.data[i].map(f32::to_bits), want.map(f32::to_bits), "pixel {i}"); + assert_eq!(img.data[i].map(f32::to_bits), without_gap_rank.data[i].map(f32::to_bits), "pixel {i}: the rank after the gap was used"); + } +} + +/// Without the ID colours the matte (A) is bit-identical and the RGB is 0, on the edge values. +#[test] +fn crypto_aux_image_without_colors_keeps_the_matte() { + let a = edge_aux(true); + let sel = edge_selection(); + let full = crypto_aux_image(&a, "CryptoObject", &sel); + let matte = crypto_aux_image_with(&a, "CryptoObject", &sel, CryptoAux::MatteOnly); + for (f, m) in full.data.iter().zip(&matte.data) { + assert_eq!(f[3].to_bits(), m[3].to_bits()); + assert_eq!(m[..3], [0.0; 3]); + } +} + +/// Only the Colors display without Matte Only reads the ID colours. +#[test] +fn crypto_aux_for_display_reads_colors_only_for_the_colors_display() { + assert_eq!(CryptoAux::for_display(0, false), CryptoAux::ColorsAndMatte); + assert_eq!(CryptoAux::for_display(0, true), CryptoAux::MatteOnly); + for display in [1, 2, 3, u32::MAX] { + for matte_only in [false, true] { + assert_eq!(CryptoAux::for_display(display, matte_only), CryptoAux::MatteOnly, "display {display}"); + } + } +} + +/// The CPU effect on a 20×10 layer over the 6×2 edge aux: pixels inside the aux area match +/// the lookup by name bit for bit in every display mode, pixels outside it (no aux index) +/// get an empty matte and no ID colour. +#[test] +fn cryptomatte_cpu_path_is_bit_exact_inside_and_outside_the_aux_area() { + let a = edge_aux(true); + let host = AuxHost(Arc::new(a.clone())); + let names = "sphere, <7fc00000>, <7fc01234>, <80000000>"; + assert_eq!(selection_hashes(names, None), edge_selection()); + let src = img().data[0]; + for (display, matte_only) in [(0, false), (1, false), (2, false), (0, true)] { + let out = run( + "ec.3d.cryptomatte", + &[("selection", Value::Str(names.into())), ("display", Value::Enum(display)), ("matteOnly", Value::Bool(matte_only))], + &host, + ); + let expect = |c: [f32; 3], m: f32| -> [f32; 4] { + if matte_only { + return [m; 4]; + } + match display { + 0 => { + let c = c.map(|v| v * (1.0 - 0.5 * m) + 0.5 * m); + [c[0], c[1], c[2], 1.0] + } + 1 => src.map(|v| v * m), + _ => [src[0] * (1.0 - 0.5 * m) + 0.5 * m * src[3], src[1] * (1.0 - 0.5 * m) + 0.5 * m * src[3], src[2] * (1.0 - 0.5 * m), src[3]], + } + }; + for (x, y) in [(0u32, 0u32), (1, 0), (5, 0), (0, 1), (3, 1), (5, 1)] { + let (c, m) = reduce_by_name(&a, "CryptoObject", &edge_selection(), (y * 6 + x) as usize); + assert_eq!(out.get(x as i64, y as i64).map(f32::to_bits), expect(c, m).map(f32::to_bits), "display {display} matteOnly {matte_only} at ({x}, {y})"); + } + for (x, y) in [(6u32, 0u32), (19, 0), (0, 2), (12, 9)] { + assert_eq!( + out.get(x as i64, y as i64).map(f32::to_bits), + expect([0.0; 3], 0.0).map(f32::to_bits), + "display {display} matteOnly {matte_only} outside the aux at ({x}, {y})" + ); + } + } +} + +#[test] +fn cryptomatte_selects_by_name_and_wildcard() { + let h = scene(); + let m = run("ec.3d.cryptomatte", &[("selection", Value::Str("right".into())), ("matteOnly", Value::Bool(true))], &h); + assert_eq!(m.get(2, 5)[3], 0.0); + assert_eq!(m.get(12, 5)[3], 1.0); + let w = run("ec.3d.cryptomatte", &[("selection", Value::Str("l*".into())), ("display", Value::Enum(1))], &h); + assert_eq!(w.get(2, 5)[3], 1.0); + assert_eq!(w.get(12, 5)[3], 0.0); + assert!(wild("*ight", "right") && wild("r*t", "right") && !wild("x*", "right")); + let hex = format!("<{:08x}>", crypto_hash("left")); + assert_eq!(selection_hashes(&hex, None), vec![crypto_hash("left")]); +} + +/// #473: a name with a comma in it (Blender allows `Green, Glossy`) is selected when it is +/// quoted or its comma escaped, instead of being cut into two names that match nothing. +#[test] +fn cryptomatte_selection_keeps_quoted_names_with_commas_whole() { + let manifest = vec![("Green, Glossy".to_string(), 7), ("Green".to_string(), 1), ("Glossy".to_string(), 2), ("ring".to_string(), 3)]; + let m = Some(manifest.as_slice()); + assert_eq!(selection_hashes(r#""Green, Glossy""#, m), vec![7]); + assert_eq!(selection_hashes("'Green, Glossy', ring", m), vec![7, 3]); + assert_eq!(selection_hashes(r"Green\, Glossy", m), vec![7]); + assert_eq!(selection_hashes(r#"ring , "Green, Glossy""#, m), vec![3, 7]); + // Without a manifest the whole name is hashed. + assert_eq!(selection_hashes(r#""Green, Glossy""#, None), vec![crypto_hash("Green, Glossy")]); + // Plain lists, apostrophes inside names and an unclosed quote still parse. + assert_eq!(selection_hashes("Green, Glossy", m), vec![1, 2]); + assert_eq!(selection_names("Bob's mat, ring"), vec!["Bob's mat", "ring"]); + assert_eq!(selection_names(r#""Green, Glossy"#), vec!["Green, Glossy"]); + assert_eq!(selection_names(r#"say \"hi\""#), vec![r#"say "hi""#]); +} + +#[test] +fn fog_and_depth_of_field_follow_depth() { + let h = scene(); + let f = run("ec.3d.fog3d", &[("fogColor", col(1.0, 0.0, 0.0)), ("fogEndDepth", num(1000.0)), ("scatteringDensity", num(0.0))], &h); + // Linear fog: 10 % at depth 100, 50 % at 500, full on the background. + assert!((f.get(2, 5)[0] - 0.55).abs() < 1e-4, "{:?}", f.get(2, 5)); + assert!((f.get(12, 5)[0] - 0.75).abs() < 1e-4); + assert!((f.get(17, 1)[0] - 1.0).abs() < 1e-4 && f.get(17, 1)[1] < 1e-4); + assert!((dof_radius(100.0, 100.0, 0.0, 10.0, 50.0)).abs() < 1e-6); + assert!((dof_radius(200.0, 100.0, 0.0, 10.0, 50.0) - 10.0).abs() < 1e-6); + assert_eq!(dof_radius(140.0, 100.0, 100.0, 10.0, 50.0), 0.0); + // A sharp edge at the far object blurs when focused near. + let mut pic = Image::new(20, 10); + for y in 0..10 { + for x in 0..20 { + let v = if x % 2 == 0 { 1.0 } else { 0.0 }; + pic.set(x, y, [v, v, v, 1.0]); + } + } + let env = EffectEnv { host: Some(&h), ..Default::default() }; + let d = run_fx("ec.3d.depthoffield", &[("focalPlane", num(100.0)), ("maximumRadius", num(4.0))], pic.clone(), 0.0, env).img; + assert_eq!(d.get(4, 5), pic.get(4, 5)); + assert!((d.get(12, 5)[0] - 0.5).abs() < 0.3 && d.get(12, 5) != pic.get(12, 5)); +} + +#[test] +fn extractor_maps_named_channels() { + let h = scene(); + let o = run("ec.3d.extractor", &[("red", Value::Str("Z".into())), ("whitePoint", num(1000.0))], &h); + assert!((o.get(2, 5)[0] - 0.1).abs() < 1e-6); + assert!((o.get(2, 5)[1] - 0.0005).abs() < 1e-6); + // Without aux: the layer's own channels can be swapped. + let s = run_fx("ec.3d.extractor", &[("red", Value::Str("A".into()))], Image::filled(2, 2, [0.2, 0.3, 0.4, 1.0]), 0.0, EffectEnv::default()); + assert_eq!(s.img.get(0, 0), [1.0, 0.3, 0.4, 1.0]); +} + +#[test] +fn channel_extract_antialias_averages_sub_pixels() { + // Depth at twice the layer resolution, alternating 0 / 1000 per aux column. + let mut a = AuxChannels::new(40, 20, 2.0); + a.channels = vec![("Z".into(), (0..800).map(|i| if i % 2 == 0 { 0.0 } else { 1000.0 }).collect())]; + let host = AuxHost(Arc::new(a)); + let vals = [("blackPoint", num(0.0)), ("whitePoint", num(1000.0))]; + let hard = run("ec.3d.channelextract", &vals, &host); + let v = hard.get(5, 5)[0]; + assert!(v == 0.0 || v == 1.0, "{v}"); + let soft = run("ec.3d.channelextract", &[vals.as_slice(), &[("antialias", Value::Bool(true))]].concat(), &host); + assert!((soft.get(5, 5)[0] - 0.5).abs() < 1e-5, "{:?}", soft.get(5, 5)); +} + +#[test] +fn instance3d_replaces_the_pixels_with_the_referenced_layer() { + // Create 3D Layer Instance's copy: the chosen layer's render takes the place of the layer's + // own pixels, alpha included — the copy is transparent where the referenced layer isn't — + // and with no layer chosen the layer passes through. + struct Host(Image); + impl EffectHost for Host { + fn layer(&self, id: u64, _: bool) -> Option { + (id == 7).then(|| LayerPixels { buf: Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 }, size: [self.0.width as f64, self.0.height as f64] }) + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + } + let solid = || Image::filled(4, 4, [1.0, 1.0, 1.0, 1.0]); + let mut render = Image::new(4, 4); + render.set(1, 1, [1.0, 0.0, 0.0, 1.0]); + let env = EffectEnv { host: Some(&Host(render)), ..Default::default() }; + let out = run_fx("ec.3d.instance3d", &[("layer", Value::Layer(Some(7)))], solid(), 0.0, env); + assert_eq!(out.img.get(1, 1), [1.0, 0.0, 0.0, 1.0], "the referenced layer's pixel"); + assert_eq!(out.img.get(3, 3), [0.0; 4], "transparent where the referenced layer isn't"); + // No layer chosen: the layer's own pixels. + let out = run_fx("ec.3d.instance3d", &[], solid(), 0.0, EffectEnv::default()); + assert_eq!(out.img.data, solid().data); +} diff --git a/crates/effects/src/color2.rs b/crates/effects/src/color2.rs index 9547fd775..25203cd7a 100644 --- a/crates/effects/src/color2.rs +++ b/crates/effects/src/color2.rs @@ -1129,347 +1129,4 @@ pub fn specs() -> Vec { } #[cfg(test)] -mod tests { - use super::*; - use crate::{Params, find}; - - fn run(id: &str, img: &Image, over: &[(&str, Value)]) -> Image { - let s = find(id).unwrap(); - let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; - for (k, v) in over { - params.values.insert(k.to_string(), v.clone()); - } - let ctx = - EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env: Default::default() }; - (s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img - } - - fn ramp() -> Image { - let mut img = Image::new(16, 8); - for y in 0..8 { - for x in 0..16 { - let v = x as f32 / 15.0; - let a = if y == 0 { 0.5 } else { 1.0 }; - img.set(x, y, [v * a, (1.0 - v) * a, 0.3 * a, a]); - } - } - img - } - - fn close(a: &Image, b: &Image, eps: f32) -> bool { - a.data.iter().zip(&b.data).all(|(p, q)| (0..4).all(|i| (p[i] - q[i]).abs() <= eps)) - } - - #[test] - fn curve_parsing() { - assert!(Curve::parse("0,0 1,1").is_none()); - assert!(Curve::parse("garbage").is_none()); - let c = Curve::parse("1,1; 0,0 0.5,0.7").unwrap(); - assert_eq!(c.xs, vec![0.0, 0.5, 1.0]); - assert!((c.eval(0.5) - 0.7).abs() < 1e-3); - // Monotone: never decreases. - let mut last = -1.0; - for i in 0..=100 { - let v = c.eval(i as f32 / 100.0); - assert!(v >= last - 1e-6); - last = v; - } - } - - #[test] - fn curves_identity_is_identity() { - let img = ramp(); - assert_eq!(run("ec.color.curves", &img, &[]), img); - let explicit = run("ec.color.curves", &img, &[("rgb", Value::Str("0,0 0.25,0.25 1,1".into()))]); - assert!(close(&explicit, &img, 1e-4)); - } - - #[test] - fn curves_midpoint_brightens() { - let img = Image::filled(4, 4, [0.5, 0.5, 0.5, 1.0]); - let out = run("ec.color.curves", &img, &[("rgb", Value::Str("0,0 0.5,0.75 1,1".into()))]); - assert!((out.data[0][0] - 0.75).abs() < 1e-3); - let red = run("ec.color.curves", &img, &[("red", Value::Str("0,1 1,1".into()))]); - assert!((red.data[0][0] - 1.0).abs() < 1e-4 && (red.data[0][1] - 0.5).abs() < 1e-6); - } - - /// After Effects 2026, 32 bpc project, Levels (Individual Controls) with RGB Input Black 0.1, - /// Input White 0.9, Gamma 1.5, Output Black 0.05, Output White 0.95, Red Gamma 0.8, Green - /// Input White 0.8, Blue Output Black 0.2, both clips "Off for 32 bpc Color", on grey steps: - /// the channel controls run first, nothing clips (8-bit captures clamp the results). - #[test] - fn levels_ic_matches_after_effects_in_32_bpc() { - let steps = [0, 16, 32, 64, 96, 128, 160, 192, 224, 255]; - let ae = [ - [0, 0, 70], - [0, 0, 88], - [0, 52, 104], - [61, 108, 132], - [102, 150, 158], - [138, 187, 181], - [171, 220, 203], - [203, 250, 223], - [233, 255, 243], - [255, 255, 255], - ]; - let mut img = Image::new(10, 1); - for (x, v) in steps.iter().enumerate() { - let v = *v as f32 / 255.0; - img.set(x as u32, 0, [v, v, v, 1.0]); - } - let vals = [ - ("rgb/rgbInBlack", Value::Scalar(0.1)), - ("rgb/rgbInWhite", Value::Scalar(0.9)), - ("rgb/rgbGamma", Value::Scalar(1.5)), - ("rgb/rgbOutBlack", Value::Scalar(0.05)), - ("rgb/rgbOutWhite", Value::Scalar(0.95)), - ("red/redGamma", Value::Scalar(0.8)), - ("green/greenInWhite", Value::Scalar(0.8)), - ("blue/blueOutBlack", Value::Scalar(0.2)), - ]; - let s = find("ec.color.levelsic").unwrap(); - let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; - for (k, v) in &vals { - params.values.insert(k.to_string(), v.clone()); - } - let render = |float_depth: bool| { - let env = crate::EffectEnv { float_depth, ..Default::default() }; - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [10.0, 1.0], seed: 1, adjustment: false, env }; - (s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img - }; - let out = render(true); - for (x, want) in ae.iter().enumerate() { - let p = out.get(x as i64, 0); - for c in 0..3 { - let got = p[c].clamp(0.0, 1.0) * 255.0; - assert!((got - want[c] as f32).abs() <= 2.0, "step {} channel {c}: {got} vs AE {}", steps[x], want[c]); - } - } - // In 8 / 16 bpc "Off for 32 bpc Color" clips: black stops at Output Black. - let clipped = render(false); - assert!((clipped.get(0, 0)[0] - 0.05).abs() < 1e-5, "{:?}", clipped.get(0, 0)); - } - - /// After Effects 2026 (cases 962 / 963): Curves through RGB (0,0) (125,199) - /// (255,255) and Red (0,0) (128,44) (255,255) on grey steps 0..255, 8-bit results. - #[test] - fn curves_follow_a_natural_spline_as_in_after_effects() { - let steps = [0u8, 16, 32, 64, 96, 128, 160, 192, 224, 255]; - let mut img = Image::new(10, 1); - for (x, v) in steps.iter().enumerate() { - let v = *v as f32 / 255.0; - img.set(x as u32, 0, [v, v, v, 1.0]); - } - let p = |pts: &[(f32, f32)]| Value::Str(pts.iter().map(|(x, y)| format!("{},{}", x / 255.0, y / 255.0)).collect::>().join(" ")); - let rgb = run("ec.color.curves", &img, &[("rgb", p(&[(0.0, 0.0), (125.0, 199.0), (255.0, 255.0)]))]); - let red = run("ec.color.curves", &img, &[("red", p(&[(0.0, 0.0), (128.0, 44.0), (255.0, 255.0)]))]); - let ae_rgb = [0, 30, 59, 115, 164, 202, 227, 242, 250, 255]; - let ae_red = [0, 0, 1, 6, 19, 44, 83, 135, 194, 255]; - for x in 0..10 { - let g = (rgb.get(x as i64, 0)[1] * 255.0).round() as i32; - let r = (red.get(x as i64, 0)[0] * 255.0).round() as i32; - assert!((g - ae_rgb[x]).abs() <= 1, "RGB step {}: {g} vs AE {}", steps[x], ae_rgb[x]); - assert!((r - ae_red[x]).abs() <= 1, "Red step {}: {r} vs AE {}", steps[x], ae_red[x]); - } - } - - #[test] - fn levels_ic_identity_and_red_only() { - let img = ramp(); - assert_eq!(run("ec.color.levelsic", &img, &[]), img); - let out = run("ec.color.levelsic", &img, &[("red/redOutWhite", num(0.5))]); - let p = out.get(15, 3); - assert!((p[0] - 0.5).abs() < 1e-4 && (p[1] - 0.0).abs() < 1e-4); - } - - #[test] - fn auto_levels_stretches_low_contrast() { - let mut img = Image::new(32, 4); - for y in 0..4 { - for x in 0..32 { - let v = 0.4 + 0.2 * x as f32 / 31.0; - img.set(x, y, [v, v, v, 1.0]); - } - } - let out = run("ec.color.autolevels", &img, &[("blackClip", num(0.0)), ("whiteClip", num(0.0))]); - let lo = out.data.iter().map(|p| p[0]).fold(1.0, f32::min); - let hi = out.data.iter().map(|p| p[0]).fold(0.0, f32::max); - assert!(lo < 0.02 && hi > 0.98, "{lo} {hi}"); - let ac = run("ec.color.autocontrast", &img, &[("blackClip", num(0.0)), ("whiteClip", num(0.0))]); - assert!(ac.data.iter().map(|p| p[0]).fold(0.0, f32::max) > 0.98); - } - - #[test] - fn auto_color_neutralises_cast() { - let mut img = Image::new(16, 4); - for y in 0..4 { - for x in 0..16 { - let v = x as f32 / 15.0; - img.set(x, y, [v * 0.6 + 0.1, v * 0.9, v * 0.5, 1.0]); - } - } - let out = run("ec.color.autocolor", &img, &[]); - let p = out.get(8, 1); - assert!((p[0] - p[1]).abs() < 0.08 && (p[1] - p[2]).abs() < 0.08, "{p:?}"); - } - - #[test] - fn equalize_flattens_histogram() { - let mut img = Image::new(64, 1); - for x in 0..64 { - let v = if x < 48 { 0.1 + x as f32 * 0.001 } else { 0.9 }; - img.set(x, 0, [v, v, v, 1.0]); - } - let out = run("ec.color.equalize", &img, &[]); - // The crowded dark values are spread across the range. - assert!(out.get(47, 0)[0] > 0.6, "{}", out.get(47, 0)[0]); - assert!(out.get(0, 0)[0] < 0.1); - assert_eq!(run("ec.color.equalize", &img, &[("amount", num(0.0))]), img); - } - - #[test] - fn selective_color_zero_is_identity_and_reds_respond() { - let img = ramp(); - assert_eq!(run("ec.color.selectivecolor", &img, &[]), img); - let red = Image::filled(2, 2, [1.0, 0.1, 0.1, 1.0]); - let out = run("ec.color.selectivecolor", &red, &[("details/reds/redsCyan", num(100.0)), ("method", Value::Enum(1))]); - assert!(out.data[0][0] < 0.3, "{:?}", out.data[0]); - let blue = Image::filled(2, 2, [0.1, 0.1, 1.0, 1.0]); - assert_eq!(run("ec.color.selectivecolor", &blue, &[("details/reds/redsCyan", num(100.0))]), blue); - } - - #[test] - fn color_offset_full_turn_is_identity() { - let img = ramp(); - let out = run("ec.color.cccoloroffset", &img, &[("redPhase", num(360.0)), ("greenPhase", num(720.0))]); - assert_eq!(out, img); - let out = run("ec.color.cccoloroffset", &img, &[("redPhase", num(180.0))]); - assert!((out.get(0, 3)[0] - 0.5).abs() < 1e-4); - } - - #[test] - fn broadcast_reduces_unsafe_color() { - let img = Image::filled(2, 2, [1.0, 1.0, 0.0, 1.0]); - let out = run("ec.color.broadcast", &img, &[]); - assert!(out.data[0][0] < 1.0); - let key = run("ec.color.broadcast", &img, &[("howToMakeSafe", Value::Enum(2))]); - assert_eq!(key.data[0][3], 0.0); - let gray = Image::filled(2, 2, [0.5, 0.5, 0.5, 1.0]); - assert_eq!(run("ec.color.broadcast", &gray, &[]), gray); - } - - #[test] - fn color_balance_hls_zero_identity_and_hue_shift() { - let img = ramp(); - assert_eq!(run("ec.color.colorbalancehls", &img, &[]), img); - let red = Image::filled(1, 1, [1.0, 0.0, 0.0, 1.0]); - let out = run("ec.color.colorbalancehls", &red, &[("hue", num(120.0))]); - assert!(out.data[0][1] > 0.99 && out.data[0][0] < 0.01); - } - - #[test] - fn color_link_fills_with_average_keeping_alpha() { - let mut img = Image::new(2, 1); - img.set(0, 0, [1.0, 0.0, 0.0, 1.0]); - img.set(1, 0, [0.0, 0.0, 0.5, 0.5]); - let out = run("ec.color.colorlink", &img, &[]); - assert!((out.data[1][3] - 0.5).abs() < 1e-5); - let (c, _) = unpremul(out.data[0]); - assert!((c[0] - 2.0 / 3.0).abs() < 1e-3 && (c[2] - 1.0 / 3.0).abs() < 1e-3, "{c:?}"); - } - - #[test] - fn cc_kernel_identity_and_toner_duotone() { - let img = ramp(); - assert_eq!(run("ec.color.cckernel", &img, &[]), img); - let g = Image::filled(1, 1, [0.5, 0.5, 0.5, 1.0]); - let t = run("ec.color.cctoner", &g, &[("tones", Value::Enum(0)), ("highlights", col(1.0, 0.0, 0.0))]); - assert!((t.data[0][0] - 0.5).abs() < 1e-3 && t.data[0][1] < 1e-3); - } - - #[test] - fn shadow_highlight_lifts_shadows() { - let img = Image::filled(8, 8, [0.1, 0.1, 0.1, 1.0]); - let out = run("ec.color.shadowhighlight", &img, &[("autoAmounts", Value::Bool(false))]); - assert!(out.data[0][0] > 0.15); - } - - #[test] - fn levels_ic_clip_popups() { - let img = Image::filled(2, 2, [0.9, 0.9, 0.9, 1.0]); - let lv = [("rgb/rgbInWhite", num(0.5))]; - assert!((run("ec.color.levelsic", &img, &lv).data[0][0] - 1.0).abs() < 1e-6); - let off = [("rgb/rgbInWhite", num(0.5)), ("clipToOutputWhite", Value::Enum(0))]; - assert!(run("ec.color.levelsic", &img, &off).data[0][0] > 1.5); - } - - /// A host whose other layers and other frames are a fixed image. - struct ImgHost(Image); - impl crate::EffectHost for ImgHost { - fn layer(&self, _: u64, _: bool) -> Option { - Some(crate::LayerPixels { buf: Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 }, size: [2.0, 2.0] }) - } - fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { - None - } - fn self_at(&self, _: f64, _: usize) -> Option { - Some(Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 }) - } - } - - fn run_host(id: &str, img: &Image, over: &[(&str, Value)], host: &ImgHost) -> Image { - let env = crate::EffectEnv { host: Some(host), frame_rate: 10.0, ..Default::default() }; - crate::run_fx(id, over, img.clone(), 1.0, env).img - } - - #[test] - fn color_link_samples_clip_and_source_layer() { - let mut img = Image::new(5, 1); - for (x, v) in [0.0f32, 0.1, 0.2, 0.3, 1.0].iter().enumerate() { - img.set(x as u32, 0, [*v, *v, *v, 1.0]); - } - // Median ignores the outlier the average follows. - let med = run("ec.color.colorlink", &img, &[("sampleSource", Value::Enum(1))]); - assert!((med.data[0][0] - 0.2).abs() < 1e-5); - // Brightest with 20 % clip skips the brightest pixel. - let br = run("ec.color.colorlink", &img, &[("sampleSource", Value::Enum(2)), ("clip", num(20.0))]); - assert!((br.data[0][0] - 0.3).abs() < 1e-5); - // Min Alpha of a half-transparent source layer sets the alpha. - let host = ImgHost(Image::filled(2, 2, [0.25, 0.25, 0.25, 0.5])); - let out = run_host("ec.color.colorlink", &img, &[("sourceLayer", Value::Layer(Some(9))), ("sampleSource", Value::Enum(9))], &host); - assert!((out.data[4][3] - 0.5).abs() < 1e-5); - // Average of the source layer (not the effect's own pixels). - let out = run_host("ec.color.colorlink", &img, &[("sourceLayer", Value::Layer(Some(9)))], &host); - assert!((out.data[4][0] - 0.5).abs() < 1e-5); - } - - #[test] - fn auto_levels_temporal_smoothing_reads_neighbour_frames() { - let mut img = Image::new(4, 1); - for x in 0..4 { - let v = 0.3 + 0.1 * x as f32; - img.set(x, 0, [v, v, v, 1.0]); - } - let wide = ImgHost(ramp()); - let p0 = [("blackClip", num(0.0)), ("whiteClip", num(0.0))]; - let alone = run_host("ec.color.autolevels", &img, &p0, &wide); - let smooth = run_host("ec.color.autolevels", &img, &[p0[0].clone(), p0[1].clone(), ("temporalSmoothing", num(0.2))], &wide); - // Alone the frame is stretched to full range; with full-range neighbours much less. - assert!(alone.data[0][0] < 0.01); - assert!(smooth.data[0][0] > 0.1, "{:?}", smooth.data[0]); - } - - #[test] - fn shadow_highlight_clip_keeps_the_tonal_range() { - let img = ramp(); - let lift = [("autoAmounts", Value::Bool(false)), ("shadowAmount", num(80.0))]; - let unclipped = run( - "ec.color.shadowhighlight", - &img, - &[lift[0].clone(), lift[1].clone(), ("moreOptions/blackClip", num(0.0)), ("moreOptions/whiteClip", num(0.0))], - ); - let clipped = run("ec.color.shadowhighlight", &img, &[lift[0].clone(), lift[1].clone(), ("moreOptions/blackClip", num(1.0))]); - let min = |im: &Image| im.data.iter().filter(|p| p[3] > 0.99).map(|p| luminance(p[0], p[1], p[2])).fold(f32::MAX, f32::min); - assert!(min(&clipped) < min(&unclipped)); - } -} +mod tests; diff --git a/crates/effects/src/color2/tests.rs b/crates/effects/src/color2/tests.rs new file mode 100644 index 000000000..5f57692a0 --- /dev/null +++ b/crates/effects/src/color2/tests.rs @@ -0,0 +1,338 @@ +use super::*; +use crate::{Params, find}; + +fn run(id: &str, img: &Image, over: &[(&str, Value)]) -> Image { + let s = find(id).unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in over { + params.values.insert(k.to_string(), v.clone()); + } + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env: Default::default() }; + (s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img +} + +fn ramp() -> Image { + let mut img = Image::new(16, 8); + for y in 0..8 { + for x in 0..16 { + let v = x as f32 / 15.0; + let a = if y == 0 { 0.5 } else { 1.0 }; + img.set(x, y, [v * a, (1.0 - v) * a, 0.3 * a, a]); + } + } + img +} + +fn close(a: &Image, b: &Image, eps: f32) -> bool { + a.data.iter().zip(&b.data).all(|(p, q)| (0..4).all(|i| (p[i] - q[i]).abs() <= eps)) +} + +#[test] +fn curve_parsing() { + assert!(Curve::parse("0,0 1,1").is_none()); + assert!(Curve::parse("garbage").is_none()); + let c = Curve::parse("1,1; 0,0 0.5,0.7").unwrap(); + assert_eq!(c.xs, vec![0.0, 0.5, 1.0]); + assert!((c.eval(0.5) - 0.7).abs() < 1e-3); + // Monotone: never decreases. + let mut last = -1.0; + for i in 0..=100 { + let v = c.eval(i as f32 / 100.0); + assert!(v >= last - 1e-6); + last = v; + } +} + +#[test] +fn curves_identity_is_identity() { + let img = ramp(); + assert_eq!(run("ec.color.curves", &img, &[]), img); + let explicit = run("ec.color.curves", &img, &[("rgb", Value::Str("0,0 0.25,0.25 1,1".into()))]); + assert!(close(&explicit, &img, 1e-4)); +} + +#[test] +fn curves_midpoint_brightens() { + let img = Image::filled(4, 4, [0.5, 0.5, 0.5, 1.0]); + let out = run("ec.color.curves", &img, &[("rgb", Value::Str("0,0 0.5,0.75 1,1".into()))]); + assert!((out.data[0][0] - 0.75).abs() < 1e-3); + let red = run("ec.color.curves", &img, &[("red", Value::Str("0,1 1,1".into()))]); + assert!((red.data[0][0] - 1.0).abs() < 1e-4 && (red.data[0][1] - 0.5).abs() < 1e-6); +} + +/// After Effects 2026, 32 bpc project, Levels (Individual Controls) with RGB Input Black 0.1, +/// Input White 0.9, Gamma 1.5, Output Black 0.05, Output White 0.95, Red Gamma 0.8, Green +/// Input White 0.8, Blue Output Black 0.2, both clips "Off for 32 bpc Color", on grey steps: +/// the channel controls run first, nothing clips (8-bit captures clamp the results). +#[test] +fn levels_ic_matches_after_effects_in_32_bpc() { + let steps = [0, 16, 32, 64, 96, 128, 160, 192, 224, 255]; + let ae = [ + [0, 0, 70], + [0, 0, 88], + [0, 52, 104], + [61, 108, 132], + [102, 150, 158], + [138, 187, 181], + [171, 220, 203], + [203, 250, 223], + [233, 255, 243], + [255, 255, 255], + ]; + let mut img = Image::new(10, 1); + for (x, v) in steps.iter().enumerate() { + let v = *v as f32 / 255.0; + img.set(x as u32, 0, [v, v, v, 1.0]); + } + let vals = [ + ("rgb/rgbInBlack", Value::Scalar(0.1)), + ("rgb/rgbInWhite", Value::Scalar(0.9)), + ("rgb/rgbGamma", Value::Scalar(1.5)), + ("rgb/rgbOutBlack", Value::Scalar(0.05)), + ("rgb/rgbOutWhite", Value::Scalar(0.95)), + ("red/redGamma", Value::Scalar(0.8)), + ("green/greenInWhite", Value::Scalar(0.8)), + ("blue/blueOutBlack", Value::Scalar(0.2)), + ]; + let s = find("ec.color.levelsic").unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in &vals { + params.values.insert(k.to_string(), v.clone()); + } + let render = |float_depth: bool| { + let env = crate::EffectEnv { float_depth, ..Default::default() }; + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [10.0, 1.0], seed: 1, adjustment: false, env }; + (s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img + }; + let out = render(true); + for (x, want) in ae.iter().enumerate() { + let p = out.get(x as i64, 0); + for c in 0..3 { + let got = p[c].clamp(0.0, 1.0) * 255.0; + assert!((got - want[c] as f32).abs() <= 2.0, "step {} channel {c}: {got} vs AE {}", steps[x], want[c]); + } + } + // In 8 / 16 bpc "Off for 32 bpc Color" clips: black stops at Output Black. + let clipped = render(false); + assert!((clipped.get(0, 0)[0] - 0.05).abs() < 1e-5, "{:?}", clipped.get(0, 0)); +} + +/// After Effects 2026 (cases 962 / 963): Curves through RGB (0,0) (125,199) +/// (255,255) and Red (0,0) (128,44) (255,255) on grey steps 0..255, 8-bit results. +#[test] +fn curves_follow_a_natural_spline_as_in_after_effects() { + let steps = [0u8, 16, 32, 64, 96, 128, 160, 192, 224, 255]; + let mut img = Image::new(10, 1); + for (x, v) in steps.iter().enumerate() { + let v = *v as f32 / 255.0; + img.set(x as u32, 0, [v, v, v, 1.0]); + } + let p = |pts: &[(f32, f32)]| Value::Str(pts.iter().map(|(x, y)| format!("{},{}", x / 255.0, y / 255.0)).collect::>().join(" ")); + let rgb = run("ec.color.curves", &img, &[("rgb", p(&[(0.0, 0.0), (125.0, 199.0), (255.0, 255.0)]))]); + let red = run("ec.color.curves", &img, &[("red", p(&[(0.0, 0.0), (128.0, 44.0), (255.0, 255.0)]))]); + let ae_rgb = [0, 30, 59, 115, 164, 202, 227, 242, 250, 255]; + let ae_red = [0, 0, 1, 6, 19, 44, 83, 135, 194, 255]; + for x in 0..10 { + let g = (rgb.get(x as i64, 0)[1] * 255.0).round() as i32; + let r = (red.get(x as i64, 0)[0] * 255.0).round() as i32; + assert!((g - ae_rgb[x]).abs() <= 1, "RGB step {}: {g} vs AE {}", steps[x], ae_rgb[x]); + assert!((r - ae_red[x]).abs() <= 1, "Red step {}: {r} vs AE {}", steps[x], ae_red[x]); + } +} + +#[test] +fn levels_ic_identity_and_red_only() { + let img = ramp(); + assert_eq!(run("ec.color.levelsic", &img, &[]), img); + let out = run("ec.color.levelsic", &img, &[("red/redOutWhite", num(0.5))]); + let p = out.get(15, 3); + assert!((p[0] - 0.5).abs() < 1e-4 && (p[1] - 0.0).abs() < 1e-4); +} + +#[test] +fn auto_levels_stretches_low_contrast() { + let mut img = Image::new(32, 4); + for y in 0..4 { + for x in 0..32 { + let v = 0.4 + 0.2 * x as f32 / 31.0; + img.set(x, y, [v, v, v, 1.0]); + } + } + let out = run("ec.color.autolevels", &img, &[("blackClip", num(0.0)), ("whiteClip", num(0.0))]); + let lo = out.data.iter().map(|p| p[0]).fold(1.0, f32::min); + let hi = out.data.iter().map(|p| p[0]).fold(0.0, f32::max); + assert!(lo < 0.02 && hi > 0.98, "{lo} {hi}"); + let ac = run("ec.color.autocontrast", &img, &[("blackClip", num(0.0)), ("whiteClip", num(0.0))]); + assert!(ac.data.iter().map(|p| p[0]).fold(0.0, f32::max) > 0.98); +} + +#[test] +fn auto_color_neutralises_cast() { + let mut img = Image::new(16, 4); + for y in 0..4 { + for x in 0..16 { + let v = x as f32 / 15.0; + img.set(x, y, [v * 0.6 + 0.1, v * 0.9, v * 0.5, 1.0]); + } + } + let out = run("ec.color.autocolor", &img, &[]); + let p = out.get(8, 1); + assert!((p[0] - p[1]).abs() < 0.08 && (p[1] - p[2]).abs() < 0.08, "{p:?}"); +} + +#[test] +fn equalize_flattens_histogram() { + let mut img = Image::new(64, 1); + for x in 0..64 { + let v = if x < 48 { 0.1 + x as f32 * 0.001 } else { 0.9 }; + img.set(x, 0, [v, v, v, 1.0]); + } + let out = run("ec.color.equalize", &img, &[]); + // The crowded dark values are spread across the range. + assert!(out.get(47, 0)[0] > 0.6, "{}", out.get(47, 0)[0]); + assert!(out.get(0, 0)[0] < 0.1); + assert_eq!(run("ec.color.equalize", &img, &[("amount", num(0.0))]), img); +} + +#[test] +fn selective_color_zero_is_identity_and_reds_respond() { + let img = ramp(); + assert_eq!(run("ec.color.selectivecolor", &img, &[]), img); + let red = Image::filled(2, 2, [1.0, 0.1, 0.1, 1.0]); + let out = run("ec.color.selectivecolor", &red, &[("details/reds/redsCyan", num(100.0)), ("method", Value::Enum(1))]); + assert!(out.data[0][0] < 0.3, "{:?}", out.data[0]); + let blue = Image::filled(2, 2, [0.1, 0.1, 1.0, 1.0]); + assert_eq!(run("ec.color.selectivecolor", &blue, &[("details/reds/redsCyan", num(100.0))]), blue); +} + +#[test] +fn color_offset_full_turn_is_identity() { + let img = ramp(); + let out = run("ec.color.cccoloroffset", &img, &[("redPhase", num(360.0)), ("greenPhase", num(720.0))]); + assert_eq!(out, img); + let out = run("ec.color.cccoloroffset", &img, &[("redPhase", num(180.0))]); + assert!((out.get(0, 3)[0] - 0.5).abs() < 1e-4); +} + +#[test] +fn broadcast_reduces_unsafe_color() { + let img = Image::filled(2, 2, [1.0, 1.0, 0.0, 1.0]); + let out = run("ec.color.broadcast", &img, &[]); + assert!(out.data[0][0] < 1.0); + let key = run("ec.color.broadcast", &img, &[("howToMakeSafe", Value::Enum(2))]); + assert_eq!(key.data[0][3], 0.0); + let gray = Image::filled(2, 2, [0.5, 0.5, 0.5, 1.0]); + assert_eq!(run("ec.color.broadcast", &gray, &[]), gray); +} + +#[test] +fn color_balance_hls_zero_identity_and_hue_shift() { + let img = ramp(); + assert_eq!(run("ec.color.colorbalancehls", &img, &[]), img); + let red = Image::filled(1, 1, [1.0, 0.0, 0.0, 1.0]); + let out = run("ec.color.colorbalancehls", &red, &[("hue", num(120.0))]); + assert!(out.data[0][1] > 0.99 && out.data[0][0] < 0.01); +} + +#[test] +fn color_link_fills_with_average_keeping_alpha() { + let mut img = Image::new(2, 1); + img.set(0, 0, [1.0, 0.0, 0.0, 1.0]); + img.set(1, 0, [0.0, 0.0, 0.5, 0.5]); + let out = run("ec.color.colorlink", &img, &[]); + assert!((out.data[1][3] - 0.5).abs() < 1e-5); + let (c, _) = unpremul(out.data[0]); + assert!((c[0] - 2.0 / 3.0).abs() < 1e-3 && (c[2] - 1.0 / 3.0).abs() < 1e-3, "{c:?}"); +} + +#[test] +fn cc_kernel_identity_and_toner_duotone() { + let img = ramp(); + assert_eq!(run("ec.color.cckernel", &img, &[]), img); + let g = Image::filled(1, 1, [0.5, 0.5, 0.5, 1.0]); + let t = run("ec.color.cctoner", &g, &[("tones", Value::Enum(0)), ("highlights", col(1.0, 0.0, 0.0))]); + assert!((t.data[0][0] - 0.5).abs() < 1e-3 && t.data[0][1] < 1e-3); +} + +#[test] +fn shadow_highlight_lifts_shadows() { + let img = Image::filled(8, 8, [0.1, 0.1, 0.1, 1.0]); + let out = run("ec.color.shadowhighlight", &img, &[("autoAmounts", Value::Bool(false))]); + assert!(out.data[0][0] > 0.15); +} + +#[test] +fn levels_ic_clip_popups() { + let img = Image::filled(2, 2, [0.9, 0.9, 0.9, 1.0]); + let lv = [("rgb/rgbInWhite", num(0.5))]; + assert!((run("ec.color.levelsic", &img, &lv).data[0][0] - 1.0).abs() < 1e-6); + let off = [("rgb/rgbInWhite", num(0.5)), ("clipToOutputWhite", Value::Enum(0))]; + assert!(run("ec.color.levelsic", &img, &off).data[0][0] > 1.5); +} + +/// A host whose other layers and other frames are a fixed image. +struct ImgHost(Image); +impl crate::EffectHost for ImgHost { + fn layer(&self, _: u64, _: bool) -> Option { + Some(crate::LayerPixels { buf: Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 }, size: [2.0, 2.0] }) + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn self_at(&self, _: f64, _: usize) -> Option { + Some(Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 }) + } +} + +fn run_host(id: &str, img: &Image, over: &[(&str, Value)], host: &ImgHost) -> Image { + let env = crate::EffectEnv { host: Some(host), frame_rate: 10.0, ..Default::default() }; + crate::run_fx(id, over, img.clone(), 1.0, env).img +} + +#[test] +fn color_link_samples_clip_and_source_layer() { + let mut img = Image::new(5, 1); + for (x, v) in [0.0f32, 0.1, 0.2, 0.3, 1.0].iter().enumerate() { + img.set(x as u32, 0, [*v, *v, *v, 1.0]); + } + // Median ignores the outlier the average follows. + let med = run("ec.color.colorlink", &img, &[("sampleSource", Value::Enum(1))]); + assert!((med.data[0][0] - 0.2).abs() < 1e-5); + // Brightest with 20 % clip skips the brightest pixel. + let br = run("ec.color.colorlink", &img, &[("sampleSource", Value::Enum(2)), ("clip", num(20.0))]); + assert!((br.data[0][0] - 0.3).abs() < 1e-5); + // Min Alpha of a half-transparent source layer sets the alpha. + let host = ImgHost(Image::filled(2, 2, [0.25, 0.25, 0.25, 0.5])); + let out = run_host("ec.color.colorlink", &img, &[("sourceLayer", Value::Layer(Some(9))), ("sampleSource", Value::Enum(9))], &host); + assert!((out.data[4][3] - 0.5).abs() < 1e-5); + // Average of the source layer (not the effect's own pixels). + let out = run_host("ec.color.colorlink", &img, &[("sourceLayer", Value::Layer(Some(9)))], &host); + assert!((out.data[4][0] - 0.5).abs() < 1e-5); +} + +#[test] +fn auto_levels_temporal_smoothing_reads_neighbour_frames() { + let mut img = Image::new(4, 1); + for x in 0..4 { + let v = 0.3 + 0.1 * x as f32; + img.set(x, 0, [v, v, v, 1.0]); + } + let wide = ImgHost(ramp()); + let p0 = [("blackClip", num(0.0)), ("whiteClip", num(0.0))]; + let alone = run_host("ec.color.autolevels", &img, &p0, &wide); + let smooth = run_host("ec.color.autolevels", &img, &[p0[0].clone(), p0[1].clone(), ("temporalSmoothing", num(0.2))], &wide); + // Alone the frame is stretched to full range; with full-range neighbours much less. + assert!(alone.data[0][0] < 0.01); + assert!(smooth.data[0][0] > 0.1, "{:?}", smooth.data[0]); +} + +#[test] +fn shadow_highlight_clip_keeps_the_tonal_range() { + let img = ramp(); + let lift = [("autoAmounts", Value::Bool(false)), ("shadowAmount", num(80.0))]; + let unclipped = + run("ec.color.shadowhighlight", &img, &[lift[0].clone(), lift[1].clone(), ("moreOptions/blackClip", num(0.0)), ("moreOptions/whiteClip", num(0.0))]); + let clipped = run("ec.color.shadowhighlight", &img, &[lift[0].clone(), lift[1].clone(), ("moreOptions/blackClip", num(1.0))]); + let min = |im: &Image| im.data.iter().filter(|p| p[3] > 0.99).map(|p| luminance(p[0], p[1], p[2])).fold(f32::MAX, f32::min); + assert!(min(&clipped) < min(&unclipped)); +} diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index 402d0ebe2..3a52e7a54 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -25,7 +25,7 @@ pub(crate) fn luma_601(c: [f32; 3]) -> f32 { LUMA_601[0] * c[0] + LUMA_601[1] * c[1] + LUMA_601[2] * c[2] } -/// The Rec. 601 luma weights of [`luma_601`]. The WGSL kernels repeat them as literals; the GPU +/// The Rec. 601 luma weights of `luma_601`. The WGSL kernels repeat them as literals; the GPU /// test `shader_constants_match_the_cpu` checks that they are these. pub const LUMA_601: [f32; 3] = [0.299, 0.587, 0.114]; @@ -384,7 +384,7 @@ pub fn mirror_pow(v: f32, p: f32) -> f32 { /// back toward the gray). After Effects 2026 with Tint Color [1, 0.5, 0]: gray 0.1 (a red solid /// at Reds 10, case 717) → (43, 21, 0), gray 0.6 (yellow at defaults, case 825) → (255, 129, 2), /// white → white; the old hue / saturation / lightness mix gave (51, 26, 0) for the first. -/// The luminosity weights of [`bw_tint`] (a Color blend's). `fx_color.wgsl` repeats them; the GPU +/// The luminosity weights of `bw_tint` (a Color blend's). `fx_color.wgsl` repeats them; the GPU /// test `shader_constants_match_the_cpu` checks that they are these. pub const BW_TINT_LUMA: [f32; 3] = [0.3, 0.59, 0.11]; @@ -755,7 +755,7 @@ impl ColoramaPalette { } } - /// Parse "pos:r,g,b[,a] pos:r,g,b …" (positions 0..1, channels 0..1). `None` without stops. + /// Parse "pos:r,g,b\[,a\] pos:r,g,b …" (positions 0..1, channels 0..1). `None` without stops. pub fn parse(s: &str) -> Option { let mut stops: Vec<(f32, [f32; 4])> = s .split_whitespace() @@ -1215,524 +1215,4 @@ pub fn specs() -> Vec { } #[cfg(test)] -mod tests { - use super::*; - use crate::{EffectEnv, run_fx}; - use effectcraft_raster::Image; - - fn px(id: &str, vals: &[(&str, Value)], c: [f32; 4]) -> [f32; 4] { - run_fx(id, vals, Image::filled(2, 2, c), 0.0, EffectEnv::default()).img.get(0, 0) - } - fn close(a: [f32; 4], b: [f32; 4], eps: f32) -> bool { - a.iter().zip(b).all(|(x, y)| (x - y).abs() <= eps) - } - - /// Projects saved before Use Legacy / the Photo Filter menu existed keep rendering as before. - #[test] - fn old_saves_get_legacy_values_for_added_params() { - use effectcraft_project::build::Ids; - let mut next = 1; - for (id, param, want) in - [("ec.color.brightnesscontrast", "useLegacy", Value::Bool(true)), ("ec.color.photofilter", "filter", Value::Enum(PHOTO_FILTER_CUSTOM))] - { - let spec = crate::find(id).unwrap(); - let mut g = crate::instantiate(spec, &mut Ids(&mut next), spec.name, [10.0, 10.0]); - g.children.retain(|c| c.match_id() != param); - crate::migrate::upgrade_instance(spec, &mut g, &mut Ids(&mut next), [10.0, 10.0]); - assert_eq!(g.get(param).unwrap().value, want, "{id}"); - } - } - - /// After Effects 2026 renders of full-frame solids with Tint at its defaults (sRGB working - /// space, not linearised, 32 bpc, 8-bit capture): Rec. 601 weights on the encoded values. - #[test] - fn tint_defaults_match_after_effects_greys() { - for (c, want) in [([1.0, 0.0, 0.0], 76.0), ([0.0, 1.0, 0.0], 150.0), ([0.0, 0.0, 1.0], 29.0), ([0.5, 0.5, 0.5], 128.0)] { - let o = px("ec.color.tint", &[], [c[0], c[1], c[2], 1.0]); - let want = want / 255.0; - assert!(close(o, [want, want, want, 1.0], 1.0 / 255.0), "{c:?}: {o:?} vs {want}"); - } - } - - #[test] - fn colorama_reads_get_phase_from() { - // Same intensity, different red: Intensity gives one colour, Red gives two. - let a = [0.6, 0.2, 0.2, 1.0]; - let b = [0.2, 0.2, 0.6, 1.0]; - let red = |c| px("ec.color.colorama", &[("inputPhase/mode", Value::Enum(1))], c); - assert!(!close(red(a), red(b), 1e-3)); - // Zero maps everything to the start of the cycle (red). - assert!(close(px("ec.color.colorama", &[("inputPhase/mode", Value::Enum(9))], b), [1.0, 0.0, 0.0, 1.0], 1e-5)); - // Interpolate Palette off posterizes: two nearby inputs land on the same colour. - let off = |v: f32| px("ec.color.colorama", &[("outputCycle/interpolatePalette", Value::Bool(false))], [v, v, v, 1.0]); - assert_eq!(off(0.20), off(0.22)); - } - - #[test] - fn brightness_contrast_modern_keeps_black_and_white_and_legacy_is_linear() { - let bc = |vals: &[(&str, Value)], v: f32| px("ec.color.brightnesscontrast", vals, [v, v, v, 1.0])[0]; - let up = [("brightness", num(50.0)), ("contrast", num(40.0))]; - assert_eq!(bc(&up, 0.0), 0.0); - assert!((bc(&up, 1.0) - 1.0).abs() < 1e-6); - assert!(bc(&up, 0.4) > 0.4); - let legacy = [("brightness", num(50.0)), ("useLegacy", Value::Bool(true))]; - assert!((bc(&legacy, 0.2) - 0.7).abs() < 1e-5); - assert!((bc(&legacy, 0.0) - 0.5).abs() < 1e-5); - } - - /// After Effects non-legacy ramps (kasper9n, #681). B0 C-100 matched B0 C-50 and is omitted. - #[test] - fn brightness_contrast_modern_matches_ae_ramp_samples() { - let u8 = |v: f32| (v * 255.0).round() as i32; - let go = |b: f64, c: f64, x: u8| { - u8(px("ec.color.brightnesscontrast", &[("brightness", num(b)), ("contrast", num(c))], [x as f32 / 255.0, x as f32 / 255.0, x as f32 / 255.0, 1.0]) - [0]) - }; - // (brightness, contrast, input, AE 8-bit) - let samples: &[(f64, f64, u8, u8)] = &[ - (0.0, 100.0, 64, 40), - (0.0, 100.0, 128, 129), - (0.0, 100.0, 192, 217), - (0.0, 50.0, 64, 53), - (0.0, 50.0, 192, 205), - (0.0, -50.0, 64, 77), - (100.0, 0.0, 128, 209), - (50.0, 0.0, 128, 171), - (106.0, -18.0, 128, 214), - ]; - for &(b, c, x, ae) in samples { - let got = go(b, c, x); - assert!((got - ae as i32).abs() <= 10, "B{b} C{c} in {x}: got {got} AE {ae}"); - } - // Old g=4 S-curve put Contrast 100 at ~8/255 here. - assert!(go(0.0, 100.0, 64) >= 30); - } - - /// After Effects 2026: Tritone Highlights [1, 1, 0.8], Midtones [0.6, 0.2, 0.2], Shadows - /// [0, 0, 0.3]. On a red solid with Blend With Original 25 (case 718) AE gives (132, 23, 46); - /// on a grey ramp (case 815) levels 0, 63.9, 127.6, 191.4 give (0, 0, 77), (77, 26, 64), - /// (153, 51, 51), (204, 153, 128). - #[test] - fn tritone_matches_after_effects() { - let colours = [ - ("highlights", Value::Color([1.0, 1.0, 0.8, 1.0])), - ("midtones", Value::Color([0.6, 0.2, 0.2, 1.0])), - ("shadows", Value::Color([0.0, 0.0, 0.3, 1.0])), - ]; - let near = |o: [f32; 4], ae: [f32; 3]| (0..3).all(|i| (o[i] * 255.0 - ae[i]).abs() <= 1.0); - let mut red = colours.to_vec(); - red.push(("blend", num(25.0))); - let o = px("ec.color.tritone", &red, [1.0, 0.0, 0.0, 1.0]); - assert!(near(o, [132.0, 23.0, 46.0]), "{:?}", o.map(|v| v * 255.0)); - for (level, ae) in [(0.1f32, [0.0, 0.0, 77.0]), (63.9, [77.0, 26.0, 64.0]), (127.6, [153.0, 51.0, 51.0]), (191.4, [204.0, 153.0, 128.0])] { - let v = level / 255.0; - let o = px("ec.color.tritone", &colours, [v, v, v, 1.0]); - assert!(near(o, ae), "level {level}: {:?} vs {ae:?}", o.map(|v| v * 255.0)); - } - } - - /// After Effects 2026, Tint Color [1, 0.5, 0]: a red solid at Reds 10 (case 717) and yellow - /// and white solids at the default sliders (case 825). - #[test] - fn black_white_tint_matches_after_effects() { - let tint = [("tint", Value::Bool(true)), ("tintColor", Value::Color([1.0, 0.5, 0.0, 1.0]))]; - let near = |o: [f32; 4], ae: [f32; 3]| (0..3).all(|i| (o[i] * 255.0 - ae[i]).abs() <= 1.5); - let mut reds = tint.to_vec(); - reds.push(("reds", num(10.0))); - let o = px("ec.color.blackwhite", &reds, [1.0, 0.0, 0.0, 1.0]); - assert!(near(o, [43.0, 21.0, 0.0]), "{:?}", o.map(|v| v * 255.0)); - let o = px("ec.color.blackwhite", &tint, [1.0, 1.0, 0.0, 1.0]); - assert!(near(o, [255.0, 129.0, 2.0]), "{:?}", o.map(|v| v * 255.0)); - let o = px("ec.color.blackwhite", &tint, [1.0, 1.0, 1.0, 1.0]); - assert!(near(o, [255.0, 255.0, 255.0]), "{:?}", o.map(|v| v * 255.0)); - // Hostile grays stay finite. - for g in [f32::NAN, -1.0, 0.0, 4.0] { - assert!(bw_tint([1.0, 0.5, 0.0], g).iter().all(|v| v.is_finite())); - } - } - - /// After Effects 2026 ramps (RENDERER round 2, session R8): negative Brightness and Contrast - /// are the inverses of the positive curves. (brightness, contrast, input, AE 8-bit) - #[test] - fn negative_brightness_and_contrast_invert_the_positive_curves() { - let go = |b: f64, c: f64, x: u8| { - let v = x as f32 / 255.0; - (px("ec.color.brightnesscontrast", &[("brightness", num(b)), ("contrast", num(c))], [v, v, v, 1.0])[0] * 255.0).round() as i32 - }; - let samples: &[(f64, f64, u8, u8)] = &[ - (-40.0, 0.0, 64, 50), - (-40.0, 0.0, 128, 99), - (-40.0, 0.0, 191, 155), - (-40.0, 0.0, 234, 207), - (0.0, -50.0, 21, 28), - (0.0, -50.0, 64, 76), - (0.0, -50.0, 191, 179), - (0.0, -50.0, 234, 227), - (40.0, 0.0, 128, 162), - (0.0, 50.0, 64, 52), - ]; - for &(b, c, x, ae) in samples { - let got = go(b, c, x); - assert!((got - ae as i32).abs() <= 7, "B{b} C{c} in {x}: got {got} AE {ae}"); - } - } - - /// #1463: Black & White's sliders set the gray of each pure hue and blend between - /// neighbouring hues; neutral pixels keep their value, overbright ones too, and an overbright - /// colour stays finite (it used to divide by zero). - #[test] - fn black_white_sliders_set_the_gray_of_each_hue() { - let bw = |vals: &[(&str, Value)], c: [f32; 3]| px("ec.color.blackwhite", vals, [c[0], c[1], c[2], 1.0]); - let gray = |v: f32| [v, v, v, 1.0]; - // The defaults: Reds 40, Yellows 60, Greens 40, Cyans 60, Blues 20, Magentas 80 (%). - let hues = [ - ([1.0, 0.0, 0.0], 0.4), - ([1.0, 1.0, 0.0], 0.6), - ([0.0, 1.0, 0.0], 0.4), - ([0.0, 1.0, 1.0], 0.6), - ([0.0, 0.0, 1.0], 0.2), - ([1.0, 0.0, 1.0], 0.8), - // Half as bright, half the gray; orange is halfway from Reds to Yellows. - ([0.5, 0.0, 0.0], 0.2), - ([1.0, 0.5, 0.0], 0.5), - // Neutral pixels keep their value. - ([0.3, 0.3, 0.3], 0.3), - ([4.0, 4.0, 4.0], 4.0), - // An overbright red: its smallest channel plus its chroma at Reds' 40 %. - ([1.5, 0.5, 0.5], 0.9), - ]; - for (c, want) in hues { - let o = bw(&[], c); - assert!(close(o, gray(want), 1e-5), "{c:?}: {o:?}, want {want}"); - } - assert!(close(bw(&[("reds", num(120.0))], [1.0, 0.0, 0.0]), gray(1.2), 1e-5)); - assert!(close(bw(&[("reds", num(-200.0))], [1.0, 0.0, 0.0]), gray(0.0), 1e-5)); - assert!(close(bw(&[("blues", num(300.0))], [0.1, 0.1, 0.3]), gray(0.7), 1e-5)); - } - - /// #1463: faint colour noise stays faint. The shadows of compressed footage carry a code or - /// two of colour noise, which Black & White turned into bright and black blocks (the HSL - /// saturation it scaled is near 1 for any tinted near-black pixel). At 8, 16 and 32 bpc the - /// gray of a dark ramp with ±2 steps of colour noise stays within 2 steps of the clean ramp's. - #[test] - fn black_white_keeps_faint_colour_noise_faint() { - // A step of 8 bpc, of 16 bpc (After Effects' 0..32768), and a small 32-bpc float one. - for step in [1.0 / 255.0, 1.0 / 32768.0, 1e-3_f32] { - let (w, h) = (64u32, 4u32); - let (mut clean, mut noisy) = (Image::new(w, h), Image::new(w, h)); - for y in 0..h { - for x in 0..w { - // A dark ramp up to a quarter gray, in whole steps... - let v = (x as f32 / w as f32 * 0.25 / step).round() * step; - // ...and up to ±2 steps of noise per channel, mixed differently in each pixel. - let n = |k: u32| (((x * 7 + y * 13 + k * 2) % 5) as f32 - 2.0) * step; - clean.set(x, y, [v, v, v, 1.0]); - noisy.set(x, y, [(v + n(0)).max(0.0), (v + n(1)).max(0.0), (v + n(2)).max(0.0), 1.0]); - } - } - let bw = |img| run_fx("ec.color.blackwhite", &[], img, 0.0, EffectEnv::default()).img; - let (a, b) = (bw(clean), bw(noisy)); - let worst = a.data.iter().zip(&b.data).map(|(p, q)| (p[0] - q[0]).abs()).fold(0.0, f32::max); - assert!(worst <= 2.0 * step + 1e-6, "step {step}: the noise moved the gray by {} steps", worst / step); - } - } - - #[test] - fn levels_clips_black_and_white_independently() { - let lv = |vals: &[(&str, Value)], v: f32| px("ec.color.levels", vals, [v, v, v, 1.0])[0]; - let base = [("inBlack", num(0.2)), ("inWhite", num(0.6)), ("outBlack", num(0.1)), ("outWhite", num(0.9))]; - assert!((lv(&base, 0.9) - 0.9).abs() < 1e-5); - let mut no_white = base.to_vec(); - no_white.push(("clipToOutputWhite", Value::Enum(0))); - assert!(lv(&no_white, 0.9) > 0.9); - assert!((lv(&no_white, 0.0) - 0.1).abs() < 1e-5); - // The hidden switch of older projects turns both off. - let mut legacy = base.to_vec(); - legacy.push(("noClip", Value::Bool(true))); - assert!(lv(&legacy, 0.9) > 0.9); - } - - #[test] - fn exposure_individual_channels_and_bypass() { - let g = [0.5, 0.5, 0.5, 1.0]; - let one = px("ec.color.exposure", &[("channels", Value::Enum(1)), ("red/redExposure", num(1.0))], g); - assert!(one[0] > 0.6 && (one[1] - 0.5).abs() < 1e-5 && (one[2] - 0.5).abs() < 1e-5); - // Master values are ignored in Individual Channels mode. - assert!(close(px("ec.color.exposure", &[("channels", Value::Enum(1)), ("master/exposure", num(2.0))], g), g, 1e-5)); - // Bypass Linear Light Conversion: +1 stop doubles the raw value. - let raw = px("ec.color.exposure", &[("master/exposure", num(1.0)), ("bypassLinearLight", Value::Bool(true))], [0.25, 0.25, 0.25, 1.0]); - assert!((raw[0] - 0.5).abs() < 1e-5); - // Negative results are mirrored, not clipped. - let neg = px( - "ec.color.exposure", - &[("master/offset", num(-0.5)), ("bypassLinearLight", Value::Bool(true)), ("master/gamma", num(2.0))], - [0.25, 0.25, 0.25, 1.0], - ); - assert!((neg[0] + 0.5).abs() < 1e-4, "{neg:?}"); - } - - /// After Effects' Exposure ranges for all twelve sliders; a saved value outside them (a gamma - /// of 0.05, valid before, as in Photoshop) survives loading and still renders. - #[test] - fn exposure_has_after_effects_ranges_and_keeps_old_values() { - use effectcraft_project::build::Ids; - let spec = crate::find("ec.color.exposure").unwrap(); - let want = [(-100.0, 100.0, -4.0, 4.0, 2), (-2.0, 2.0, -0.5, 0.5, 4), (0.1, 10.0, 0.1, 10.0, 2)]; - let sliders: Vec<_> = spec - .params - .iter() - .filter_map(|p| match p.ui { - ParamUi::Slider { min, max, slider_min, slider_max, decimals } => Some((p.id, (min, max, slider_min, slider_max, decimals))), - _ => None, - }) - .collect(); - assert_eq!(sliders.len(), 12); - for (i, (id, r)) in sliders.iter().enumerate() { - assert_eq!(*r, want[i % 3], "{id}"); - } - let mut next = 1; - let mut g = crate::instantiate(spec, &mut Ids(&mut next), spec.name, [10.0, 10.0]); - let gamma = g.prop_mut("master/gamma").unwrap(); - gamma.value = num(0.05); - gamma.ui = slider(0.01, 9.99, 0.1, 3.0, 2); - crate::migrate::upgrade_instance(spec, &mut g, &mut Ids(&mut next), [10.0, 10.0]); - let gamma = g.prop_mut("master/gamma").unwrap(); - assert_eq!(gamma.value, num(0.05)); - assert!(matches!(gamma.ui, ParamUi::Slider { min, .. } if min == 0.1)); - let o = px("ec.color.exposure", &[("master/gamma", num(0.05)), ("bypassLinearLight", Value::Bool(true))], [0.5, 0.5, 0.5, 1.0]); - assert!((o[0] - 0.5f32.powf(20.0)).abs() < 1e-9, "{o:?}"); - } - - /// Exposure on a red [1, 0, 0] solid against After Effects 2026 renders (sRGB working space, - /// not linearised, 32 bpc, 8-bit captures): the linear-light conversion is a pure 2.2 power. - /// The piecewise sRGB curve gave 2 / 7 / 39 for the three smallest Offsets. - #[test] - fn exposure_matches_after_effects_gamma_2_2() { - let red = [1.0, 0.0, 0.0, 1.0]; - let code = |o: [f32; 4], i: usize| o[i] * 255.0; - // Master Offset only: G and B carry the curve; R goes over 1 (255 in the 8-bit capture). - for (offset, ae) in [(0.0005, 8.0), (0.002, 15.0), (0.02, 43.0), (0.1, 90.0)] { - let o = px("ec.color.exposure", &[("master/offset", num(offset))], red); - for i in [1, 2] { - assert!((code(o, i) - ae).abs() <= 1.0, "offset {offset}: {} vs AE {ae}", code(o, i)); - } - assert!(o[0] >= 1.0, "{o:?}"); - } - // Exposure −2.25: AE 126; with Bypass Linear Light Conversion (gain on encoded values) 54. - let o = px("ec.color.exposure", &[("master/exposure", num(-2.25))], red); - assert!((code(o, 0) - 126.0).abs() <= 1.0 && o[1] == 0.0, "{o:?}"); - let o = px("ec.color.exposure", &[("master/exposure", num(-2.25)), ("bypassLinearLight", Value::Bool(true))], red); - assert!((code(o, 0) - 54.0).abs() <= 1.0 && o[1] == 0.0, "{o:?}"); - // Default settings leave 32-bpc values alone, negative and over-range ones included. - let hdr = [-0.25, 0.5, 2.0, 1.0]; - assert!(close(px("ec.color.exposure", &[], hdr), hdr, 1e-5)); - // A negative Offset pushes black below zero, mirrored, rather than clipping or NaN. - let o = px("ec.color.exposure", &[("master/offset", num(-0.02))], [0.0, 0.0, 0.0, 1.0]); - assert!((o[0] + 0.02f32.powf(1.0 / 2.2)).abs() < 1e-5, "{o:?}"); - } - - #[test] - fn change_to_color_change_modes_tolerances_and_matte() { - let red = [0.8, 0.1, 0.1, 1.0]; - let green = [0.1, 0.8, 0.1, 1.0]; - let base = [("from", col(0.8, 0.1, 0.1)), ("to", col(0.1, 0.1, 0.8))]; - // Hue only: red turns blue with its own lightness / saturation; green is untouched. - let o = px("ec.color.changetocolor", &base, red); - assert!(o[2] > 0.7 && o[0] < 0.2, "{o:?}"); - assert!(close(px("ec.color.changetocolor", &base, green), green, 1e-5)); - // The matte is white where matched, black elsewhere. - let mut m = base.to_vec(); - m.push(("viewCorrectionMatte", Value::Bool(true))); - assert!(close(px("ec.color.changetocolor", &m, red), [1.0, 1.0, 1.0, 1.0], 1e-5)); - assert!(close(px("ec.color.changetocolor", &m, green), [0.0, 0.0, 0.0, 1.0], 1e-5)); - // A lightness tolerance of 0 rejects a lighter red. - let mut strict = m.clone(); - strict.push(("toleranceGroup/lightness", num(0.0))); - strict.push(("softness", num(0.0))); - assert!(close(px("ec.color.changetocolor", &strict, [1.0, 0.5, 0.5, 1.0]), [0.0, 0.0, 0.0, 1.0], 1e-5)); - // Hue, Lightness & Saturation, set: a matched pixel becomes the To colour. - let mut all = base.to_vec(); - all.push(("change", Value::Enum(3))); - assert!(close(px("ec.color.changetocolor", &all, red), [0.1, 0.1, 0.8, 1.0], 1e-4)); - } - - #[test] - fn leave_color_tolerance_softness_and_match_mode() { - let green = [0.1, 0.8, 0.1, 1.0]; - let lc = |vals: &[(&str, Value)]| { - let mut v = vec![("amount", num(100.0))]; - v.extend_from_slice(vals); - px("ec.color.leavecolor", &v, green) - }; - let g = luminance(0.1, 0.8, 0.1); - assert!(close(lc(&[]), [g, g, g, 1.0], 1e-5)); - assert!(close(lc(&[("tolerance", num(100.0))]), green, 1e-6)); - // Softness keeps part of the colour. - let s = lc(&[("tolerance", num(10.0)), ("edgeSoftness", num(100.0))]); - assert!(s[1] > g + 0.05 && s[1] < 0.8); - // Using Hue: a dark red still matches pure red. - let dark = px("ec.color.leavecolor", &[("amount", num(100.0)), ("tolerance", num(5.0)), ("matchColors", Value::Enum(1))], [0.4, 0.0, 0.0, 1.0]); - assert!(close(dark, [0.4, 0.0, 0.0, 1.0], 1e-6)); - } - - #[test] - fn photo_filter_presets_and_custom() { - let g = [0.5, 0.5, 0.5, 1.0]; - let cool = px("ec.color.photofilter", &[("filter", Value::Enum(3)), ("preserveLuminosity", Value::Bool(false))], g); - assert!(cool[2] > cool[0], "a cooling filter is blue: {cool:?}"); - let custom = |c| px("ec.color.photofilter", &[("filter", Value::Enum(PHOTO_FILTER_CUSTOM)), ("color", c)], g); - assert!(custom(col(0.0, 1.0, 0.0))[1] > custom(col(1.0, 0.0, 0.0))[1]); - // The Color swatch is ignored by the presets. - let warm = |c| px("ec.color.photofilter", &[("color", c)], g); - assert_eq!(warm(col(0.0, 1.0, 0.0)), warm(col(1.0, 0.0, 0.0))); - } - - #[test] - fn color_balance_preserve_luminosity_and_black_stretch() { - let g = [0.5, 0.5, 0.5, 1.0]; - let o = px("ec.color.colorbalance", &[("midRed", num(60.0)), ("preserveLuminosity", Value::Bool(true))], g); - assert!((luminance(o[0], o[1], o[2]) - 0.5).abs() < 1e-4 && o[0] > o[2]); - let dark = [0.1, 0.1, 0.1, 1.0]; - assert!(px("ec.color.gammapedestalgain", &[("blackStretch", num(4.0))], dark)[0] > 0.15); - assert!(close(px("ec.color.gammapedestalgain", &[], dark), dark, 1e-6)); - } - - #[test] - fn levels_channel_controls_act_on_their_channel() { - let g = [0.5, 0.5, 0.5, 1.0]; - assert!(close(px("ec.color.levels", &[("channel", Value::Enum(1))], g), g, 1e-6), "the popup alone changes nothing"); - let r = px("ec.color.levels", &[("redOutWhite", num(0.5))], g); - assert!(close(r, [0.25, 0.5, 0.5, 1.0], 1e-5), "{r:?}"); - let b = px("ec.color.levels", &[("blueGamma", num(2.0)), ("inWhite", num(0.5))], [0.25, 0.25, 0.25, 1.0]); - assert!(close(b, [0.5, 0.5, 0.5f32.sqrt(), 1.0], 1e-5), "RGB first, then blue: {b:?}"); - let a = px("ec.color.levels", &[("alphaOutWhite", num(0.5))], [0.5, 0.5, 0.5, 1.0]); - assert!(close(a, [0.25, 0.25, 0.25, 0.5], 1e-5), "{a:?}"); - // Per-channel clipping follows its own popups. - let hot = [0.9, 0.9, 0.9, 1.0]; - let c = px("ec.color.levels", &[("greenInWhite", num(0.5))], hot)[1]; - let nc = px("ec.color.levels", &[("greenInWhite", num(0.5)), ("greenClipToOutputWhite", Value::Enum(0))], hot)[1]; - assert!((c - 1.0).abs() < 1e-6 && (nc - 1.8).abs() < 1e-5, "{c} {nc}"); - } - - #[test] - fn hue_saturation_ranges_adjust_their_hues_only() { - let red = [0.8, 0.1, 0.1, 1.0]; - let blue = [0.1, 0.1, 0.8, 1.0]; - let grey = [0.5, 0.5, 0.5, 1.0]; - let v = [("redsSaturation", num(-100.0))]; - let r = px("ec.color.huesaturation", &v, red); - assert!((r[0] - r[1]).abs() < 1e-4, "reds desaturated: {r:?}"); - assert!(close(px("ec.color.huesaturation", &v, blue), blue, 1e-5)); - assert!(close(px("ec.color.huesaturation", &v, grey), grey, 1e-5)); - // Blues hue +120°: blue becomes red. - let b = px("ec.color.huesaturation", &[("bluesHue", num(120.0))], blue); - assert!(b[0] > 0.7 && b[2] < 0.2, "{b:?}"); - // Fall-off: a hue 15° past the range end (Reds: −15..15, fall-off 30) gets half. - let orange_ish = { - let (r, g, b) = hsl_to_rgb(30.0 / 360.0, 0.8, 0.5); - [r, g, b, 1.0] - }; - let o = px("ec.color.huesaturation", &[("redsLightness", num(100.0))], orange_ish); - let (_, _, l) = rgb_to_hsl(o[0], o[1], o[2]); - assert!((l - 0.75).abs() < 0.01, "half weight: {l}"); - // Moving the range changes which hues it takes. - let o2 = px("ec.color.huesaturation", &[("redsLightness", num(100.0)), ("redsRangeEnd", num(40.0))], orange_ish); - assert!(rgb_to_hsl(o2[0], o2[1], o2[2]).2 > 0.99); - let params = crate::Params { values: [("redsHue".to_string(), num(10.0))].into_iter().collect() }; - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [1.0, 1.0], seed: 0, adjustment: false, env: Default::default() }; - assert!(!huesat_ranges_identity(&ctx)); - } - - #[test] - fn fill_mask_all_masks_feather_and_invert() { - let sq = |x0: f64, x1: f64| crate::MaskShape { - name: String::new(), - points: vec![[x0, 0.0], [x1, 0.0], [x1, 10.0], [x0, 10.0]], - closed: true, - inverted: false, - }; - let masks = [sq(0.0, 5.0), sq(15.0, 20.0)]; - let env = EffectEnv { masks: &masks, ..Default::default() }; - let grey = Image::filled(20, 10, [0.5, 0.5, 0.5, 1.0]); - let run = |vals: &[(&str, Value)]| run_fx("ec.generate.fill", vals, grey.clone(), 0.0, env).img; - let one = run(&[("fillMask", num(1.0))]); - assert_eq!(one.get(2, 5), [1.0, 0.0, 0.0, 1.0]); - assert_eq!(one.get(10, 5), [0.5, 0.5, 0.5, 1.0]); - assert_eq!(one.get(17, 5), [0.5, 0.5, 0.5, 1.0]); - let all = run(&[("allMasks", Value::Bool(true))]); - assert_eq!(all.get(17, 5), [1.0, 0.0, 0.0, 1.0]); - let inv = run(&[("fillMask", num(1.0)), ("invert", Value::Bool(true))]); - assert_eq!(inv.get(2, 5), [0.5, 0.5, 0.5, 1.0]); - assert_eq!(inv.get(10, 5), [1.0, 0.0, 0.0, 1.0]); - let soft = run(&[("fillMask", num(1.0)), ("horizontalFeather", num(6.0))]); - let v = soft.get(6, 5)[1]; - assert!(v > 0.05 && v < 0.5, "feathered edge {v}"); - // Without masks, Fill fills the layer as before. - let plain = run_fx("ec.generate.fill", &[("fillMask", num(1.0))], grey.clone(), 0.0, EffectEnv::default()).img; - assert_eq!(plain.get(10, 5), [1.0, 0.0, 0.0, 1.0]); - } - - /// After Effects 2026: Fill (0, 0.5, 1) at Opacity 50 % on an opaque layer (a Gradient Ramp - /// on a red solid) renders premultiplied (0, 0.251, 0.502) at alpha 0.502 everywhere: the - /// fill colour at half the layer's alpha, nothing of the ramp left. - #[test] - fn fill_opacity_is_the_layers_opacity_as_in_after_effects() { - let mut img = Image::new(16, 4); - for x in 0..16 { - let v = x as f32 / 15.0; - for y in 0..4 { - img.set(x, y, [v, 0.0, 0.0, 1.0]); - } - } - let vals = [("color", Value::Color([0.0, 0.5, 1.0, 1.0])), ("opacity", num(50.0))]; - let out = run_fx("ec.generate.fill", &vals, img, 0.0, EffectEnv::default()).img; - for x in [0, 7, 15] { - let p = out.get(x, 2); - let ae = [0.0, 0.251, 0.502, 0.502]; - assert!(p.iter().zip(ae).all(|(v, a)| (v - a).abs() < 1.0 / 255.0), "x {x}: {p:?} vs AE {ae:?}"); - } - // A half-transparent pixel ends at a quarter alpha, still the fill colour. - let half = run_fx("ec.generate.fill", &vals, Image::filled(2, 2, [0.2, 0.2, 0.2, 0.5]), 0.0, EffectEnv::default()).img; - assert_eq!(half.get(0, 0), [0.0, 0.125, 0.25, 0.25]); - } - - #[test] - fn colorama_palettes_modify_selection_masking_and_add_phase() { - let grey = [0.5, 0.5, 0.5, 1.0]; - let red = [1.0, 0.0, 0.0, 1.0]; - // Ramp Grayscale: intensity 0.5 maps to mid grey. - let g = px("ec.color.colorama", &[("outputCycle/usePresetPalette", Value::Enum(1))], grey); - assert!((g[0] - 0.5).abs() < 0.01 && (g[0] - g[2]).abs() < 1e-5, "{g:?}"); - // Custom palette: two stops, black at 0 and blue at 0.5. - let custom = [("outputCycle/usePresetPalette", Value::Enum(8)), ("outputCycle/palette", Value::Str("0:0,0,0 0.5:0,0,1".into()))]; - let c = px("ec.color.colorama", &custom, grey); - assert!(c[2] > 0.99 && c[0] < 0.01, "{c:?}"); - // Modify Lightness keeps the hue of the pixel (red stays red). - let l = px("ec.color.colorama", &[("modify/modify", Value::Enum(5)), ("outputCycle/usePresetPalette", Value::Enum(1))], red); - assert!(l[0] > l[1] + 0.2 && l[1] < 0.01 + l[2] + 1e-4, "{l:?}"); - // Pixel Selection: only colours near Matching Color change. - let sel = [ - ("pixelSelection/matchingMode", Value::Enum(1)), - ("pixelSelection/matchingColor", col(1.0, 0.0, 0.0)), - ("pixelSelection/matchingTolerance", num(10.0)), - ]; - assert!(close(px("ec.color.colorama", &sel, grey), grey, 1e-6)); - assert!(!close(px("ec.color.colorama", &sel, red), red, 1e-3)); - // Composite Over Layer off: unselected pixels vanish. - let mut off = sel.to_vec(); - off.push(("masking/compositeOverLayer", Value::Bool(false))); - assert_eq!(px("ec.color.colorama", &off, grey)[3], 0.0); - // Modify Alpha takes the palette alpha. - let alpha = [ - ("outputCycle/usePresetPalette", Value::Enum(8)), - ("outputCycle/palette", Value::Str("0:1,1,1,0.25".into())), - ("modify/modifyAlpha", Value::Bool(true)), - ]; - assert!((px("ec.color.colorama", &alpha, grey)[3] - 0.25).abs() < 1e-5); - // Palette parsing and evaluation. - let p = ColoramaPalette::parse("0.5:0,0,1 0:1,0,0").unwrap(); - assert_eq!(p.eval(0.25, true), [0.5, 0.0, 0.5, 1.0]); - assert_eq!(p.eval(0.75, true), [0.5, 0.0, 0.5, 1.0], "wraps around"); - assert_eq!(p.eval(0.25, false), [1.0, 0.0, 0.0, 1.0]); - } -} +mod tests; diff --git a/crates/effects/src/color_fx/tests.rs b/crates/effects/src/color_fx/tests.rs new file mode 100644 index 000000000..2f3370204 --- /dev/null +++ b/crates/effects/src/color_fx/tests.rs @@ -0,0 +1,508 @@ +use super::*; +use crate::{EffectEnv, run_fx}; +use effectcraft_raster::Image; + +fn px(id: &str, vals: &[(&str, Value)], c: [f32; 4]) -> [f32; 4] { + run_fx(id, vals, Image::filled(2, 2, c), 0.0, EffectEnv::default()).img.get(0, 0) +} +fn close(a: [f32; 4], b: [f32; 4], eps: f32) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() <= eps) +} + +/// Projects saved before Use Legacy / the Photo Filter menu existed keep rendering as before. +#[test] +fn old_saves_get_legacy_values_for_added_params() { + use effectcraft_project::build::Ids; + let mut next = 1; + for (id, param, want) in + [("ec.color.brightnesscontrast", "useLegacy", Value::Bool(true)), ("ec.color.photofilter", "filter", Value::Enum(PHOTO_FILTER_CUSTOM))] + { + let spec = crate::find(id).unwrap(); + let mut g = crate::instantiate(spec, &mut Ids(&mut next), spec.name, [10.0, 10.0]); + g.children.retain(|c| c.match_id() != param); + crate::migrate::upgrade_instance(spec, &mut g, &mut Ids(&mut next), [10.0, 10.0]); + assert_eq!(g.get(param).unwrap().value, want, "{id}"); + } +} + +/// After Effects 2026 renders of full-frame solids with Tint at its defaults (sRGB working +/// space, not linearised, 32 bpc, 8-bit capture): Rec. 601 weights on the encoded values. +#[test] +fn tint_defaults_match_after_effects_greys() { + for (c, want) in [([1.0, 0.0, 0.0], 76.0), ([0.0, 1.0, 0.0], 150.0), ([0.0, 0.0, 1.0], 29.0), ([0.5, 0.5, 0.5], 128.0)] { + let o = px("ec.color.tint", &[], [c[0], c[1], c[2], 1.0]); + let want = want / 255.0; + assert!(close(o, [want, want, want, 1.0], 1.0 / 255.0), "{c:?}: {o:?} vs {want}"); + } +} + +#[test] +fn colorama_reads_get_phase_from() { + // Same intensity, different red: Intensity gives one colour, Red gives two. + let a = [0.6, 0.2, 0.2, 1.0]; + let b = [0.2, 0.2, 0.6, 1.0]; + let red = |c| px("ec.color.colorama", &[("inputPhase/mode", Value::Enum(1))], c); + assert!(!close(red(a), red(b), 1e-3)); + // Zero maps everything to the start of the cycle (red). + assert!(close(px("ec.color.colorama", &[("inputPhase/mode", Value::Enum(9))], b), [1.0, 0.0, 0.0, 1.0], 1e-5)); + // Interpolate Palette off posterizes: two nearby inputs land on the same colour. + let off = |v: f32| px("ec.color.colorama", &[("outputCycle/interpolatePalette", Value::Bool(false))], [v, v, v, 1.0]); + assert_eq!(off(0.20), off(0.22)); +} + +#[test] +fn brightness_contrast_modern_keeps_black_and_white_and_legacy_is_linear() { + let bc = |vals: &[(&str, Value)], v: f32| px("ec.color.brightnesscontrast", vals, [v, v, v, 1.0])[0]; + let up = [("brightness", num(50.0)), ("contrast", num(40.0))]; + assert_eq!(bc(&up, 0.0), 0.0); + assert!((bc(&up, 1.0) - 1.0).abs() < 1e-6); + assert!(bc(&up, 0.4) > 0.4); + let legacy = [("brightness", num(50.0)), ("useLegacy", Value::Bool(true))]; + assert!((bc(&legacy, 0.2) - 0.7).abs() < 1e-5); + assert!((bc(&legacy, 0.0) - 0.5).abs() < 1e-5); +} + +/// After Effects non-legacy ramps (kasper9n, #681). B0 C-100 matched B0 C-50 and is omitted. +#[test] +fn brightness_contrast_modern_matches_ae_ramp_samples() { + let u8 = |v: f32| (v * 255.0).round() as i32; + let go = |b: f64, c: f64, x: u8| { + u8(px("ec.color.brightnesscontrast", &[("brightness", num(b)), ("contrast", num(c))], [x as f32 / 255.0, x as f32 / 255.0, x as f32 / 255.0, 1.0])[0]) + }; + // (brightness, contrast, input, AE 8-bit) + let samples: &[(f64, f64, u8, u8)] = &[ + (0.0, 100.0, 64, 40), + (0.0, 100.0, 128, 129), + (0.0, 100.0, 192, 217), + (0.0, 50.0, 64, 53), + (0.0, 50.0, 192, 205), + (0.0, -50.0, 64, 77), + (100.0, 0.0, 128, 209), + (50.0, 0.0, 128, 171), + (106.0, -18.0, 128, 214), + ]; + for &(b, c, x, ae) in samples { + let got = go(b, c, x); + assert!((got - ae as i32).abs() <= 10, "B{b} C{c} in {x}: got {got} AE {ae}"); + } + // Old g=4 S-curve put Contrast 100 at ~8/255 here. + assert!(go(0.0, 100.0, 64) >= 30); +} + +/// After Effects 2026: Tritone Highlights [1, 1, 0.8], Midtones [0.6, 0.2, 0.2], Shadows +/// [0, 0, 0.3]. On a red solid with Blend With Original 25 (case 718) AE gives (132, 23, 46); +/// on a grey ramp (case 815) levels 0, 63.9, 127.6, 191.4 give (0, 0, 77), (77, 26, 64), +/// (153, 51, 51), (204, 153, 128). +#[test] +fn tritone_matches_after_effects() { + let colours = + [("highlights", Value::Color([1.0, 1.0, 0.8, 1.0])), ("midtones", Value::Color([0.6, 0.2, 0.2, 1.0])), ("shadows", Value::Color([0.0, 0.0, 0.3, 1.0]))]; + let near = |o: [f32; 4], ae: [f32; 3]| (0..3).all(|i| (o[i] * 255.0 - ae[i]).abs() <= 1.0); + let mut red = colours.to_vec(); + red.push(("blend", num(25.0))); + let o = px("ec.color.tritone", &red, [1.0, 0.0, 0.0, 1.0]); + assert!(near(o, [132.0, 23.0, 46.0]), "{:?}", o.map(|v| v * 255.0)); + for (level, ae) in [(0.1f32, [0.0, 0.0, 77.0]), (63.9, [77.0, 26.0, 64.0]), (127.6, [153.0, 51.0, 51.0]), (191.4, [204.0, 153.0, 128.0])] { + let v = level / 255.0; + let o = px("ec.color.tritone", &colours, [v, v, v, 1.0]); + assert!(near(o, ae), "level {level}: {:?} vs {ae:?}", o.map(|v| v * 255.0)); + } +} + +/// After Effects 2026, Tint Color [1, 0.5, 0]: a red solid at Reds 10 (case 717) and yellow +/// and white solids at the default sliders (case 825). +#[test] +fn black_white_tint_matches_after_effects() { + let tint = [("tint", Value::Bool(true)), ("tintColor", Value::Color([1.0, 0.5, 0.0, 1.0]))]; + let near = |o: [f32; 4], ae: [f32; 3]| (0..3).all(|i| (o[i] * 255.0 - ae[i]).abs() <= 1.5); + let mut reds = tint.to_vec(); + reds.push(("reds", num(10.0))); + let o = px("ec.color.blackwhite", &reds, [1.0, 0.0, 0.0, 1.0]); + assert!(near(o, [43.0, 21.0, 0.0]), "{:?}", o.map(|v| v * 255.0)); + let o = px("ec.color.blackwhite", &tint, [1.0, 1.0, 0.0, 1.0]); + assert!(near(o, [255.0, 129.0, 2.0]), "{:?}", o.map(|v| v * 255.0)); + let o = px("ec.color.blackwhite", &tint, [1.0, 1.0, 1.0, 1.0]); + assert!(near(o, [255.0, 255.0, 255.0]), "{:?}", o.map(|v| v * 255.0)); + // Hostile grays stay finite. + for g in [f32::NAN, -1.0, 0.0, 4.0] { + assert!(bw_tint([1.0, 0.5, 0.0], g).iter().all(|v| v.is_finite())); + } +} + +/// After Effects 2026 ramps (RENDERER round 2, session R8): negative Brightness and Contrast +/// are the inverses of the positive curves. (brightness, contrast, input, AE 8-bit) +#[test] +fn negative_brightness_and_contrast_invert_the_positive_curves() { + let go = |b: f64, c: f64, x: u8| { + let v = x as f32 / 255.0; + (px("ec.color.brightnesscontrast", &[("brightness", num(b)), ("contrast", num(c))], [v, v, v, 1.0])[0] * 255.0).round() as i32 + }; + let samples: &[(f64, f64, u8, u8)] = &[ + (-40.0, 0.0, 64, 50), + (-40.0, 0.0, 128, 99), + (-40.0, 0.0, 191, 155), + (-40.0, 0.0, 234, 207), + (0.0, -50.0, 21, 28), + (0.0, -50.0, 64, 76), + (0.0, -50.0, 191, 179), + (0.0, -50.0, 234, 227), + (40.0, 0.0, 128, 162), + (0.0, 50.0, 64, 52), + ]; + for &(b, c, x, ae) in samples { + let got = go(b, c, x); + assert!((got - ae as i32).abs() <= 7, "B{b} C{c} in {x}: got {got} AE {ae}"); + } +} + +/// #1463: Black & White's sliders set the gray of each pure hue and blend between +/// neighbouring hues; neutral pixels keep their value, overbright ones too, and an overbright +/// colour stays finite (it used to divide by zero). +#[test] +fn black_white_sliders_set_the_gray_of_each_hue() { + let bw = |vals: &[(&str, Value)], c: [f32; 3]| px("ec.color.blackwhite", vals, [c[0], c[1], c[2], 1.0]); + let gray = |v: f32| [v, v, v, 1.0]; + // The defaults: Reds 40, Yellows 60, Greens 40, Cyans 60, Blues 20, Magentas 80 (%). + let hues = [ + ([1.0, 0.0, 0.0], 0.4), + ([1.0, 1.0, 0.0], 0.6), + ([0.0, 1.0, 0.0], 0.4), + ([0.0, 1.0, 1.0], 0.6), + ([0.0, 0.0, 1.0], 0.2), + ([1.0, 0.0, 1.0], 0.8), + // Half as bright, half the gray; orange is halfway from Reds to Yellows. + ([0.5, 0.0, 0.0], 0.2), + ([1.0, 0.5, 0.0], 0.5), + // Neutral pixels keep their value. + ([0.3, 0.3, 0.3], 0.3), + ([4.0, 4.0, 4.0], 4.0), + // An overbright red: its smallest channel plus its chroma at Reds' 40 %. + ([1.5, 0.5, 0.5], 0.9), + ]; + for (c, want) in hues { + let o = bw(&[], c); + assert!(close(o, gray(want), 1e-5), "{c:?}: {o:?}, want {want}"); + } + assert!(close(bw(&[("reds", num(120.0))], [1.0, 0.0, 0.0]), gray(1.2), 1e-5)); + assert!(close(bw(&[("reds", num(-200.0))], [1.0, 0.0, 0.0]), gray(0.0), 1e-5)); + assert!(close(bw(&[("blues", num(300.0))], [0.1, 0.1, 0.3]), gray(0.7), 1e-5)); +} + +/// #1463: faint colour noise stays faint. The shadows of compressed footage carry a code or +/// two of colour noise, which Black & White turned into bright and black blocks (the HSL +/// saturation it scaled is near 1 for any tinted near-black pixel). At 8, 16 and 32 bpc the +/// gray of a dark ramp with ±2 steps of colour noise stays within 2 steps of the clean ramp's. +#[test] +fn black_white_keeps_faint_colour_noise_faint() { + // A step of 8 bpc, of 16 bpc (After Effects' 0..32768), and a small 32-bpc float one. + for step in [1.0 / 255.0, 1.0 / 32768.0, 1e-3_f32] { + let (w, h) = (64u32, 4u32); + let (mut clean, mut noisy) = (Image::new(w, h), Image::new(w, h)); + for y in 0..h { + for x in 0..w { + // A dark ramp up to a quarter gray, in whole steps... + let v = (x as f32 / w as f32 * 0.25 / step).round() * step; + // ...and up to ±2 steps of noise per channel, mixed differently in each pixel. + let n = |k: u32| (((x * 7 + y * 13 + k * 2) % 5) as f32 - 2.0) * step; + clean.set(x, y, [v, v, v, 1.0]); + noisy.set(x, y, [(v + n(0)).max(0.0), (v + n(1)).max(0.0), (v + n(2)).max(0.0), 1.0]); + } + } + let bw = |img| run_fx("ec.color.blackwhite", &[], img, 0.0, EffectEnv::default()).img; + let (a, b) = (bw(clean), bw(noisy)); + let worst = a.data.iter().zip(&b.data).map(|(p, q)| (p[0] - q[0]).abs()).fold(0.0, f32::max); + assert!(worst <= 2.0 * step + 1e-6, "step {step}: the noise moved the gray by {} steps", worst / step); + } +} + +#[test] +fn levels_clips_black_and_white_independently() { + let lv = |vals: &[(&str, Value)], v: f32| px("ec.color.levels", vals, [v, v, v, 1.0])[0]; + let base = [("inBlack", num(0.2)), ("inWhite", num(0.6)), ("outBlack", num(0.1)), ("outWhite", num(0.9))]; + assert!((lv(&base, 0.9) - 0.9).abs() < 1e-5); + let mut no_white = base.to_vec(); + no_white.push(("clipToOutputWhite", Value::Enum(0))); + assert!(lv(&no_white, 0.9) > 0.9); + assert!((lv(&no_white, 0.0) - 0.1).abs() < 1e-5); + // The hidden switch of older projects turns both off. + let mut legacy = base.to_vec(); + legacy.push(("noClip", Value::Bool(true))); + assert!(lv(&legacy, 0.9) > 0.9); +} + +#[test] +fn exposure_individual_channels_and_bypass() { + let g = [0.5, 0.5, 0.5, 1.0]; + let one = px("ec.color.exposure", &[("channels", Value::Enum(1)), ("red/redExposure", num(1.0))], g); + assert!(one[0] > 0.6 && (one[1] - 0.5).abs() < 1e-5 && (one[2] - 0.5).abs() < 1e-5); + // Master values are ignored in Individual Channels mode. + assert!(close(px("ec.color.exposure", &[("channels", Value::Enum(1)), ("master/exposure", num(2.0))], g), g, 1e-5)); + // Bypass Linear Light Conversion: +1 stop doubles the raw value. + let raw = px("ec.color.exposure", &[("master/exposure", num(1.0)), ("bypassLinearLight", Value::Bool(true))], [0.25, 0.25, 0.25, 1.0]); + assert!((raw[0] - 0.5).abs() < 1e-5); + // Negative results are mirrored, not clipped. + let neg = + px("ec.color.exposure", &[("master/offset", num(-0.5)), ("bypassLinearLight", Value::Bool(true)), ("master/gamma", num(2.0))], [0.25, 0.25, 0.25, 1.0]); + assert!((neg[0] + 0.5).abs() < 1e-4, "{neg:?}"); +} + +/// After Effects' Exposure ranges for all twelve sliders; a saved value outside them (a gamma +/// of 0.05, valid before, as in Photoshop) survives loading and still renders. +#[test] +fn exposure_has_after_effects_ranges_and_keeps_old_values() { + use effectcraft_project::build::Ids; + let spec = crate::find("ec.color.exposure").unwrap(); + let want = [(-100.0, 100.0, -4.0, 4.0, 2), (-2.0, 2.0, -0.5, 0.5, 4), (0.1, 10.0, 0.1, 10.0, 2)]; + let sliders: Vec<_> = spec + .params + .iter() + .filter_map(|p| match p.ui { + ParamUi::Slider { min, max, slider_min, slider_max, decimals } => Some((p.id, (min, max, slider_min, slider_max, decimals))), + _ => None, + }) + .collect(); + assert_eq!(sliders.len(), 12); + for (i, (id, r)) in sliders.iter().enumerate() { + assert_eq!(*r, want[i % 3], "{id}"); + } + let mut next = 1; + let mut g = crate::instantiate(spec, &mut Ids(&mut next), spec.name, [10.0, 10.0]); + let gamma = g.prop_mut("master/gamma").unwrap(); + gamma.value = num(0.05); + gamma.ui = slider(0.01, 9.99, 0.1, 3.0, 2); + crate::migrate::upgrade_instance(spec, &mut g, &mut Ids(&mut next), [10.0, 10.0]); + let gamma = g.prop_mut("master/gamma").unwrap(); + assert_eq!(gamma.value, num(0.05)); + assert!(matches!(gamma.ui, ParamUi::Slider { min, .. } if min == 0.1)); + let o = px("ec.color.exposure", &[("master/gamma", num(0.05)), ("bypassLinearLight", Value::Bool(true))], [0.5, 0.5, 0.5, 1.0]); + assert!((o[0] - 0.5f32.powf(20.0)).abs() < 1e-9, "{o:?}"); +} + +/// Exposure on a red [1, 0, 0] solid against After Effects 2026 renders (sRGB working space, +/// not linearised, 32 bpc, 8-bit captures): the linear-light conversion is a pure 2.2 power. +/// The piecewise sRGB curve gave 2 / 7 / 39 for the three smallest Offsets. +#[test] +fn exposure_matches_after_effects_gamma_2_2() { + let red = [1.0, 0.0, 0.0, 1.0]; + let code = |o: [f32; 4], i: usize| o[i] * 255.0; + // Master Offset only: G and B carry the curve; R goes over 1 (255 in the 8-bit capture). + for (offset, ae) in [(0.0005, 8.0), (0.002, 15.0), (0.02, 43.0), (0.1, 90.0)] { + let o = px("ec.color.exposure", &[("master/offset", num(offset))], red); + for i in [1, 2] { + assert!((code(o, i) - ae).abs() <= 1.0, "offset {offset}: {} vs AE {ae}", code(o, i)); + } + assert!(o[0] >= 1.0, "{o:?}"); + } + // Exposure −2.25: AE 126; with Bypass Linear Light Conversion (gain on encoded values) 54. + let o = px("ec.color.exposure", &[("master/exposure", num(-2.25))], red); + assert!((code(o, 0) - 126.0).abs() <= 1.0 && o[1] == 0.0, "{o:?}"); + let o = px("ec.color.exposure", &[("master/exposure", num(-2.25)), ("bypassLinearLight", Value::Bool(true))], red); + assert!((code(o, 0) - 54.0).abs() <= 1.0 && o[1] == 0.0, "{o:?}"); + // Default settings leave 32-bpc values alone, negative and over-range ones included. + let hdr = [-0.25, 0.5, 2.0, 1.0]; + assert!(close(px("ec.color.exposure", &[], hdr), hdr, 1e-5)); + // A negative Offset pushes black below zero, mirrored, rather than clipping or NaN. + let o = px("ec.color.exposure", &[("master/offset", num(-0.02))], [0.0, 0.0, 0.0, 1.0]); + assert!((o[0] + 0.02f32.powf(1.0 / 2.2)).abs() < 1e-5, "{o:?}"); +} + +#[test] +fn change_to_color_change_modes_tolerances_and_matte() { + let red = [0.8, 0.1, 0.1, 1.0]; + let green = [0.1, 0.8, 0.1, 1.0]; + let base = [("from", col(0.8, 0.1, 0.1)), ("to", col(0.1, 0.1, 0.8))]; + // Hue only: red turns blue with its own lightness / saturation; green is untouched. + let o = px("ec.color.changetocolor", &base, red); + assert!(o[2] > 0.7 && o[0] < 0.2, "{o:?}"); + assert!(close(px("ec.color.changetocolor", &base, green), green, 1e-5)); + // The matte is white where matched, black elsewhere. + let mut m = base.to_vec(); + m.push(("viewCorrectionMatte", Value::Bool(true))); + assert!(close(px("ec.color.changetocolor", &m, red), [1.0, 1.0, 1.0, 1.0], 1e-5)); + assert!(close(px("ec.color.changetocolor", &m, green), [0.0, 0.0, 0.0, 1.0], 1e-5)); + // A lightness tolerance of 0 rejects a lighter red. + let mut strict = m.clone(); + strict.push(("toleranceGroup/lightness", num(0.0))); + strict.push(("softness", num(0.0))); + assert!(close(px("ec.color.changetocolor", &strict, [1.0, 0.5, 0.5, 1.0]), [0.0, 0.0, 0.0, 1.0], 1e-5)); + // Hue, Lightness & Saturation, set: a matched pixel becomes the To colour. + let mut all = base.to_vec(); + all.push(("change", Value::Enum(3))); + assert!(close(px("ec.color.changetocolor", &all, red), [0.1, 0.1, 0.8, 1.0], 1e-4)); +} + +#[test] +fn leave_color_tolerance_softness_and_match_mode() { + let green = [0.1, 0.8, 0.1, 1.0]; + let lc = |vals: &[(&str, Value)]| { + let mut v = vec![("amount", num(100.0))]; + v.extend_from_slice(vals); + px("ec.color.leavecolor", &v, green) + }; + let g = luminance(0.1, 0.8, 0.1); + assert!(close(lc(&[]), [g, g, g, 1.0], 1e-5)); + assert!(close(lc(&[("tolerance", num(100.0))]), green, 1e-6)); + // Softness keeps part of the colour. + let s = lc(&[("tolerance", num(10.0)), ("edgeSoftness", num(100.0))]); + assert!(s[1] > g + 0.05 && s[1] < 0.8); + // Using Hue: a dark red still matches pure red. + let dark = px("ec.color.leavecolor", &[("amount", num(100.0)), ("tolerance", num(5.0)), ("matchColors", Value::Enum(1))], [0.4, 0.0, 0.0, 1.0]); + assert!(close(dark, [0.4, 0.0, 0.0, 1.0], 1e-6)); +} + +#[test] +fn photo_filter_presets_and_custom() { + let g = [0.5, 0.5, 0.5, 1.0]; + let cool = px("ec.color.photofilter", &[("filter", Value::Enum(3)), ("preserveLuminosity", Value::Bool(false))], g); + assert!(cool[2] > cool[0], "a cooling filter is blue: {cool:?}"); + let custom = |c| px("ec.color.photofilter", &[("filter", Value::Enum(PHOTO_FILTER_CUSTOM)), ("color", c)], g); + assert!(custom(col(0.0, 1.0, 0.0))[1] > custom(col(1.0, 0.0, 0.0))[1]); + // The Color swatch is ignored by the presets. + let warm = |c| px("ec.color.photofilter", &[("color", c)], g); + assert_eq!(warm(col(0.0, 1.0, 0.0)), warm(col(1.0, 0.0, 0.0))); +} + +#[test] +fn color_balance_preserve_luminosity_and_black_stretch() { + let g = [0.5, 0.5, 0.5, 1.0]; + let o = px("ec.color.colorbalance", &[("midRed", num(60.0)), ("preserveLuminosity", Value::Bool(true))], g); + assert!((luminance(o[0], o[1], o[2]) - 0.5).abs() < 1e-4 && o[0] > o[2]); + let dark = [0.1, 0.1, 0.1, 1.0]; + assert!(px("ec.color.gammapedestalgain", &[("blackStretch", num(4.0))], dark)[0] > 0.15); + assert!(close(px("ec.color.gammapedestalgain", &[], dark), dark, 1e-6)); +} + +#[test] +fn levels_channel_controls_act_on_their_channel() { + let g = [0.5, 0.5, 0.5, 1.0]; + assert!(close(px("ec.color.levels", &[("channel", Value::Enum(1))], g), g, 1e-6), "the popup alone changes nothing"); + let r = px("ec.color.levels", &[("redOutWhite", num(0.5))], g); + assert!(close(r, [0.25, 0.5, 0.5, 1.0], 1e-5), "{r:?}"); + let b = px("ec.color.levels", &[("blueGamma", num(2.0)), ("inWhite", num(0.5))], [0.25, 0.25, 0.25, 1.0]); + assert!(close(b, [0.5, 0.5, 0.5f32.sqrt(), 1.0], 1e-5), "RGB first, then blue: {b:?}"); + let a = px("ec.color.levels", &[("alphaOutWhite", num(0.5))], [0.5, 0.5, 0.5, 1.0]); + assert!(close(a, [0.25, 0.25, 0.25, 0.5], 1e-5), "{a:?}"); + // Per-channel clipping follows its own popups. + let hot = [0.9, 0.9, 0.9, 1.0]; + let c = px("ec.color.levels", &[("greenInWhite", num(0.5))], hot)[1]; + let nc = px("ec.color.levels", &[("greenInWhite", num(0.5)), ("greenClipToOutputWhite", Value::Enum(0))], hot)[1]; + assert!((c - 1.0).abs() < 1e-6 && (nc - 1.8).abs() < 1e-5, "{c} {nc}"); +} + +#[test] +fn hue_saturation_ranges_adjust_their_hues_only() { + let red = [0.8, 0.1, 0.1, 1.0]; + let blue = [0.1, 0.1, 0.8, 1.0]; + let grey = [0.5, 0.5, 0.5, 1.0]; + let v = [("redsSaturation", num(-100.0))]; + let r = px("ec.color.huesaturation", &v, red); + assert!((r[0] - r[1]).abs() < 1e-4, "reds desaturated: {r:?}"); + assert!(close(px("ec.color.huesaturation", &v, blue), blue, 1e-5)); + assert!(close(px("ec.color.huesaturation", &v, grey), grey, 1e-5)); + // Blues hue +120°: blue becomes red. + let b = px("ec.color.huesaturation", &[("bluesHue", num(120.0))], blue); + assert!(b[0] > 0.7 && b[2] < 0.2, "{b:?}"); + // Fall-off: a hue 15° past the range end (Reds: −15..15, fall-off 30) gets half. + let orange_ish = { + let (r, g, b) = hsl_to_rgb(30.0 / 360.0, 0.8, 0.5); + [r, g, b, 1.0] + }; + let o = px("ec.color.huesaturation", &[("redsLightness", num(100.0))], orange_ish); + let (_, _, l) = rgb_to_hsl(o[0], o[1], o[2]); + assert!((l - 0.75).abs() < 0.01, "half weight: {l}"); + // Moving the range changes which hues it takes. + let o2 = px("ec.color.huesaturation", &[("redsLightness", num(100.0)), ("redsRangeEnd", num(40.0))], orange_ish); + assert!(rgb_to_hsl(o2[0], o2[1], o2[2]).2 > 0.99); + let params = crate::Params { values: [("redsHue".to_string(), num(10.0))].into_iter().collect() }; + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [1.0, 1.0], seed: 0, adjustment: false, env: Default::default() }; + assert!(!huesat_ranges_identity(&ctx)); +} + +#[test] +fn fill_mask_all_masks_feather_and_invert() { + let sq = + |x0: f64, x1: f64| crate::MaskShape { name: String::new(), points: vec![[x0, 0.0], [x1, 0.0], [x1, 10.0], [x0, 10.0]], closed: true, inverted: false }; + let masks = [sq(0.0, 5.0), sq(15.0, 20.0)]; + let env = EffectEnv { masks: &masks, ..Default::default() }; + let grey = Image::filled(20, 10, [0.5, 0.5, 0.5, 1.0]); + let run = |vals: &[(&str, Value)]| run_fx("ec.generate.fill", vals, grey.clone(), 0.0, env).img; + let one = run(&[("fillMask", num(1.0))]); + assert_eq!(one.get(2, 5), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(one.get(10, 5), [0.5, 0.5, 0.5, 1.0]); + assert_eq!(one.get(17, 5), [0.5, 0.5, 0.5, 1.0]); + let all = run(&[("allMasks", Value::Bool(true))]); + assert_eq!(all.get(17, 5), [1.0, 0.0, 0.0, 1.0]); + let inv = run(&[("fillMask", num(1.0)), ("invert", Value::Bool(true))]); + assert_eq!(inv.get(2, 5), [0.5, 0.5, 0.5, 1.0]); + assert_eq!(inv.get(10, 5), [1.0, 0.0, 0.0, 1.0]); + let soft = run(&[("fillMask", num(1.0)), ("horizontalFeather", num(6.0))]); + let v = soft.get(6, 5)[1]; + assert!(v > 0.05 && v < 0.5, "feathered edge {v}"); + // Without masks, Fill fills the layer as before. + let plain = run_fx("ec.generate.fill", &[("fillMask", num(1.0))], grey.clone(), 0.0, EffectEnv::default()).img; + assert_eq!(plain.get(10, 5), [1.0, 0.0, 0.0, 1.0]); +} + +/// After Effects 2026: Fill (0, 0.5, 1) at Opacity 50 % on an opaque layer (a Gradient Ramp +/// on a red solid) renders premultiplied (0, 0.251, 0.502) at alpha 0.502 everywhere: the +/// fill colour at half the layer's alpha, nothing of the ramp left. +#[test] +fn fill_opacity_is_the_layers_opacity_as_in_after_effects() { + let mut img = Image::new(16, 4); + for x in 0..16 { + let v = x as f32 / 15.0; + for y in 0..4 { + img.set(x, y, [v, 0.0, 0.0, 1.0]); + } + } + let vals = [("color", Value::Color([0.0, 0.5, 1.0, 1.0])), ("opacity", num(50.0))]; + let out = run_fx("ec.generate.fill", &vals, img, 0.0, EffectEnv::default()).img; + for x in [0, 7, 15] { + let p = out.get(x, 2); + let ae = [0.0, 0.251, 0.502, 0.502]; + assert!(p.iter().zip(ae).all(|(v, a)| (v - a).abs() < 1.0 / 255.0), "x {x}: {p:?} vs AE {ae:?}"); + } + // A half-transparent pixel ends at a quarter alpha, still the fill colour. + let half = run_fx("ec.generate.fill", &vals, Image::filled(2, 2, [0.2, 0.2, 0.2, 0.5]), 0.0, EffectEnv::default()).img; + assert_eq!(half.get(0, 0), [0.0, 0.125, 0.25, 0.25]); +} + +#[test] +fn colorama_palettes_modify_selection_masking_and_add_phase() { + let grey = [0.5, 0.5, 0.5, 1.0]; + let red = [1.0, 0.0, 0.0, 1.0]; + // Ramp Grayscale: intensity 0.5 maps to mid grey. + let g = px("ec.color.colorama", &[("outputCycle/usePresetPalette", Value::Enum(1))], grey); + assert!((g[0] - 0.5).abs() < 0.01 && (g[0] - g[2]).abs() < 1e-5, "{g:?}"); + // Custom palette: two stops, black at 0 and blue at 0.5. + let custom = [("outputCycle/usePresetPalette", Value::Enum(8)), ("outputCycle/palette", Value::Str("0:0,0,0 0.5:0,0,1".into()))]; + let c = px("ec.color.colorama", &custom, grey); + assert!(c[2] > 0.99 && c[0] < 0.01, "{c:?}"); + // Modify Lightness keeps the hue of the pixel (red stays red). + let l = px("ec.color.colorama", &[("modify/modify", Value::Enum(5)), ("outputCycle/usePresetPalette", Value::Enum(1))], red); + assert!(l[0] > l[1] + 0.2 && l[1] < 0.01 + l[2] + 1e-4, "{l:?}"); + // Pixel Selection: only colours near Matching Color change. + let sel = [ + ("pixelSelection/matchingMode", Value::Enum(1)), + ("pixelSelection/matchingColor", col(1.0, 0.0, 0.0)), + ("pixelSelection/matchingTolerance", num(10.0)), + ]; + assert!(close(px("ec.color.colorama", &sel, grey), grey, 1e-6)); + assert!(!close(px("ec.color.colorama", &sel, red), red, 1e-3)); + // Composite Over Layer off: unselected pixels vanish. + let mut off = sel.to_vec(); + off.push(("masking/compositeOverLayer", Value::Bool(false))); + assert_eq!(px("ec.color.colorama", &off, grey)[3], 0.0); + // Modify Alpha takes the palette alpha. + let alpha = [ + ("outputCycle/usePresetPalette", Value::Enum(8)), + ("outputCycle/palette", Value::Str("0:1,1,1,0.25".into())), + ("modify/modifyAlpha", Value::Bool(true)), + ]; + assert!((px("ec.color.colorama", &alpha, grey)[3] - 0.25).abs() < 1e-5); + // Palette parsing and evaluation. + let p = ColoramaPalette::parse("0.5:0,0,1 0:1,0,0").unwrap(); + assert_eq!(p.eval(0.25, true), [0.5, 0.0, 0.5, 1.0]); + assert_eq!(p.eval(0.75, true), [0.5, 0.0, 0.5, 1.0], "wraps around"); + assert_eq!(p.eval(0.25, false), [1.0, 0.0, 0.0, 1.0]); +} diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index 534da8061..916e162f6 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -275,27 +275,70 @@ pub fn transform_shutter(ctx: &EffectCtx) -> Option<(f64, f64, u32)> { (angle > 0.0).then_some((angle, phase, n.clamp(2, 64))) } -fn transform_matrix(pr: &crate::Params, b: &Buf) -> Mat3 { - let anchor = b.to_px(pr.v2("anchor")); - let pos = b.to_px(pr.v2("position")); +/// The Transform effect's matrix in buffer pixels (`to_px` maps layer pixels into the buffer), +/// as After Effects 2026 draws it (cases 723, 819, 820): `position + K · R · S · (p − anchor)` +/// with the skew `K = rot(−axis) · shearY(tan skew) · rot(axis)` applied after the rotation. +pub fn transform_matrix_px(pr: &crate::Params, to_px: impl Fn([f64; 2]) -> (f64, f64)) -> Mat3 { + let anchor = to_px(pr.v2("anchor")); + let pos = to_px(pr.v2("position")); let sh = pr.f("scaleHeight"); let sw = if pr.b("uniform") { sh } else { pr.f("scaleWidth") }; + let axis = pr.f("skewAxis"); + let k = pr.f("skew").clamp(-85.0, 85.0).to_radians().tan(); + let k = if k.is_finite() { k } else { 0.0 }; + let skew = Mat3::rotate_deg(-axis) * Mat3([[1.0, 0.0, 0.0], [k, 1.0, 0.0], [0.0, 0.0, 1.0]]) * Mat3::rotate_deg(axis); Mat3::translate(vec2(pos.0, pos.1)) + * skew * Mat3::rotate_deg(pr.f("rotation")) - * Mat3::skew_deg(pr.f("skew"), pr.f("skewAxis")) * Mat3::scale(vec2(sw / 100.0, sh / 100.0)) * Mat3::translate(vec2(-anchor.0, -anchor.1)) } +fn transform_matrix(pr: &crate::Params, b: &Buf) -> Mat3 { + transform_matrix_px(pr, |p| b.to_px(p)) +} + +/// The transparent margins (left, top, right, bottom) a `w` x `h` buffer needs so its warp by +/// any of `ms` stays on it: After Effects draws Transform's result outside the layer's bounds +/// (case 723). Each side grows by its own overshoot; the grown buffer is capped at 16 384 px a +/// side and 16 Mpx in all (the margins shrink in proportion), so a layer moved far away costs +/// a bounded allocation. +pub fn transform_grow(ms: &[Mat3], w: u32, h: u32) -> [u32; 4] { + const SIDE: f64 = 16_384.0; + const AREA: f64 = 16_777_216.0; + let (wf, hf) = (w as f64, h as f64); + let mut m = [0.0f64; 4]; + for mat in ms { + for (x, y) in [(0.0, 0.0), (wf, 0.0), (0.0, hf), (wf, hf)] { + let q = mat.apply(vec2(x, y)); + if q.x.is_finite() && q.y.is_finite() { + m = [m[0].max(-q.x), m[1].max(-q.y), m[2].max(q.x - wf), m[3].max(q.y - hf)]; + } + } + } + let m = m.map(|v| v.ceil().max(0.0)); + let fit = |len: f64, a: f64, b: f64| -> f64 { + let room = (SIDE - len).max(0.0); + if a + b > room && a + b > 0.0 { room / (a + b) } else { 1.0 } + }; + let kx = fit(wf, m[0], m[2]); + let ky = fit(hf, m[1], m[3]); + let area = |k: f64| (wf + (m[0] + m[2]) * kx * k) * (hf + (m[1] + m[3]) * ky * k); + let k = if area(1.0) > AREA && area(1.0) > wf * hf { + // Shrink both margins by the same factor until the area fits. + let (mut lo, mut hi) = (0.0f64, 1.0f64); + for _ in 0..40 { + let mid = (lo + hi) / 2.0; + if area(mid) > AREA { hi = mid } else { lo = mid } + } + lo + } else { + 1.0 + }; + [m[0] * kx * k, m[1] * ky * k, m[2] * kx * k, m[3] * ky * k].map(|v| v.floor().clamp(0.0, SIDE) as u32) +} + fn transform(ctx: &EffectCtx, mut b: Buf) -> Buf { - let anchor = b.to_px(ctx.params.v2("anchor")); - let pos = b.to_px(ctx.params.v2("position")); - let uniform = ctx.params.b("uniform"); - let sh = ctx.params.f("scaleHeight"); - let sw = if uniform { sh } else { ctx.params.f("scaleWidth") }; - let skew = ctx.params.f("skew"); - let skew_axis = ctx.params.f("skewAxis"); - let rot = ctx.params.f("rotation"); let opacity = ctx.params.f("opacity") as f32 / 100.0; let sampling = if ctx.params.e("sampling") == 1 { effectcraft_raster::Sampling::Bicubic } else { effectcraft_raster::Sampling::Bilinear }; // Motion blur: the transform at Samples instants across the shutter, averaged (the @@ -304,33 +347,35 @@ fn transform(ctx: &EffectCtx, mut b: Buf) -> Buf { && let Some(host) = ctx.env.host { let fd = 1.0 / ctx.fps(); - let mut acc = Image::new(b.img.width, b.img.height); - let mut count = 0.0f32; - for k in 0..n { - let t = ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd; - let Some(pr) = host.params_at(t) else { continue }; - let mut one = Image::new(b.img.width, b.img.height); - let op = pr.f("opacity") as f32 / 100.0; - effectcraft_raster::composite_warp( - &mut one, - &b.img, - &transform_matrix(&pr, &b), - &effectcraft_raster::WarpOpts { opacity: op, sampling, ..Default::default() }, - ); - acc.data.iter_mut().zip(&one.data).for_each(|(a, o)| (0..4).for_each(|c| a[c] += o[c])); - count += 1.0; - } - if count > 0.0 { + let samples: Vec = (0..n).filter_map(|k| host.params_at(ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd)).collect(); + if !samples.is_empty() { + if !ctx.adjustment { + let ms: Vec = samples.iter().map(|pr| transform_matrix(pr, &b)).collect(); + b.pad_sides(transform_grow(&ms, b.img.width, b.img.height)); + } + let mut acc = Image::new(b.img.width, b.img.height); + for pr in &samples { + let mut one = Image::new(b.img.width, b.img.height); + let op = pr.f("opacity") as f32 / 100.0; + effectcraft_raster::composite_warp( + &mut one, + &b.img, + &transform_matrix(pr, &b), + &effectcraft_raster::WarpOpts { opacity: op, sampling, ..Default::default() }, + ); + acc.data.iter_mut().zip(&one.data).for_each(|(a, o)| (0..4).for_each(|c| a[c] += o[c])); + } + let count = samples.len() as f32; acc.data.iter_mut().for_each(|p| p.iter_mut().for_each(|v| *v /= count)); b.img = acc; return b; } } - let m = Mat3::translate(vec2(pos.0, pos.1)) - * Mat3::rotate_deg(rot) - * Mat3::skew_deg(skew, skew_axis) - * Mat3::scale(vec2(sw / 100.0, sh / 100.0)) - * Mat3::translate(vec2(-anchor.0, -anchor.1)); + if !ctx.adjustment { + let m = transform_matrix(ctx.params, &b); + b.pad_sides(transform_grow(&[m], b.img.width, b.img.height)); + } + let m = transform_matrix(ctx.params, &b); let mut out = Image::new(b.img.width, b.img.height); effectcraft_raster::composite_warp(&mut out, &b.img, &m, &effectcraft_raster::WarpOpts { opacity, sampling, ..Default::default() }); b.img = out; @@ -576,6 +621,144 @@ mod tests { } } + /// After Effects 2026 (cases 723, 819, 820): a grey 960 × 540 solid centred in a 1920 × 1080 + /// comp with Transform. The skew applies after the rotation, and the result is drawn outside + /// the layer's bounds. Points are comp pixels sampled from AE's renders (at least 3 px from + /// an edge; true = the solid covers it), half where the old order and clipping differed. + #[test] + fn transform_matches_after_effects() { + let solid = Image::filled(960, 540, [0.5, 0.5, 0.5, 1.0]); + let p2 = |x: f64, y: f64| Value::Vec2([x, y]); + let cases: [(&str, Vec<(&str, Value)>, &[(u32, u32, bool)]); 3] = [ + ( + "723", + vec![ + ("anchor", p2(400.0, 200.0)), + ("position", p2(500.0, 300.0)), + ("uniform", Value::Bool(false)), + ("scaleHeight", num(80.0)), + ("scaleWidth", num(120.0)), + ("skew", num(10.0)), + ("skewAxis", num(20.0)), + ("rotation", num(30.0)), + ], + &[ + (1465, 954, true), + (1127, 821, true), + (1378, 846, true), + (1416, 878, true), + (1486, 850, true), + (492, 467, true), + (706, 239, true), + (1402, 1021, true), + (518, 440, true), + (1218, 882, true), + (454, 448, true), + (1473, 920, true), + (1048, 654, true), + (1050, 486, true), + (1060, 736, true), + (932, 804, true), + (1074, 562, true), + (653, 591, true), + (1353, 466, false), + (88, 1021, false), + (1855, 886, false), + (1796, 401, false), + (497, 913, false), + (1445, 515, false), + ], + ), + ( + "819", + vec![("skew", num(15.0)), ("skewAxis", num(60.0))], + &[ + (465, 367, true), + (554, 793, false), + (481, 529, true), + (1451, 599, true), + (428, 575, true), + (432, 600, true), + (463, 444, true), + (759, 803, false), + (445, 684, true), + (1411, 757, true), + (915, 784, false), + (480, 737, true), + (758, 620, true), + (975, 705, true), + (1289, 376, true), + (575, 420, true), + (1015, 507, true), + (1377, 448, true), + (1679, 171, false), + (194, 435, false), + (413, 159, false), + (327, 741, false), + (1824, 138, false), + (1093, 29, false), + ], + ), + ( + "820", + vec![ + ("skew", num(15.0)), + ("skewAxis", num(60.0)), + ("rotation", num(-25.0)), + ("uniform", Value::Bool(false)), + ("scaleWidth", num(130.0)), + ("scaleHeight", num(70.0)), + ("anchor", p2(100.0, 50.0)), + ], + &[ + (1674, 566, true), + (1425, 317, false), + (1788, 278, true), + (1530, 390, true), + (1531, 652, true), + (1589, 545, true), + (1890, 441, true), + (1724, 400, true), + (1657, 511, true), + (1895, 261, true), + (1697, 344, true), + (933, 840, true), + (1413, 467, true), + (1295, 691, true), + (1353, 548, true), + (1293, 548, true), + (1103, 551, true), + (973, 758, true), + (446, 484, false), + (1009, 420, false), + (144, 761, false), + (1812, 111, false), + (1241, 10, false), + (900, 854, false), + ], + ), + ]; + for (case, set, pts) in cases { + let o = run("ec.distort.transform", &set, solid.clone()); + for &(x, y, inside) in pts { + // Comp pixel → layer pixel (the solid's top-left sits at 480, 270) → buffer. + let (bx, by) = o.to_px([x as f64 + 0.5 - 480.0, y as f64 + 0.5 - 270.0]); + let a = if bx >= 0.0 && by >= 0.0 && bx < o.img.width as f64 && by < o.img.height as f64 { o.img.get(bx as i64, by as i64)[3] } else { 0.0 }; + assert_eq!(a > 0.5, inside, "case {case}: comp pixel ({x}, {y}) alpha {a}"); + } + } + // A huge scale stays within the buffer cap. + let o = run("ec.distort.transform", &[("scaleHeight", num(100_000.0))], Image::filled(8, 8, [1.0; 4])); + assert!(o.img.width <= 16_384 && o.img.height <= 16_384); + // A layer moved far right grows the buffer on that side only, within 16 Mpx. + let o = run("ec.distort.transform", &[("position", p2(960.0 + 2000.0, 540.0))], Image::filled(1920, 1080, [1.0; 4])); + assert_eq!((o.offset[0], o.img.height), (0.0, 1080), "only the right side grows"); + assert!(o.img.width >= 1920 + 2000 && o.img.width as u64 * o.img.height as u64 <= 16_777_216, "{} x {}", o.img.width, o.img.height); + assert!(o.img.get(2000 + 960, 540)[3] > 0.99 && o.img.get(10, 540)[3] < 0.01); + let o = run("ec.distort.transform", &[("position", p2(40_000.0, 30_000.0))], Image::filled(1920, 1080, [1.0; 4])); + assert!(o.img.width as u64 * o.img.height as u64 <= 16_777_216, "{} x {}", o.img.width, o.img.height); + } + #[test] fn transform_sampling_bicubic_differs_from_bilinear() { let im = img(32, 32); diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs index 10ec59735..7add5fc58 100644 --- a/crates/effects/src/distort2.rs +++ b/crates/effects/src/distort2.rs @@ -19,7 +19,7 @@ use effectcraft_project::ParamUi; use effectcraft_raster::{Image, Px}; use rayon::prelude::*; -use crate::generate::value_noise; +use crate::generate::value_noise_linear_z; use crate::util::{SRC_NAMES, Src, fit_layer, layer_cover_pad, layer_rect, lerp4, map_xy, pick, premul, remap, smoothstep, src_at, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; @@ -37,13 +37,15 @@ fn pct(max: f64) -> ParamUi { // ---------------------------------------------------------------- Turbulent Displace +/// Fractional-octave fBm of value noise in 0..1. Evolution (`z`) is linear: with the eased z of +/// plain value noise a few degrees of Evolution barely moved the pattern (#943). fn fbm(u: f64, v: f64, z: f64, seed: u32, octaves: f64, falloff: f32) -> f32 { let n = octaves.ceil().max(1.0) as usize; let frac = (octaves - octaves.floor()) as f32; let (mut sum, mut norm, mut amp, mut f) = (0.0f32, 0.0f32, 1.0f32, 1.0f32); for o in 0..n { let w = if o + 1 == n && frac > 0.0 { frac } else { 1.0 }; - sum += value_noise(u as f32 * f, v as f32 * f, z as f32 + o as f32 * 5.17, seed.wrapping_add(o as u32 * 101)) * amp * w; + sum += value_noise_linear_z(u as f32 * f, v as f32 * f, z as f32 + o as f32 * 5.17, seed.wrapping_add(o as u32 * 101)) * amp * w; norm += amp * w; amp *= falloff; f *= 2.0; @@ -93,6 +95,9 @@ fn turbulent_displace(ctx: &EffectCtx, mut b: Buf) -> Buf { let size = (ctx.params.f("size") * b.scale).max(1.0); if ctx.params.b("resizeLayer") && !ctx.adjustment { b.pad(amount.abs().ceil() as u32 + 1); + } else { + // Room for pixels displaced past the content, up to the layer's edge (#1205). + b.pad(crate::cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale, amount.abs().ceil() as u32 + 1)); } let off = b.to_px(ctx.params.v2("offset")); let oct = ctx.params.f("complexity").clamp(1.0, 10.0); @@ -1115,6 +1120,45 @@ mod tests { assert_eq!(TURBULENT_PINNING[11], "Pin Left Locked"); } + /// #943: every non-equivalent Evolution value renders a visibly different frame. The eased + /// evolution axis left 0 and 37 degrees (or 360 and 400) within a hundredth of each other. + #[test] + fn turbulent_displace_evolution_changes_the_output() { + // A smooth ramp (red = x, green = y) with Pin All Locked, so the frame stays opaque and + // the difference is purely how far the pattern moved. + let mut ramp = Image::new(64, 64); + for y in 0..64u32 { + for x in 0..64u32 { + ramp.set(x, y, [x as f32 / 63.0, y as f32 / 63.0, 0.25, 1.0]); + } + } + let render = |evo: f64, extra: &[(&str, Value)]| { + let base = [("evolution", num(evo)), ("size", num(10.0)), ("pinning", Value::Enum(8))]; + run("ec.distort.turbulentdisplace", &[base.as_slice(), extra].concat(), ramp.clone()).img + }; + let evolutions = [0.0, 37.0, 90.5, 180.0, 360.0, 400.25, 725.0, 1000.5]; + let frames: Vec = evolutions.iter().map(|&e| render(e, &[])).collect(); + for (i, a) in frames.iter().enumerate() { + for (j, b) in frames.iter().enumerate().skip(i + 1) { + let d = max_diff(a, b); + assert!(d > 0.01, "evolution {} and {} differ by only {d}", evolutions[i], evolutions[j]); + } + } + // Constant rate: 20 degrees next to a whole revolution (from either side) is a visible + // step, not the rounding-error size the eased axis gave (under 0.001). + assert!(max_diff(&frames[0], &render(20.0, &[])) > 0.02); + assert!(max_diff(&render(340.0, &[]), &frames[4]) > 0.02); + // 0 and 360 degrees are different frames without Cycle Evolution, and the same frame with + // Cycle (in Revolutions) = 1, at any starting angle. + assert!(max_diff(&frames[0], &frames[4]) > 0.01); + let cycle = [("evolutionOptions/cycleEvolution", Value::Bool(true)), ("evolutionOptions/cycle", num(1.0))]; + assert!(max_diff(&render(0.0, &cycle), &render(360.0, &cycle)) < 1e-4); + assert!(max_diff(&render(37.0, &cycle), &render(397.0, &cycle)) < 1e-4); + assert!(max_diff(&render(0.0, &cycle), &render(37.0, &cycle)) > 0.01); + // Random Seed still gives a different pattern at the same Evolution. + assert!(max_diff(&frames[1], &render(37.0, &[("evolutionOptions/randomSeed", num(5.0))])) > 0.01); + } + #[test] fn magnify_blending_none_and_scaling() { let img = test_img(32, 32); diff --git a/crates/effects/src/distort3.rs b/crates/effects/src/distort3.rs index cb389d901..a02ab5743 100644 --- a/crates/effects/src/distort3.rs +++ b/crates/effects/src/distort3.rs @@ -1084,337 +1084,4 @@ pub fn specs() -> Vec { } #[cfg(test)] -mod tests { - use super::*; - use crate::{EffectEnv, MaskShape, Params}; - - fn img(w: u32, h: u32) -> Image { - crate::util::gen_image(w, h, |x, y| [x as f32 / w as f32, y as f32 / h as f32, ((x * 7 + y * 3) % 11) as f32 / 11.0, 1.0]) - } - - /// Run with Point defaults scaled to the layer (like `instantiate`). - fn run(id: &str, set: &[(&str, Value)], im: Image, masks: &[MaskShape]) -> Buf { - let s = crate::find(id).unwrap(); - let ls = [im.width as f64, im.height as f64]; - let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; - for ps in &s.params { - if let (ParamUi::Point, Value::Vec2(v)) = (&ps.ui, &ps.default) { - params.values.insert(ps.id.to_string(), Value::Vec2([v[0] * ls[0], v[1] * ls[1]])); - } - } - for (k, v) in set { - assert!(params.values.contains_key(*k), "{id}: {k}"); - params.values.insert(k.to_string(), v.clone()); - } - let env = EffectEnv { masks, ..Default::default() }; - let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: ls, seed: 1, adjustment: false, env }; - crate::apply(s, &ctx, Buf { img: im, offset: [0.0, 0.0], scale: 1.0 }) - } - - fn maxd(a: &Image, b: &Image) -> f32 { - assert_eq!((a.width, a.height), (b.width, b.height)); - a.data.iter().zip(&b.data).map(|(p, q)| (0..4).map(|i| (p[i] - q[i]).abs()).fold(0.0, f32::max)).fold(0.0, f32::max) - } - - fn rect_mask(x0: f64, y0: f64, x1: f64, y1: f64) -> MaskShape { - MaskShape { name: "m".into(), points: vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]], closed: true, inverted: false } - } - - #[test] - fn identity_at_neutral_settings() { - let im = img(40, 30); - let cases: &[(&str, Vec<(&str, Value)>)] = &[ - ("ec.distort.ccbender", vec![]), - ("ec.distort.ccpowerpin", vec![]), - ("ec.distort.ccripplepulse", vec![]), - ("ec.distort.ccslant", vec![]), - ("ec.distort.ccsmear", vec![("reach", num(0.0))]), - ("ec.distort.ccsplit", vec![("split", num(0.0))]), - ("ec.distort.ccsplit2", vec![("split1", num(0.0)), ("split2", num(0.0))]), - ("ec.distort.smear", vec![]), - ("ec.distort.reshape", vec![]), - ("ec.distort.twirllegacy", vec![]), - ("ec.distort.warp", vec![("warpStyle", Value::Enum(5))]), - ("ec.distort.upscale", vec![]), - ]; - for (id, set) in cases { - let o = run(id, set, im.clone(), &[]); - assert!(maxd(&o.img, &im) < 1e-5, "{id} not identity"); - assert_eq!(o.offset, [0.0, 0.0], "{id}"); - } - // Power pin with perspective 0 at default corners is also identity. - let o = run("ec.distort.ccpowerpin", &[("perspective", num(0.0))], im.clone(), &[]); - assert!(maxd(&o.img, &im) < 1e-4); - } - - #[test] - fn all_deterministic() { - let im = img(36, 28); - let masks = [rect_mask(8.0, 8.0, 16.0, 16.0), rect_mask(12.0, 10.0, 22.0, 20.0), rect_mask(2.0, 2.0, 34.0, 26.0)]; - let cases: &[(&str, Vec<(&str, Value)>)] = &[ - ("ec.distort.ccbender", vec![("amount", num(40.0)), ("style", Value::Enum(3))]), - ("ec.distort.ccblobbylize", vec![]), - ("ec.distort.ccpowerpin", vec![("topLeft", pt(5.0, 3.0)), ("perspective", num(40.0))]), - ("ec.distort.ccripplepulse", vec![("pulseLevel", num(80.0))]), - ("ec.distort.ccslant", vec![("slant", num(30.0)), ("stretching", Value::Bool(true))]), - ("ec.distort.ccsmear", vec![]), - ("ec.distort.ccsplit", vec![]), - ("ec.distort.ccsplit2", vec![("split2", num(10.0))]), - ("ec.distort.smear", vec![("sourceMask", Value::Enum(1)), ("boundaryMask", Value::Enum(3)), ("maskOffset", pt(4.0, 2.0))]), - ("ec.distort.reshape", vec![("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))]), - ("ec.distort.twirllegacy", vec![("angle", num(90.0))]), - ("ec.distort.warp", vec![("bend", num(50.0)), ("horizontalDistortion", num(20.0))]), - ("ec.distort.upscale", vec![("scale", num(200.0))]), - ]; - for (id, set) in cases { - let a = run(id, set, im.clone(), &masks); - let b = run(id, set, im.clone(), &masks); - assert_eq!(a.img, b.img, "{id}"); - assert!(a.img.data.iter().all(|p| p.iter().all(|v| v.is_finite())), "{id}"); - assert!(a.img.width != im.width || a.img.height != im.height || maxd(&a.img, &im) > 1e-3, "{id} did nothing"); - } - } - - #[test] - fn blobbylize_defaults_match_ae() { - let s = crate::find("ec.distort.ccblobbylize").unwrap(); - let default = |id: &str| s.params.iter().find(|p| p.id == id).unwrap().default.clone(); - assert_eq!(default("property"), Value::Enum(0), "Property defaults to Red"); - assert_eq!(default("softness"), num(20.0)); - assert_eq!(default("cutAway"), num(25.0)); - assert_eq!(default("lightHeight"), num(65.0)); - assert_eq!(default("lightPosition"), pt(0.5, 0.5)); - assert_eq!(default("ambient"), num(75.0)); - assert_eq!(default("diffuse"), num(25.0)); - assert_eq!(default("specular"), num(100.0)); - assert_eq!(default("roughness"), num(0.025)); - assert_eq!(default("metal"), num(100.0)); - } - - #[test] - fn blobbylize_cut_away_thresholds_a_height_ramp() { - // AE oracle: Cut Away is a linear height threshold with a ~1 px antialiased edge. - let im = crate::util::gen_image(1920, 8, |x, _| { - let h = x as f32 / 1920.0; - [h, h, h, 1.0] - }); - let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(0)), ("softness", num(0.0)), ("cutAway", num(25.0))], im, &[]); - assert_eq!(o.img.get(400, 4)[3], 0.0, "below Cut Away must be transparent"); - assert_eq!(o.img.get(560, 4)[3], 1.0, "above Cut Away must be opaque"); - let edge = o.img.get(480, 4)[3]; - assert!((0.45..=0.55).contains(&edge), "edge alpha {edge} is not a one-pixel threshold"); - let partial = (450..510).filter(|&x| (0.001..0.999).contains(&o.img.get(x, 4)[3])).count(); - assert!(partial <= 2, "{partial} antialiased columns instead of one"); - } - - #[test] - fn blobbylize_cut_away_100_is_empty() { - // Regression: the old clamp to 0.99 left the top of the ramp opaque at Cut Away 100. - let im = crate::util::gen_image(1920, 8, |x, _| { - let h = x as f32 / 1920.0; - [h, h, h, 1.0] - }); - let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(0)), ("softness", num(0.0)), ("cutAway", num(100.0))], im, &[]); - assert!(o.img.data.iter().all(|p| p[3] == 0.0), "Cut Away 100 left opaque pixels"); - } - - #[test] - fn blobbylize_cut_away_100_clears_an_opaque_edge() { - // An opaque source reaches h = 1 on the high side of an edge; the gradient antialias - // used to leave m = 0.5 there, so Cut Away 100 still produced visible pixels. - let im = crate::util::gen_image(64, 8, |x, _| if x < 32 { [1.0; 4] } else { [0.0; 4] }); - let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(3)), ("softness", num(0.0)), ("cutAway", num(100.0))], im, &[]); - assert!(o.img.data.iter().all(|p| p[3] == 0.0), "Cut Away 100 left an opaque edge"); - } - - #[test] - fn blobbylize_softness_warps_a_step_matte_inward() { - // AE oracle T2: a self alpha step at x = 960, Softness 20, Cut Away 0 puts the matte - // edge at about x = 946, not at the source edge. The PR's matte-preserving version - // left it at 960; the effect must warp the source matte. - let im = crate::util::gen_image(1920, 32, |x, _| if x < 960 { [1.0; 4] } else { [0.0; 4] }); - let o = run( - "ec.distort.ccblobbylize", - &[ - ("property", Value::Enum(3)), - ("softness", num(20.0)), - ("cutAway", num(0.0)), - ("ambient", num(100.0)), - ("diffuse", num(0.0)), - ("specular", num(0.0)), - ("metal", num(0.0)), - ], - im, - &[], - ); - assert!(o.img.get(945, 16)[3] > 0.95, "the matte did not move inward: {}", o.img.get(945, 16)[3]); - assert!(o.img.get(949, 16)[3] < 0.05, "source alpha leaked past the warped edge"); - assert_eq!(o.img.get(960, 16)[3], 0.0, "the source matte was preserved instead of warped"); - } - - /// A text layer's buffer: 20 × 10 opaque pixels at (90, 45)–(110, 55) of its comp-sized - /// (200 × 100) layer bounds, after `id` with `set`. - fn on_text(id: &str, set: &[(&str, Value)]) -> Buf { - let s = crate::find(id).unwrap(); - let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; - for (k, v) in set { - assert!(params.values.contains_key(*k), "{id}: {k}"); - params.values.insert(k.to_string(), v.clone()); - } - let mut im = Image::new(20, 10); - im.data.iter_mut().for_each(|p| *p = [1.0; 4]); - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [200.0, 100.0], seed: 1, adjustment: false, env: Default::default() }; - crate::apply(s, &ctx, Buf { img: im, offset: [-90.0, -45.0], scale: 1.0 }) - } - - /// Alpha at layer point `p` (0 outside the buffer). - fn alpha_at(b: &Buf, p: [f64; 2]) -> f32 { - let (x, y) = b.to_px(p); - b.img.get(x.floor() as i64, y.floor() as i64)[3] - } - - /// #659: CC Smear and CC Tiler on a text layer reach past the text's box to the layer bounds - /// (comp-sized, as in After Effects), not cut off where the glyphs end. - #[test] - fn effects_on_text_reach_past_its_box() { - let smear = on_text("ec.distort.ccsmear", &[("from", pt(100.0, 50.0)), ("to", pt(140.0, 50.0)), ("reach", num(100.0)), ("radius", num(30.0))]); - assert!(alpha_at(&smear, [130.0, 50.0]) > 0.9, "the smear is cut off at the text's box"); - assert!(smear.img.width < 200, "it grows only as far as pixels move"); - let tiles = on_text("ec.distort.cctiler", &[("scale", num(50.0)), ("center", pt(100.0, 50.0))]); - assert!(alpha_at(&tiles, [100.0, 50.0]) > 0.9); - assert!(alpha_at(&tiles, [2.0, 50.0]) > 0.9, "tiles fill the layer bounds"); - assert!(alpha_at(&tiles, [50.0, 50.0]) < 0.1); - // A footage layer's buffer is its bounds already: no change. - let o = run("ec.distort.ccsmear", &[], img(40, 30), &[]); - assert_eq!((o.img.width, o.img.height, o.offset), (40, 30, [0.0, 0.0])); - } - - #[test] - fn every_warp_style_runs_and_bends() { - let im = img(40, 30); - for s in 0..WARP_STYLES.len() as u32 { - for axis in 0..2 { - let o = run("ec.distort.warp", &[("warpStyle", Value::Enum(s)), ("warpAxis", Value::Enum(axis)), ("bend", num(50.0))], im.clone(), &[]); - let pad = o.offset[0] as u32; - // The layer centre's neighbourhood is still made of source pixels. - assert!(o.img.get(pad as i64 + 20, pad as i64 + 15)[3] > 0.5, "style {s}"); - assert!(o.img.width > im.width); - } - } - // Arc with positive bend raises the middle of the layer: the top edge in the middle - // moves up (opaque pixels above the original top). - let o = run("ec.distort.warp", &[("warpStyle", Value::Enum(3)), ("bend", num(50.0))], im.clone(), &[]); - let pad = o.offset[1] as i64; - assert!(o.img.get(o.offset[0] as i64 + 20, pad - 3)[3] > 0.5); - assert!(o.img.get(o.offset[0] as i64 + 1, pad - 3)[3] < 0.5); - } - - #[test] - fn power_pin_moves_corners() { - let im = img(20, 20); - // Pin to the right half: output x in 10..20 shows the whole layer squeezed. - let set = [("topLeft", pt(10.0, 0.0)), ("bottomLeft", pt(10.0, 20.0))]; - let o = run("ec.distort.ccpowerpin", &set, im.clone(), &[]); - assert_eq!(o.img.get(3, 10)[3], 0.0); - let px = o.img.get(15, 10); - assert!((px[0] - 0.5).abs() < 0.1, "{px:?}"); - } - - #[test] - fn power_pin_unstretch_expands_pinned_region() { - let im = crate::util::gen_image(24, 16, |_, _| [1.0, 0.0, 0.0, 1.0]); - let inset = [("topLeft", pt(6.0, 4.0)), ("topRight", pt(18.0, 4.0)), ("bottomRight", pt(18.0, 12.0)), ("bottomLeft", pt(6.0, 12.0))]; - let opaque = |o: &Buf| o.img.data.iter().filter(|p| p[3] > 0.99).count(); - let off = run("ec.distort.ccpowerpin", &inset, im.clone(), &[]); - assert_eq!(opaque(&off), 96); - let mut set = inset.to_vec(); - set.push(("unstretch", Value::Bool(true))); - let on = run("ec.distort.ccpowerpin", &set, im.clone(), &[]); - assert_eq!(opaque(&on), 24 * 16); - assert!(on.img.get(0, 0)[3] > 0.99); - // Default corners are the identity either way. - let id = run("ec.distort.ccpowerpin", &[("unstretch", Value::Bool(true))], im.clone(), &[]); - assert!(maxd(&id.img, &im) < 1e-4); - } - - #[test] - fn split_opens_a_gap() { - let im = img(40, 30); - let o = run("ec.distort.ccsplit", &[("split", num(40.0))], im.clone(), &[]); - assert_eq!(o.img.get(20, 15)[3], 0.0); - assert_eq!(o.img.get(2, 2), im.get(2, 2)); - } - - #[test] - fn slant_shears_from_floor() { - let im = img(20, 20); - let o = run("ec.distort.ccslant", &[("slant", num(50.0))], im.clone(), &[]); - let pad = o.offset[0] as i64; - // Bottom row stays put; the top row shifts right by ~10 px. - assert!((o.img.get(pad + 5, pad + 19)[0] - im.get(5, 19)[0]).abs() < 0.05); - assert!((o.img.get(pad + 15, pad)[0] - im.get(5, 0)[0]).abs() < 0.08); - } - - #[test] - fn reshape_moves_source_to_destination() { - // A bright square at the source mask; reshape to a shifted destination mask. - let im = crate::util::gen_image(40, 40, |x, y| if (10..20).contains(&x) && (10..20).contains(&y) { [1.0; 4] } else { [0.0, 0.0, 0.0, 1.0] }); - let masks = [rect_mask(10.0, 10.0, 20.0, 20.0), rect_mask(16.0, 10.0, 26.0, 20.0), rect_mask(0.0, 0.0, 40.0, 40.0)]; - let o = run("ec.distort.reshape", &[("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))], im, &masks); - assert!(o.img.get(24, 15)[0] > 0.8, "{:?}", o.img.get(24, 15)); - assert!(o.img.get(11, 15)[0] < 0.3, "{:?}", o.img.get(11, 15)); - } - - #[test] - fn reshape_correspondence_points_steer_the_match() { - // Matching outlines with a zero pair change nothing; a quarter-turn offset twists. - let im = img(40, 40); - let masks = [rect_mask(10.0, 10.0, 30.0, 30.0), rect_mask(10.0, 10.0, 30.0, 30.0), rect_mask(0.0, 0.0, 40.0, 40.0)]; - let base = [("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))]; - let same = run("ec.distort.reshape", &[base.as_slice(), &[("correspondencePoints", Value::Str("0,0".into()))]].concat(), im.clone(), &masks); - assert!(maxd(&same.img, &im) < 1e-3, "matching outlines: no change"); - let twisted = run("ec.distort.reshape", &[base.as_slice(), &[("correspondencePoints", Value::Str("0.25,0".into()))]].concat(), im.clone(), &masks); - assert!(maxd(&twisted.img, &im) > 0.1); - // Fraction mapping. - assert!((map_fraction(&[(0.0, 0.0), (0.5, 0.25)], 0.125, true) - 0.25).abs() < 1e-9); - assert!((map_fraction(&[(0.2, 0.0)], 0.9, true) - 0.1).abs() < 1e-9); - assert!((map_fraction(&[(0.5, 0.5)], 0.25, false) - 0.25).abs() < 1e-9); - } - - #[test] - fn upscale_grows_about_centre() { - let im = img(20, 10); - let o = run("ec.distort.upscale", &[("scale", num(200.0)), ("detail", num(0.0))], im.clone(), &[]); - assert_eq!((o.img.width, o.img.height), (40, 20)); - // Grown about the layer centre: layer point (0,0) now sits at buffer pixel (10,5), so the - // centre (10,5) lands in the middle of the 40×20 buffer. - assert_eq!(o.offset, [10.0, 5.0]); - let c = o.img.sample_bilinear(20.0, 10.0); - assert!((c[0] - im.sample_bilinear(10.0, 5.0)[0]).abs() < 0.03); - } - - #[test] - fn twirl_legacy_rotates_inside_radius() { - let im = img(40, 40); - let o = run("ec.distort.twirllegacy", &[("angle", num(180.0)), ("radius", num(100.0))], im.clone(), &[]); - assert!(maxd(&o.img, &im) > 0.1); - // Corners lie outside the radius. - assert_eq!(o.img.get(0, 0), im.get(0, 0)); - } - - #[test] - fn half_resolution_matches_scaled_geometry() { - // Bender at scale 0.5: the buffer is half size, parameters in layer pixels. - let s = crate::find("ec.distort.ccbender").unwrap(); - let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; - params.values.insert("amount".into(), num(30.0)); - params.values.insert("top".into(), pt(20.0, 0.0)); - params.values.insert("base".into(), pt(20.0, 40.0)); - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [40.0, 40.0], seed: 1, adjustment: false, env: Default::default() }; - let full = crate::apply(s, &ctx, Buf { img: img(40, 40), offset: [0.0; 2], scale: 1.0 }); - let half = crate::apply(s, &ctx, Buf { img: effectcraft_raster::resample(&img(40, 40), 20, 20), offset: [0.0; 2], scale: 0.5 }); - let a = full.img.sample_bilinear(20.0, 4.0)[3]; - let b = half.img.sample_bilinear(10.0, 2.0)[3]; - assert!((a - b).abs() < 0.3); - } -} +mod tests; diff --git a/crates/effects/src/distort3/tests.rs b/crates/effects/src/distort3/tests.rs new file mode 100644 index 000000000..b50d6b450 --- /dev/null +++ b/crates/effects/src/distort3/tests.rs @@ -0,0 +1,332 @@ +use super::*; +use crate::{EffectEnv, MaskShape, Params}; + +fn img(w: u32, h: u32) -> Image { + crate::util::gen_image(w, h, |x, y| [x as f32 / w as f32, y as f32 / h as f32, ((x * 7 + y * 3) % 11) as f32 / 11.0, 1.0]) +} + +/// Run with Point defaults scaled to the layer (like `instantiate`). +fn run(id: &str, set: &[(&str, Value)], im: Image, masks: &[MaskShape]) -> Buf { + let s = crate::find(id).unwrap(); + let ls = [im.width as f64, im.height as f64]; + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for ps in &s.params { + if let (ParamUi::Point, Value::Vec2(v)) = (&ps.ui, &ps.default) { + params.values.insert(ps.id.to_string(), Value::Vec2([v[0] * ls[0], v[1] * ls[1]])); + } + } + for (k, v) in set { + assert!(params.values.contains_key(*k), "{id}: {k}"); + params.values.insert(k.to_string(), v.clone()); + } + let env = EffectEnv { masks, ..Default::default() }; + let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: ls, seed: 1, adjustment: false, env }; + crate::apply(s, &ctx, Buf { img: im, offset: [0.0, 0.0], scale: 1.0 }) +} + +fn maxd(a: &Image, b: &Image) -> f32 { + assert_eq!((a.width, a.height), (b.width, b.height)); + a.data.iter().zip(&b.data).map(|(p, q)| (0..4).map(|i| (p[i] - q[i]).abs()).fold(0.0, f32::max)).fold(0.0, f32::max) +} + +fn rect_mask(x0: f64, y0: f64, x1: f64, y1: f64) -> MaskShape { + MaskShape { name: "m".into(), points: vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]], closed: true, inverted: false } +} + +#[test] +fn identity_at_neutral_settings() { + let im = img(40, 30); + let cases: &[(&str, Vec<(&str, Value)>)] = &[ + ("ec.distort.ccbender", vec![]), + ("ec.distort.ccpowerpin", vec![]), + ("ec.distort.ccripplepulse", vec![]), + ("ec.distort.ccslant", vec![]), + ("ec.distort.ccsmear", vec![("reach", num(0.0))]), + ("ec.distort.ccsplit", vec![("split", num(0.0))]), + ("ec.distort.ccsplit2", vec![("split1", num(0.0)), ("split2", num(0.0))]), + ("ec.distort.smear", vec![]), + ("ec.distort.reshape", vec![]), + ("ec.distort.twirllegacy", vec![]), + ("ec.distort.warp", vec![("warpStyle", Value::Enum(5))]), + ("ec.distort.upscale", vec![]), + ]; + for (id, set) in cases { + let o = run(id, set, im.clone(), &[]); + assert!(maxd(&o.img, &im) < 1e-5, "{id} not identity"); + assert_eq!(o.offset, [0.0, 0.0], "{id}"); + } + // Power pin with perspective 0 at default corners is also identity. + let o = run("ec.distort.ccpowerpin", &[("perspective", num(0.0))], im.clone(), &[]); + assert!(maxd(&o.img, &im) < 1e-4); +} + +#[test] +fn all_deterministic() { + let im = img(36, 28); + let masks = [rect_mask(8.0, 8.0, 16.0, 16.0), rect_mask(12.0, 10.0, 22.0, 20.0), rect_mask(2.0, 2.0, 34.0, 26.0)]; + let cases: &[(&str, Vec<(&str, Value)>)] = &[ + ("ec.distort.ccbender", vec![("amount", num(40.0)), ("style", Value::Enum(3))]), + ("ec.distort.ccblobbylize", vec![]), + ("ec.distort.ccpowerpin", vec![("topLeft", pt(5.0, 3.0)), ("perspective", num(40.0))]), + ("ec.distort.ccripplepulse", vec![("pulseLevel", num(80.0))]), + ("ec.distort.ccslant", vec![("slant", num(30.0)), ("stretching", Value::Bool(true))]), + ("ec.distort.ccsmear", vec![]), + ("ec.distort.ccsplit", vec![]), + ("ec.distort.ccsplit2", vec![("split2", num(10.0))]), + ("ec.distort.smear", vec![("sourceMask", Value::Enum(1)), ("boundaryMask", Value::Enum(3)), ("maskOffset", pt(4.0, 2.0))]), + ("ec.distort.reshape", vec![("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))]), + ("ec.distort.twirllegacy", vec![("angle", num(90.0))]), + ("ec.distort.warp", vec![("bend", num(50.0)), ("horizontalDistortion", num(20.0))]), + ("ec.distort.upscale", vec![("scale", num(200.0))]), + ]; + for (id, set) in cases { + let a = run(id, set, im.clone(), &masks); + let b = run(id, set, im.clone(), &masks); + assert_eq!(a.img, b.img, "{id}"); + assert!(a.img.data.iter().all(|p| p.iter().all(|v| v.is_finite())), "{id}"); + assert!(a.img.width != im.width || a.img.height != im.height || maxd(&a.img, &im) > 1e-3, "{id} did nothing"); + } +} + +#[test] +fn blobbylize_defaults_match_ae() { + let s = crate::find("ec.distort.ccblobbylize").unwrap(); + let default = |id: &str| s.params.iter().find(|p| p.id == id).unwrap().default.clone(); + assert_eq!(default("property"), Value::Enum(0), "Property defaults to Red"); + assert_eq!(default("softness"), num(20.0)); + assert_eq!(default("cutAway"), num(25.0)); + assert_eq!(default("lightHeight"), num(65.0)); + assert_eq!(default("lightPosition"), pt(0.5, 0.5)); + assert_eq!(default("ambient"), num(75.0)); + assert_eq!(default("diffuse"), num(25.0)); + assert_eq!(default("specular"), num(100.0)); + assert_eq!(default("roughness"), num(0.025)); + assert_eq!(default("metal"), num(100.0)); +} + +#[test] +fn blobbylize_cut_away_thresholds_a_height_ramp() { + // AE oracle: Cut Away is a linear height threshold with a ~1 px antialiased edge. + let im = crate::util::gen_image(1920, 8, |x, _| { + let h = x as f32 / 1920.0; + [h, h, h, 1.0] + }); + let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(0)), ("softness", num(0.0)), ("cutAway", num(25.0))], im, &[]); + assert_eq!(o.img.get(400, 4)[3], 0.0, "below Cut Away must be transparent"); + assert_eq!(o.img.get(560, 4)[3], 1.0, "above Cut Away must be opaque"); + let edge = o.img.get(480, 4)[3]; + assert!((0.45..=0.55).contains(&edge), "edge alpha {edge} is not a one-pixel threshold"); + let partial = (450..510).filter(|&x| (0.001..0.999).contains(&o.img.get(x, 4)[3])).count(); + assert!(partial <= 2, "{partial} antialiased columns instead of one"); +} + +#[test] +fn blobbylize_cut_away_100_is_empty() { + // Regression: the old clamp to 0.99 left the top of the ramp opaque at Cut Away 100. + let im = crate::util::gen_image(1920, 8, |x, _| { + let h = x as f32 / 1920.0; + [h, h, h, 1.0] + }); + let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(0)), ("softness", num(0.0)), ("cutAway", num(100.0))], im, &[]); + assert!(o.img.data.iter().all(|p| p[3] == 0.0), "Cut Away 100 left opaque pixels"); +} + +#[test] +fn blobbylize_cut_away_100_clears_an_opaque_edge() { + // An opaque source reaches h = 1 on the high side of an edge; the gradient antialias + // used to leave m = 0.5 there, so Cut Away 100 still produced visible pixels. + let im = crate::util::gen_image(64, 8, |x, _| if x < 32 { [1.0; 4] } else { [0.0; 4] }); + let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(3)), ("softness", num(0.0)), ("cutAway", num(100.0))], im, &[]); + assert!(o.img.data.iter().all(|p| p[3] == 0.0), "Cut Away 100 left an opaque edge"); +} + +#[test] +fn blobbylize_softness_warps_a_step_matte_inward() { + // AE oracle T2: a self alpha step at x = 960, Softness 20, Cut Away 0 puts the matte + // edge at about x = 946, not at the source edge. The PR's matte-preserving version + // left it at 960; the effect must warp the source matte. + let im = crate::util::gen_image(1920, 32, |x, _| if x < 960 { [1.0; 4] } else { [0.0; 4] }); + let o = run( + "ec.distort.ccblobbylize", + &[ + ("property", Value::Enum(3)), + ("softness", num(20.0)), + ("cutAway", num(0.0)), + ("ambient", num(100.0)), + ("diffuse", num(0.0)), + ("specular", num(0.0)), + ("metal", num(0.0)), + ], + im, + &[], + ); + assert!(o.img.get(945, 16)[3] > 0.95, "the matte did not move inward: {}", o.img.get(945, 16)[3]); + assert!(o.img.get(949, 16)[3] < 0.05, "source alpha leaked past the warped edge"); + assert_eq!(o.img.get(960, 16)[3], 0.0, "the source matte was preserved instead of warped"); +} + +/// A text layer's buffer: 20 × 10 opaque pixels at (90, 45)–(110, 55) of its comp-sized +/// (200 × 100) layer bounds, after `id` with `set`. +fn on_text(id: &str, set: &[(&str, Value)]) -> Buf { + let s = crate::find(id).unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in set { + assert!(params.values.contains_key(*k), "{id}: {k}"); + params.values.insert(k.to_string(), v.clone()); + } + let mut im = Image::new(20, 10); + im.data.iter_mut().for_each(|p| *p = [1.0; 4]); + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [200.0, 100.0], seed: 1, adjustment: false, env: Default::default() }; + crate::apply(s, &ctx, Buf { img: im, offset: [-90.0, -45.0], scale: 1.0 }) +} + +/// Alpha at layer point `p` (0 outside the buffer). +fn alpha_at(b: &Buf, p: [f64; 2]) -> f32 { + let (x, y) = b.to_px(p); + b.img.get(x.floor() as i64, y.floor() as i64)[3] +} + +/// #659: CC Smear and CC Tiler on a text layer reach past the text's box to the layer bounds +/// (comp-sized, as in After Effects), not cut off where the glyphs end. +#[test] +fn effects_on_text_reach_past_its_box() { + let smear = on_text("ec.distort.ccsmear", &[("from", pt(100.0, 50.0)), ("to", pt(140.0, 50.0)), ("reach", num(100.0)), ("radius", num(30.0))]); + assert!(alpha_at(&smear, [130.0, 50.0]) > 0.9, "the smear is cut off at the text's box"); + assert!(smear.img.width < 200, "it grows only as far as pixels move"); + let tiles = on_text("ec.distort.cctiler", &[("scale", num(50.0)), ("center", pt(100.0, 50.0))]); + assert!(alpha_at(&tiles, [100.0, 50.0]) > 0.9); + assert!(alpha_at(&tiles, [2.0, 50.0]) > 0.9, "tiles fill the layer bounds"); + assert!(alpha_at(&tiles, [50.0, 50.0]) < 0.1); + // A footage layer's buffer is its bounds already: no change. + let o = run("ec.distort.ccsmear", &[], img(40, 30), &[]); + assert_eq!((o.img.width, o.img.height, o.offset), (40, 30, [0.0, 0.0])); +} + +#[test] +fn every_warp_style_runs_and_bends() { + let im = img(40, 30); + for s in 0..WARP_STYLES.len() as u32 { + for axis in 0..2 { + let o = run("ec.distort.warp", &[("warpStyle", Value::Enum(s)), ("warpAxis", Value::Enum(axis)), ("bend", num(50.0))], im.clone(), &[]); + let pad = o.offset[0] as u32; + // The layer centre's neighbourhood is still made of source pixels. + assert!(o.img.get(pad as i64 + 20, pad as i64 + 15)[3] > 0.5, "style {s}"); + assert!(o.img.width > im.width); + } + } + // Arc with positive bend raises the middle of the layer: the top edge in the middle + // moves up (opaque pixels above the original top). + let o = run("ec.distort.warp", &[("warpStyle", Value::Enum(3)), ("bend", num(50.0))], im.clone(), &[]); + let pad = o.offset[1] as i64; + assert!(o.img.get(o.offset[0] as i64 + 20, pad - 3)[3] > 0.5); + assert!(o.img.get(o.offset[0] as i64 + 1, pad - 3)[3] < 0.5); +} + +#[test] +fn power_pin_moves_corners() { + let im = img(20, 20); + // Pin to the right half: output x in 10..20 shows the whole layer squeezed. + let set = [("topLeft", pt(10.0, 0.0)), ("bottomLeft", pt(10.0, 20.0))]; + let o = run("ec.distort.ccpowerpin", &set, im.clone(), &[]); + assert_eq!(o.img.get(3, 10)[3], 0.0); + let px = o.img.get(15, 10); + assert!((px[0] - 0.5).abs() < 0.1, "{px:?}"); +} + +#[test] +fn power_pin_unstretch_expands_pinned_region() { + let im = crate::util::gen_image(24, 16, |_, _| [1.0, 0.0, 0.0, 1.0]); + let inset = [("topLeft", pt(6.0, 4.0)), ("topRight", pt(18.0, 4.0)), ("bottomRight", pt(18.0, 12.0)), ("bottomLeft", pt(6.0, 12.0))]; + let opaque = |o: &Buf| o.img.data.iter().filter(|p| p[3] > 0.99).count(); + let off = run("ec.distort.ccpowerpin", &inset, im.clone(), &[]); + assert_eq!(opaque(&off), 96); + let mut set = inset.to_vec(); + set.push(("unstretch", Value::Bool(true))); + let on = run("ec.distort.ccpowerpin", &set, im.clone(), &[]); + assert_eq!(opaque(&on), 24 * 16); + assert!(on.img.get(0, 0)[3] > 0.99); + // Default corners are the identity either way. + let id = run("ec.distort.ccpowerpin", &[("unstretch", Value::Bool(true))], im.clone(), &[]); + assert!(maxd(&id.img, &im) < 1e-4); +} + +#[test] +fn split_opens_a_gap() { + let im = img(40, 30); + let o = run("ec.distort.ccsplit", &[("split", num(40.0))], im.clone(), &[]); + assert_eq!(o.img.get(20, 15)[3], 0.0); + assert_eq!(o.img.get(2, 2), im.get(2, 2)); +} + +#[test] +fn slant_shears_from_floor() { + let im = img(20, 20); + let o = run("ec.distort.ccslant", &[("slant", num(50.0))], im.clone(), &[]); + let pad = o.offset[0] as i64; + // Bottom row stays put; the top row shifts right by ~10 px. + assert!((o.img.get(pad + 5, pad + 19)[0] - im.get(5, 19)[0]).abs() < 0.05); + assert!((o.img.get(pad + 15, pad)[0] - im.get(5, 0)[0]).abs() < 0.08); +} + +#[test] +fn reshape_moves_source_to_destination() { + // A bright square at the source mask; reshape to a shifted destination mask. + let im = crate::util::gen_image(40, 40, |x, y| if (10..20).contains(&x) && (10..20).contains(&y) { [1.0; 4] } else { [0.0, 0.0, 0.0, 1.0] }); + let masks = [rect_mask(10.0, 10.0, 20.0, 20.0), rect_mask(16.0, 10.0, 26.0, 20.0), rect_mask(0.0, 0.0, 40.0, 40.0)]; + let o = run("ec.distort.reshape", &[("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))], im, &masks); + assert!(o.img.get(24, 15)[0] > 0.8, "{:?}", o.img.get(24, 15)); + assert!(o.img.get(11, 15)[0] < 0.3, "{:?}", o.img.get(11, 15)); +} + +#[test] +fn reshape_correspondence_points_steer_the_match() { + // Matching outlines with a zero pair change nothing; a quarter-turn offset twists. + let im = img(40, 40); + let masks = [rect_mask(10.0, 10.0, 30.0, 30.0), rect_mask(10.0, 10.0, 30.0, 30.0), rect_mask(0.0, 0.0, 40.0, 40.0)]; + let base = [("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))]; + let same = run("ec.distort.reshape", &[base.as_slice(), &[("correspondencePoints", Value::Str("0,0".into()))]].concat(), im.clone(), &masks); + assert!(maxd(&same.img, &im) < 1e-3, "matching outlines: no change"); + let twisted = run("ec.distort.reshape", &[base.as_slice(), &[("correspondencePoints", Value::Str("0.25,0".into()))]].concat(), im.clone(), &masks); + assert!(maxd(&twisted.img, &im) > 0.1); + // Fraction mapping. + assert!((map_fraction(&[(0.0, 0.0), (0.5, 0.25)], 0.125, true) - 0.25).abs() < 1e-9); + assert!((map_fraction(&[(0.2, 0.0)], 0.9, true) - 0.1).abs() < 1e-9); + assert!((map_fraction(&[(0.5, 0.5)], 0.25, false) - 0.25).abs() < 1e-9); +} + +#[test] +fn upscale_grows_about_centre() { + let im = img(20, 10); + let o = run("ec.distort.upscale", &[("scale", num(200.0)), ("detail", num(0.0))], im.clone(), &[]); + assert_eq!((o.img.width, o.img.height), (40, 20)); + // Grown about the layer centre: layer point (0,0) now sits at buffer pixel (10,5), so the + // centre (10,5) lands in the middle of the 40×20 buffer. + assert_eq!(o.offset, [10.0, 5.0]); + let c = o.img.sample_bilinear(20.0, 10.0); + assert!((c[0] - im.sample_bilinear(10.0, 5.0)[0]).abs() < 0.03); +} + +#[test] +fn twirl_legacy_rotates_inside_radius() { + let im = img(40, 40); + let o = run("ec.distort.twirllegacy", &[("angle", num(180.0)), ("radius", num(100.0))], im.clone(), &[]); + assert!(maxd(&o.img, &im) > 0.1); + // Corners lie outside the radius. + assert_eq!(o.img.get(0, 0), im.get(0, 0)); +} + +#[test] +fn half_resolution_matches_scaled_geometry() { + // Bender at scale 0.5: the buffer is half size, parameters in layer pixels. + let s = crate::find("ec.distort.ccbender").unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + params.values.insert("amount".into(), num(30.0)); + params.values.insert("top".into(), pt(20.0, 0.0)); + params.values.insert("base".into(), pt(20.0, 40.0)); + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [40.0, 40.0], seed: 1, adjustment: false, env: Default::default() }; + let full = crate::apply(s, &ctx, Buf { img: img(40, 40), offset: [0.0; 2], scale: 1.0 }); + let half = crate::apply(s, &ctx, Buf { img: effectcraft_raster::resample(&img(40, 40), 20, 20), offset: [0.0; 2], scale: 0.5 }); + let a = full.img.sample_bilinear(20.0, 4.0)[3]; + let b = half.img.sample_bilinear(10.0, 2.0)[3]; + assert!((a - b).abs() < 0.3); +} diff --git a/crates/effects/src/generate.rs b/crates/effects/src/generate.rs index 637cc428e..ae0d61861 100644 --- a/crates/effects/src/generate.rs +++ b/crates/effects/src/generate.rs @@ -292,10 +292,22 @@ pub(crate) fn fade(t: f32) -> f32 { t * t * t * (t * (t * 6.0 - 15.0) + 10.0) } -/// 3D value noise in 0..1 (z = evolution). +/// 3D value noise in 0..1 (z = evolution). The quintic fade applies on all three axes, so z +/// eases in and out of every integer. pub fn value_noise(x: f32, y: f32, z: f32, seed: u32) -> f32 { + value_noise_z(x, y, z, seed, true) +} + +/// [`value_noise`] with the evolution axis z interpolated linearly (x and y keep the quintic +/// fade). The fade's zero slope at every integer z made a small Evolution change almost invisible +/// next to a whole revolution (#943, #1816); linear z moves the pattern at a constant rate. +pub fn value_noise_linear_z(x: f32, y: f32, z: f32, seed: u32) -> f32 { + value_noise_z(x, y, z, seed, false) +} + +fn value_noise_z(x: f32, y: f32, z: f32, seed: u32, ease_z: bool) -> f32 { let (x0, y0, z0) = (x.floor(), y.floor(), z.floor()); - let (fx, fy, fz) = (fade(x - x0), fade(y - y0), fade(z - z0)); + let (fx, fy, fz) = (fade(x - x0), fade(y - y0), if ease_z { fade(z - z0) } else { z - z0 }); let (ix, iy, iz) = (x0 as i32, y0 as i32, z0 as i32); let l = |a: f32, b: f32, t: f32| a + (b - a) * t; let c = |dx: i32, dy: i32, dz: i32| lattice(ix + dx, iy + dy, iz + dz, seed); @@ -414,6 +426,27 @@ mod generator_tests { Image::filled(32, 32, [1.0, 0.0, 0.0, 1.0]) } + /// #943: the evolution axis is linear, so z moves the noise at a constant rate, while the + /// classic `value_noise` keeps its eased z (other effects and their pinned goldens use it). + #[test] + fn linear_z_value_noise_moves_at_a_constant_rate() { + for (x, y, seed) in [(0.3f32, 0.7f32, 5u32), (2.4, -1.6, 11), (-3.1, 4.9, 0)] { + let a = value_noise_linear_z(x, y, 0.0, seed); + let b = value_noise_linear_z(x, y, 1.0, seed); + for z in [0.1f32, 0.25, 0.5, 0.75, 0.9] { + let want = a + (b - a) * z; + let got = value_noise_linear_z(x, y, z, seed); + assert!((got - want).abs() < 1e-5, "({x}, {y}) z {z}: {got} vs {want}"); + } + // Same lattice values at whole z, and x / y are untouched. + assert_eq!(value_noise_linear_z(x, y, 2.0, seed), value_noise(x, y, 2.0, seed)); + } + // The eased version still has the old shape: at z = 0.25 it lags the linear one. + let (a, b) = (value_noise(0.3, 0.7, 0.0, 5), value_noise(0.3, 0.7, 1.0, 5)); + let eased = value_noise(0.3, 0.7, 0.25, 5); + assert!((eased - (a + (b - a) * 0.25)).abs() > 1e-3); + } + #[test] fn gradient_ramp_stays_inside_the_layer_alpha() { // #1063: a shape's transparent pixels stay transparent; the ramp fills only the shape. diff --git a/crates/effects/src/generate2.rs b/crates/effects/src/generate2.rs index 80d32ce0e..b4781b793 100644 --- a/crates/effects/src/generate2.rs +++ b/crates/effects/src/generate2.rs @@ -61,7 +61,7 @@ const CELL_PATTERNS: [&str; 12] = [ "Tubular", ]; -/// Shaping used for Cell Pattern option `i` (see [`cell_value`]). +/// Shaping used for Cell Pattern option `i` (see `cell_value`). pub fn pattern_kind(i: u32) -> u32 { match i { 0..=5 => i, @@ -692,18 +692,33 @@ impl BoltCfg<'_> { } } -/// Grow a bolt from `a` to `z` by midpoint displacement (`depth` levels), forking at random. -/// Segments go to `out` with their intensity; `mains` marks the main core's segments. -#[allow(clippy::too_many_arguments)] -fn bolt(cfg: &BoltCfg, a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, main: bool, rng: &mut Rng, out: &mut Vec, mains: &mut Vec) { - if out.len() > 20_000 { +/// One branch's geometry and recursive growth state. +struct BoltBranch { + start: (f64, f64), + end: (f64, f64), + depth: u32, + intensity: f32, + main: bool, +} + +/// Segments and their matching main-core flags, kept in the same traversal order. +#[derive(Default)] +struct BoltOutput { + segments: Vec, + mains: Vec, +} + +/// Grow a branch by midpoint displacement, recursively appending its segments and forks. +fn bolt(cfg: &BoltCfg, branch: BoltBranch, rng: &mut Rng, output: &mut BoltOutput) { + let BoltBranch { start: a, end: z, depth, intensity: inten, main } = branch; + if output.segments.len() > 20_000 { return; } let (dx, dy) = (z.0 - a.0, z.1 - a.1); let len = (dx * dx + dy * dy).sqrt(); if depth == 0 || len < 1.0 { - out.push((a, z, inten)); - mains.push(main); + output.segments.push((a, z, inten)); + output.mains.push(main); return; } let (nx, ny) = (-dy / len.max(1e-9), dx / len.max(1e-9)); @@ -725,8 +740,8 @@ fn bolt(cfg: &BoltCfg, a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, mai if cfg.obstacle > 0.0 && !main && cfg.alpha_at(m) > 0.5 && rng.f() < cfg.obstacle / 10.0 { return; } - bolt(cfg, a, m, depth - 1, inten, main, rng, out, mains); - bolt(cfg, m, z, depth - 1, inten, main, rng, out, mains); + bolt(cfg, BoltBranch { start: a, end: m, depth: depth - 1, intensity: inten, main }, rng, output); + bolt(cfg, BoltBranch { start: m, end: z, depth: depth - 1, intensity: inten, main }, rng, output); if rng.f() < cfg.fork && inten > cfg.term && len >= cfg.min_fork { let ang = rng.s() * 0.7; let (s, c) = ang.sin_cos(); @@ -734,7 +749,7 @@ fn bolt(cfg: &BoltCfg, a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, mai let k = 1.2; // Fork Strength 50 % keeps the decayed intensity; Fork Variation randomises it. let fi = inten * (1.0 - cfg.decay) * cfg.fork_strength * 2.0 * if cfg.fork_var > 0.0 { 1.0 - cfg.fork_var * rng.f() as f32 } else { 1.0 }; - bolt(cfg, m, (m.0 + fx * k, m.1 + fy * k), depth.saturating_sub(1), fi.min(1.0), false, rng, out, mains); + bolt(cfg, BoltBranch { start: m, end: (m.0 + fx * k, m.1 + fy * k), depth: depth.saturating_sub(1), intensity: fi.min(1.0), main: false }, rng, output); } } @@ -772,10 +787,9 @@ pub fn lightning_segments(ctx: &EffectCtx, b: &Buf) -> Vec { fork_strength: (g("expertSettings/forkStrength", 50.0) / 100.0).clamp(0.0, 1.0) as f32, fork_var: (g("expertSettings/forkVariation", 0.0) / 100.0).clamp(0.0, 1.0) as f32, }; - let mut segs = Vec::new(); - let mut mains = Vec::new(); + let mut output = BoltOutput::default(); let (h, w) = (b.img.height as f64, b.img.width as f64); - let mut go = |a, z, depth, cfg: &BoltCfg, rng: &mut Rng| bolt(cfg, a, z, depth, 1.0, true, rng, &mut segs, &mut mains); + let mut go = |a, z, depth, cfg: &BoltCfg, rng: &mut Rng| bolt(cfg, BoltBranch { start: a, end: z, depth, intensity: 1.0, main: true }, rng, &mut output); // LIGHTNING_TYPES order. match kind { // Direction: travels toward the Direction point and beyond. @@ -811,6 +825,7 @@ pub fn lightning_segments(ctx: &EffectCtx, b: &Buf) -> Vec { // Strike. _ => go(o, d, depth, &cfg, &mut rng), } + let BoltOutput { segments: mut segs, mains } = output; let dist = |p: (f64, f64)| ((p.0 - o.0).powi(2) + (p.1 - o.1).powi(2)).sqrt(); let far = segs.iter().map(|(_, z, _)| dist(*z)).fold(1e-9, f64::max); // Decay Main Core: the whole bolt fades with distance from the origin. @@ -1223,315 +1238,4 @@ pub fn specs() -> Vec { } #[cfg(test)] -mod tests { - use super::*; - use crate::Params; - use effectcraft_raster::Image; - - fn run(id: &str, set: &[(&str, Value)], img: Image) -> Buf { - let s = crate::find(id).unwrap(); - let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; - let ls = [img.width as f64, img.height as f64]; - for p in &s.params { - if let (ParamUi::Point, Value::Vec2(v)) = (&p.ui, &p.default) { - params.values.insert(p.id.to_string(), Value::Vec2([v[0] * ls[0], v[1] * ls[1]])); - } - } - for (k, v) in set { - params.values.insert(k.to_string(), v.clone()); - } - let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: ls, seed: 3, adjustment: false, env: Default::default() }; - crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }) - } - - #[test] - fn cell_pattern_is_deterministic_and_seeded() { - let a = run("ec.generate.cellpattern", &[("size", num(10.0))], Image::new(40, 30)); - let b2 = run("ec.generate.cellpattern", &[("size", num(10.0))], Image::new(40, 30)); - assert_eq!(a.img, b2.img); - let c = run("ec.generate.cellpattern", &[("size", num(10.0)), ("evolutionOptions/randomSeed", num(5.0))], Image::new(40, 30)); - assert_ne!(a.img, c.img); - assert!(a.img.data.iter().all(|p| p[3] == 1.0 && (0.0..=1.0).contains(&p[0]))); - } - - #[test] - fn ellipse_ring_coverage() { - let o = run("ec.generate.ellipse", &[("width", num(40.0)), ("height", num(40.0)), ("thickness", num(6.0))], Image::new(64, 64)); - // Ring passes through (32 + 20, 32); centre is empty. - assert!(o.img.get(51, 32)[3] > 0.9, "{:?}", o.img.get(51, 32)); - assert!(o.img.get(32, 32)[3] < 1e-3); - assert!(o.img.get(2, 2)[3] < 1e-3); - } - - #[test] - fn lightning_is_deterministic_per_conductivity() { - let a = run("ec.generate.advancedlightning", &[], Image::new(64, 48)); - let b2 = run("ec.generate.advancedlightning", &[], Image::new(64, 48)); - assert_eq!(a.img, b2.img); - assert!(a.img.data.iter().any(|p| p[3] > 0.5)); - let c = run("ec.generate.advancedlightning", &[("conductivityState", num(11.0))], Image::new(64, 48)); - assert_ne!(a.img, c.img); - } - - #[test] - fn beam_draws_between_points() { - let o = run("ec.generate.beam", &[("length", num(100.0)), ("softness", num(0.0))], Image::new(64, 32)); - assert!(o.img.get(32, 16)[3] > 0.99); - assert!(o.img.get(32, 2)[3] < 1e-3); - assert!(o.img.get(2, 16)[3] < 1e-3); - } - - #[test] - fn cell_pattern_tiles() { - // 4×4-cell tiles of 8 px: the pattern repeats every 32 px. - let set = [ - ("size", num(8.0)), - ("tilingOptions/enableTiling", Value::Bool(true)), - ("tilingOptions/cellsHorizontal", num(4.0)), - ("tilingOptions/cellsVertical", num(4.0)), - ]; - let o = run("ec.generate.cellpattern", &set, Image::new(64, 64)); - for (x, y) in [(3, 5), (10, 20), (17, 2)] { - let (a, b2) = (o.img.get(x, y), o.img.get(x + 32, y + 32)); - assert!((a[0] - b2[0]).abs() < 1e-4, "{x},{y}: {a:?} vs {b2:?}"); - } - } - - #[test] - fn radio_waves_expansion_velocity_and_star() { - let r = |set: &[(&str, Value)], t: f64| { - let s = crate::find("ec.generate.radiowaves").unwrap(); - let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; - params.values.insert("producerPoint".into(), Value::Vec2([32.0, 32.0])); - for (k, v) in set { - params.values.insert(k.to_string(), v.clone()); - } - let ctx = EffectCtx { params: ¶ms, time: t, layer_size: [64.0, 64.0], seed: 3, adjustment: false, env: Default::default() }; - crate::apply(s, &ctx, Buf { img: Image::new(64, 64), offset: [0.0, 0.0], scale: 1.0 }) - }; - // One wave born at t = 0, 0.2 s old: radius = Expansion × age = 20 px. - let base = [("waveMotion/expansion", num(100.0)), ("waveMotion/frequency", num(0.5))]; - let o = r(&base, 0.2); - assert!(o.img.get(52, 32)[3] > 0.9, "{:?}", o.img.get(52, 32)); - assert!(o.img.get(32, 32)[3] < 0.01); - // Velocity moves the wave toward Direction (90° = right). - let mut moving = base.to_vec(); - moving.push(("waveMotion/velocity", num(50.0))); - let o = r(&moving, 0.2); - assert!(o.img.get(62, 32)[3] > 0.9 && o.img.get(52, 32)[3] < 0.5, "{:?}", o.img.get(62, 32)); - // A 4-point star: the outline dips toward the centre between the points. - let mut star = base.to_vec(); - star.extend([("polygon/sides", num(4.0)), ("polygon/star", Value::Bool(true)), ("polygon/starDepth", num(-0.5))]); - let o = r(&star, 0.2); - assert!(o.img.get(32, 12)[3] > 0.9, "point straight up"); - // Between points (45°) the outline sits near half the radius. - assert!(o.img.get(39, 24)[3] > 0.5, "{:?}", o.img.get(39, 24)); - assert!(o.img.get(46, 18)[3] < 0.01, "no circle there"); - } - - #[test] - fn lightning_alpha_obstacle_and_expert_settings() { - let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::(); - let alpha_in = |img: &Image, src: &Image| img.data.iter().zip(&src.data).filter(|(_, s)| s[3] > 0.5).map(|(p, _)| p[3]).sum::(); - // A wall of opaque pixels across the middle of the bolt's path. - let mut wall = Image::new(64, 48); - for y in 0..48 { - for x in 0..64 { - if (18..30).contains(&y) && x < 50 { - wall.set(x, y, [0.0, 0.0, 0.0, 1.0]); - } - } - } - let opts = |o: f64| { - vec![("alphaObstacle", num(o)), ("compositeOnOriginal", Value::Bool(false)), ("glowSettings/glowRadius", num(0.0)), ("forking", num(60.0))] - }; - let free = run("ec.generate.advancedlightning", &opts(0.0), wall.clone()); - let avoid = run("ec.generate.advancedlightning", &opts(10.0), wall.clone()); - assert!(alpha_in(&avoid.img, &wall) < alpha_in(&free.img, &wall), "{} vs {}", alpha_in(&avoid.img, &wall), alpha_in(&free.img, &wall)); - // Fractal Type, Core Drain, Fork Strength, Termination Threshold, Min Fork Distance. - let base = run("ec.generate.advancedlightning", &[], Image::new(64, 48)); - for (id, v) in [ - ("expertSettings/fractalType", Value::Enum(2)), - ("expertSettings/coreDrain", num(100.0)), - ("expertSettings/forkStrength", num(100.0)), - ("expertSettings/forkVariation", num(100.0)), - ] { - assert_ne!(run("ec.generate.advancedlightning", &[(id, v)], Image::new(64, 48)).img, base.img, "{id}"); - } - let drained = run("ec.generate.advancedlightning", &[("expertSettings/coreDrain", num(100.0))], Image::new(64, 48)); - assert!(sum(&drained.img) < sum(&base.img)); - let no_forks = run("ec.generate.advancedlightning", &[("expertSettings/minForkDistance", num(1000.0))], Image::new(64, 48)); - let term = run("ec.generate.advancedlightning", &[("expertSettings/terminationThreshold", num(100.0))], Image::new(64, 48)); - assert_eq!(no_forks.img, term.img, "no forks either way"); - assert!(sum(&no_forks.img) < sum(&base.img)); - } - - #[test] - fn lightning_types_and_decay_main_core() { - let a = run("ec.generate.advancedlightning", &[], Image::new(64, 48)); - let strike = run("ec.generate.advancedlightning", &[("lightningType", Value::Enum(1))], Image::new(64, 48)); - assert_eq!(a.img, strike.img, "Strike is the default"); - for t in 0..8 { - let o = run("ec.generate.advancedlightning", &[("lightningType", Value::Enum(t))], Image::new(64, 48)); - assert!(o.img.data.iter().any(|p| p[3] > 0.3), "type {t} draws"); - } - let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::(); - let d = run("ec.generate.advancedlightning", &[("decayMainCore", Value::Bool(true)), ("decay", num(0.9))], Image::new(64, 48)); - let n = run("ec.generate.advancedlightning", &[("decay", num(0.9))], Image::new(64, 48)); - assert!(sum(&d.img) < sum(&n.img)); - } - - #[test] - fn beam_3d_perspective_controls_thickness() { - // Short beam near the start: with 3D Perspective its thickness comes from its position - // (thin, near the start); without, its head takes the full ending thickness. - let set = |p: bool| { - vec![ - ("length", num(20.0)), - ("time", num(0.0)), - ("softness", num(0.0)), - ("startThickness", num(2.0)), - ("endThickness", num(20.0)), - ("perspective3d", Value::Bool(p)), - ] - }; - let on = run("ec.generate.beam", &set(true), Image::new(64, 32)); - let off = run("ec.generate.beam", &set(false), Image::new(64, 32)); - let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::(); - assert!(sum(&off.img) > sum(&on.img) * 1.5, "{} {}", sum(&off.img), sum(&on.img)); - } - - #[test] - fn lens_flare_brightest_at_center() { - let o = run("ec.generate.lensflare", &[], Image::new(64, 64)); - let c = o.img.get(16, 16)[0]; - assert!(c > o.img.get(60, 4)[0]); - assert!(c > 0.5); - } - - fn rw(set: &[(&str, Value)], t: f64, env: crate::EffectEnv) -> Image { - let mut v: Vec<(&str, Value)> = - vec![("producerPoint", Value::Vec2([32.0, 32.0])), ("waveMotion/frequency", num(0.5)), ("waveMotion/expansion", num(100.0))]; - v.extend_from_slice(set); - crate::run_fx("ec.generate.radiowaves", &v, Image::new(64, 64), t, env).img - } - - #[test] - fn signed_distance_of_a_square() { - let (w, h) = (20, 20); - let inside: Vec = (0..w * h).map(|i| (5..15).contains(&(i % w)) && (5..15).contains(&(i / w))).collect(); - let d = crate::util::signed_distance(&inside, w, h); - assert!((d.get(10, 10) + 4.5).abs() < 1e-4, "{}", d.get(10, 10)); - assert!((d.get(0, 10) - 4.5).abs() < 1e-4, "{}", d.get(0, 10)); - assert!((d.get(0, 0) - (50f32.sqrt() - 0.5)).abs() < 1e-4); - } - - #[test] - fn radio_waves_mask_contours_profile_reflection_and_birth() { - // Mask waves: a 10×10 square mask; after 0.1 s (10 px) the wave is 10 px outside it. - let sq = crate::MaskShape { name: String::new(), points: vec![[27.0, 27.0], [37.0, 27.0], [37.0, 37.0], [27.0, 37.0]], closed: true, inverted: false }; - let masks = [sq]; - let env = crate::EffectEnv { masks: &masks, ..Default::default() }; - let m = rw(&[("waveType", Value::Enum(2)), ("waveMask/mask", num(1.0))], 0.1, env); - assert!(m.get(47, 32)[3] > 0.9, "{:?}", m.get(47, 32)); - assert!(m.get(32, 32)[3] < 0.01 && m.get(40, 32)[3] < 0.01); - // Rounded corner: diagonal distance 10 from (37, 37). - assert!(m.get(44, 44)[3] > 0.5, "{:?}", m.get(44, 44)); - // No mask chosen: nothing drawn. - assert!(rw(&[("waveType", Value::Enum(2))], 0.1, env).data.iter().all(|p| p[3] == 0.0)); - // Image Contours: the bright square of a source layer. - struct Src; - impl crate::EffectHost for Src { - fn layer(&self, _: u64, _: bool) -> Option { - let mut img = Image::new(64, 64); - for y in 27..37 { - for x in 27..37 { - img.set(x, y, [1.0, 1.0, 1.0, 1.0]); - } - } - Some(crate::LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size: [64.0, 64.0] }) - } - fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { - None - } - fn params_at(&self, t: f64) -> Option { - // Colour animates from red (born at 0) to green (born later). - let s = crate::find("ec.generate.radiowaves").unwrap(); - let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; - p.values.insert("producerPoint".into(), Value::Vec2([32.0, 32.0])); - p.values.insert("waveMotion/frequency".into(), num(1.0)); - p.values.insert("waveMotion/expansion".into(), num(20.0)); - p.values.insert("waveStroke/color".into(), if t < 0.5 { col(1.0, 0.0, 0.0) } else { col(0.0, 1.0, 0.0) }); - Some(p) - } - } - let env = crate::EffectEnv { host: Some(&Src), ..Default::default() }; - let ic = rw( - &[ - ("waveType", Value::Enum(1)), - ("imageContour/sourceLayer", Value::Layer(Some(1))), - ("imageContour/preBlur", num(0.0)), - ("imageContour/tolerance", num(0.0)), - ], - 0.1, - env, - ); - assert!(ic.get(47, 32)[3] > 0.9 && ic.get(32, 32)[3] < 0.01, "{:?}", ic.get(47, 32)); - // Source Center off the square shifts the contour onto the producer point accordingly. - let sh = rw( - &[ - ("waveType", Value::Enum(1)), - ("imageContour/sourceLayer", Value::Layer(Some(1))), - ("imageContour/sourceCenter", Value::Vec2([22.0, 32.0])), - ("imageContour/preBlur", num(0.0)), - ("imageContour/tolerance", num(0.0)), - ], - 0.1, - env, - ); - assert!(sh.get(57, 32)[3] > 0.9, "{:?}", sh.get(57, 32)); - // Profile: Taper Both thins the stroke edges; Square is solid across. - let wide = [("waveStroke/startWidth", num(10.0)), ("waveStroke/endWidth", num(10.0))]; - let sqr = rw(&wide, 0.2, crate::EffectEnv::default()); - let mut tap = wide.to_vec(); - tap.push(("waveStroke/profile", Value::Enum(3))); - let tap = rw(&tap, 0.2, crate::EffectEnv::default()); - assert!(sqr.get(55, 32)[3] > 0.9 && tap.get(55, 32)[3] < 0.6, "{:?} {:?}", sqr.get(55, 32), tap.get(55, 32)); - assert!(tap.get(52, 32)[3] > 0.8); - // Reflection: a fast wave bounces back off the right edge. - let fast = [("waveMotion/velocity", num(200.0)), ("waveMotion/expansion", num(10.0))]; - let gone = rw(&fast, 0.3, crate::EffectEnv::default()); - let mut rf = fast.to_vec(); - rf.push(("waveMotion/reflection", Value::Bool(true))); - let back = rw(&rf, 0.3, crate::EffectEnv::default()); - let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::(); - assert!(sum(&back) > sum(&gone) + 5.0, "{} vs {}", sum(&back), sum(&gone)); - // Parameters Are Set At Birth: the older wave keeps red, the newer one is green. - let both = rw(&[("parametersAreSetAt", Value::Enum(0)), ("waveMotion/frequency", num(1.0)), ("waveMotion/expansion", num(20.0))], 1.15, env); - let (old, new) = (both.get(55, 32), both.get(35, 32)); - assert!(old[0] > 0.9 && old[1] < 0.1, "{old:?}"); - assert!(new[1] > 0.9 && new[0] < 0.1, "{new:?}"); - } - - #[test] - fn cell_pattern_hq_variants_antialias() { - let s = crate::find("ec.generate.cellpattern").unwrap(); - let r = |pat: u32| { - let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; - params.values.insert("cellPattern".into(), Value::Enum(pat)); - params.values.insert("size".into(), num(12.0)); - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [48.0, 48.0], seed: 1, adjustment: false, env: Default::default() }; - crate::apply(s, &ctx, Buf { img: Image::new(48, 48), offset: [0.0; 2], scale: 1.0 }).img - }; - let distinct = |img: &Image| { - let mut v: Vec = img.data.iter().map(|p| (p[0] * 10000.0) as u32).collect(); - v.sort_unstable(); - v.dedup(); - v.len() - }; - let (std, hq) = (r(4), r(9)); - assert_eq!(CELL_PATTERNS[9], "Crystallize HQ"); - assert_ne!(std, hq); - assert!(distinct(&hq) > distinct(&std) * 2, "{} vs {}", distinct(&hq), distinct(&std)); - } -} +mod tests; diff --git a/crates/effects/src/generate2/tests.rs b/crates/effects/src/generate2/tests.rs new file mode 100644 index 000000000..c2acff446 --- /dev/null +++ b/crates/effects/src/generate2/tests.rs @@ -0,0 +1,309 @@ +use super::*; +use crate::Params; +use effectcraft_raster::Image; + +fn run(id: &str, set: &[(&str, Value)], img: Image) -> Buf { + let s = crate::find(id).unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + let ls = [img.width as f64, img.height as f64]; + for p in &s.params { + if let (ParamUi::Point, Value::Vec2(v)) = (&p.ui, &p.default) { + params.values.insert(p.id.to_string(), Value::Vec2([v[0] * ls[0], v[1] * ls[1]])); + } + } + for (k, v) in set { + params.values.insert(k.to_string(), v.clone()); + } + let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: ls, seed: 3, adjustment: false, env: Default::default() }; + crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }) +} + +#[test] +fn cell_pattern_is_deterministic_and_seeded() { + let a = run("ec.generate.cellpattern", &[("size", num(10.0))], Image::new(40, 30)); + let b2 = run("ec.generate.cellpattern", &[("size", num(10.0))], Image::new(40, 30)); + assert_eq!(a.img, b2.img); + let c = run("ec.generate.cellpattern", &[("size", num(10.0)), ("evolutionOptions/randomSeed", num(5.0))], Image::new(40, 30)); + assert_ne!(a.img, c.img); + assert!(a.img.data.iter().all(|p| p[3] == 1.0 && (0.0..=1.0).contains(&p[0]))); +} + +#[test] +fn ellipse_ring_coverage() { + let o = run("ec.generate.ellipse", &[("width", num(40.0)), ("height", num(40.0)), ("thickness", num(6.0))], Image::new(64, 64)); + // Ring passes through (32 + 20, 32); centre is empty. + assert!(o.img.get(51, 32)[3] > 0.9, "{:?}", o.img.get(51, 32)); + assert!(o.img.get(32, 32)[3] < 1e-3); + assert!(o.img.get(2, 2)[3] < 1e-3); +} + +#[test] +fn lightning_is_deterministic_per_conductivity() { + let a = run("ec.generate.advancedlightning", &[], Image::new(64, 48)); + let b2 = run("ec.generate.advancedlightning", &[], Image::new(64, 48)); + assert_eq!(a.img, b2.img); + assert!(a.img.data.iter().any(|p| p[3] > 0.5)); + let c = run("ec.generate.advancedlightning", &[("conductivityState", num(11.0))], Image::new(64, 48)); + assert_ne!(a.img, c.img); +} + +#[test] +fn beam_draws_between_points() { + let o = run("ec.generate.beam", &[("length", num(100.0)), ("softness", num(0.0))], Image::new(64, 32)); + assert!(o.img.get(32, 16)[3] > 0.99); + assert!(o.img.get(32, 2)[3] < 1e-3); + assert!(o.img.get(2, 16)[3] < 1e-3); +} + +#[test] +fn cell_pattern_tiles() { + // 4×4-cell tiles of 8 px: the pattern repeats every 32 px. + let set = [ + ("size", num(8.0)), + ("tilingOptions/enableTiling", Value::Bool(true)), + ("tilingOptions/cellsHorizontal", num(4.0)), + ("tilingOptions/cellsVertical", num(4.0)), + ]; + let o = run("ec.generate.cellpattern", &set, Image::new(64, 64)); + for (x, y) in [(3, 5), (10, 20), (17, 2)] { + let (a, b2) = (o.img.get(x, y), o.img.get(x + 32, y + 32)); + assert!((a[0] - b2[0]).abs() < 1e-4, "{x},{y}: {a:?} vs {b2:?}"); + } +} + +#[test] +fn radio_waves_expansion_velocity_and_star() { + let r = |set: &[(&str, Value)], t: f64| { + let s = crate::find("ec.generate.radiowaves").unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + params.values.insert("producerPoint".into(), Value::Vec2([32.0, 32.0])); + for (k, v) in set { + params.values.insert(k.to_string(), v.clone()); + } + let ctx = EffectCtx { params: ¶ms, time: t, layer_size: [64.0, 64.0], seed: 3, adjustment: false, env: Default::default() }; + crate::apply(s, &ctx, Buf { img: Image::new(64, 64), offset: [0.0, 0.0], scale: 1.0 }) + }; + // One wave born at t = 0, 0.2 s old: radius = Expansion × age = 20 px. + let base = [("waveMotion/expansion", num(100.0)), ("waveMotion/frequency", num(0.5))]; + let o = r(&base, 0.2); + assert!(o.img.get(52, 32)[3] > 0.9, "{:?}", o.img.get(52, 32)); + assert!(o.img.get(32, 32)[3] < 0.01); + // Velocity moves the wave toward Direction (90° = right). + let mut moving = base.to_vec(); + moving.push(("waveMotion/velocity", num(50.0))); + let o = r(&moving, 0.2); + assert!(o.img.get(62, 32)[3] > 0.9 && o.img.get(52, 32)[3] < 0.5, "{:?}", o.img.get(62, 32)); + // A 4-point star: the outline dips toward the centre between the points. + let mut star = base.to_vec(); + star.extend([("polygon/sides", num(4.0)), ("polygon/star", Value::Bool(true)), ("polygon/starDepth", num(-0.5))]); + let o = r(&star, 0.2); + assert!(o.img.get(32, 12)[3] > 0.9, "point straight up"); + // Between points (45°) the outline sits near half the radius. + assert!(o.img.get(39, 24)[3] > 0.5, "{:?}", o.img.get(39, 24)); + assert!(o.img.get(46, 18)[3] < 0.01, "no circle there"); +} + +#[test] +fn lightning_alpha_obstacle_and_expert_settings() { + let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::(); + let alpha_in = |img: &Image, src: &Image| img.data.iter().zip(&src.data).filter(|(_, s)| s[3] > 0.5).map(|(p, _)| p[3]).sum::(); + // A wall of opaque pixels across the middle of the bolt's path. + let mut wall = Image::new(64, 48); + for y in 0..48 { + for x in 0..64 { + if (18..30).contains(&y) && x < 50 { + wall.set(x, y, [0.0, 0.0, 0.0, 1.0]); + } + } + } + let opts = + |o: f64| vec![("alphaObstacle", num(o)), ("compositeOnOriginal", Value::Bool(false)), ("glowSettings/glowRadius", num(0.0)), ("forking", num(60.0))]; + let free = run("ec.generate.advancedlightning", &opts(0.0), wall.clone()); + let avoid = run("ec.generate.advancedlightning", &opts(10.0), wall.clone()); + assert!(alpha_in(&avoid.img, &wall) < alpha_in(&free.img, &wall), "{} vs {}", alpha_in(&avoid.img, &wall), alpha_in(&free.img, &wall)); + // Fractal Type, Core Drain, Fork Strength, Termination Threshold, Min Fork Distance. + let base = run("ec.generate.advancedlightning", &[], Image::new(64, 48)); + for (id, v) in [ + ("expertSettings/fractalType", Value::Enum(2)), + ("expertSettings/coreDrain", num(100.0)), + ("expertSettings/forkStrength", num(100.0)), + ("expertSettings/forkVariation", num(100.0)), + ] { + assert_ne!(run("ec.generate.advancedlightning", &[(id, v)], Image::new(64, 48)).img, base.img, "{id}"); + } + let drained = run("ec.generate.advancedlightning", &[("expertSettings/coreDrain", num(100.0))], Image::new(64, 48)); + assert!(sum(&drained.img) < sum(&base.img)); + let no_forks = run("ec.generate.advancedlightning", &[("expertSettings/minForkDistance", num(1000.0))], Image::new(64, 48)); + let term = run("ec.generate.advancedlightning", &[("expertSettings/terminationThreshold", num(100.0))], Image::new(64, 48)); + assert_eq!(no_forks.img, term.img, "no forks either way"); + assert!(sum(&no_forks.img) < sum(&base.img)); +} + +#[test] +fn lightning_types_and_decay_main_core() { + let a = run("ec.generate.advancedlightning", &[], Image::new(64, 48)); + let strike = run("ec.generate.advancedlightning", &[("lightningType", Value::Enum(1))], Image::new(64, 48)); + assert_eq!(a.img, strike.img, "Strike is the default"); + for t in 0..8 { + let o = run("ec.generate.advancedlightning", &[("lightningType", Value::Enum(t))], Image::new(64, 48)); + assert!(o.img.data.iter().any(|p| p[3] > 0.3), "type {t} draws"); + } + let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::(); + let d = run("ec.generate.advancedlightning", &[("decayMainCore", Value::Bool(true)), ("decay", num(0.9))], Image::new(64, 48)); + let n = run("ec.generate.advancedlightning", &[("decay", num(0.9))], Image::new(64, 48)); + assert!(sum(&d.img) < sum(&n.img)); +} + +#[test] +fn beam_3d_perspective_controls_thickness() { + // Short beam near the start: with 3D Perspective its thickness comes from its position + // (thin, near the start); without, its head takes the full ending thickness. + let set = |p: bool| { + vec![ + ("length", num(20.0)), + ("time", num(0.0)), + ("softness", num(0.0)), + ("startThickness", num(2.0)), + ("endThickness", num(20.0)), + ("perspective3d", Value::Bool(p)), + ] + }; + let on = run("ec.generate.beam", &set(true), Image::new(64, 32)); + let off = run("ec.generate.beam", &set(false), Image::new(64, 32)); + let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::(); + assert!(sum(&off.img) > sum(&on.img) * 1.5, "{} {}", sum(&off.img), sum(&on.img)); +} + +#[test] +fn lens_flare_brightest_at_center() { + let o = run("ec.generate.lensflare", &[], Image::new(64, 64)); + let c = o.img.get(16, 16)[0]; + assert!(c > o.img.get(60, 4)[0]); + assert!(c > 0.5); +} + +fn rw(set: &[(&str, Value)], t: f64, env: crate::EffectEnv) -> Image { + let mut v: Vec<(&str, Value)> = + vec![("producerPoint", Value::Vec2([32.0, 32.0])), ("waveMotion/frequency", num(0.5)), ("waveMotion/expansion", num(100.0))]; + v.extend_from_slice(set); + crate::run_fx("ec.generate.radiowaves", &v, Image::new(64, 64), t, env).img +} + +#[test] +fn signed_distance_of_a_square() { + let (w, h) = (20, 20); + let inside: Vec = (0..w * h).map(|i| (5..15).contains(&(i % w)) && (5..15).contains(&(i / w))).collect(); + let d = crate::util::signed_distance(&inside, w, h); + assert!((d.get(10, 10) + 4.5).abs() < 1e-4, "{}", d.get(10, 10)); + assert!((d.get(0, 10) - 4.5).abs() < 1e-4, "{}", d.get(0, 10)); + assert!((d.get(0, 0) - (50f32.sqrt() - 0.5)).abs() < 1e-4); +} + +#[test] +fn radio_waves_mask_contours_profile_reflection_and_birth() { + // Mask waves: a 10×10 square mask; after 0.1 s (10 px) the wave is 10 px outside it. + let sq = crate::MaskShape { name: String::new(), points: vec![[27.0, 27.0], [37.0, 27.0], [37.0, 37.0], [27.0, 37.0]], closed: true, inverted: false }; + let masks = [sq]; + let env = crate::EffectEnv { masks: &masks, ..Default::default() }; + let m = rw(&[("waveType", Value::Enum(2)), ("waveMask/mask", num(1.0))], 0.1, env); + assert!(m.get(47, 32)[3] > 0.9, "{:?}", m.get(47, 32)); + assert!(m.get(32, 32)[3] < 0.01 && m.get(40, 32)[3] < 0.01); + // Rounded corner: diagonal distance 10 from (37, 37). + assert!(m.get(44, 44)[3] > 0.5, "{:?}", m.get(44, 44)); + // No mask chosen: nothing drawn. + assert!(rw(&[("waveType", Value::Enum(2))], 0.1, env).data.iter().all(|p| p[3] == 0.0)); + // Image Contours: the bright square of a source layer. + struct Src; + impl crate::EffectHost for Src { + fn layer(&self, _: u64, _: bool) -> Option { + let mut img = Image::new(64, 64); + for y in 27..37 { + for x in 27..37 { + img.set(x, y, [1.0, 1.0, 1.0, 1.0]); + } + } + Some(crate::LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size: [64.0, 64.0] }) + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn params_at(&self, t: f64) -> Option { + // Colour animates from red (born at 0) to green (born later). + let s = crate::find("ec.generate.radiowaves").unwrap(); + let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + p.values.insert("producerPoint".into(), Value::Vec2([32.0, 32.0])); + p.values.insert("waveMotion/frequency".into(), num(1.0)); + p.values.insert("waveMotion/expansion".into(), num(20.0)); + p.values.insert("waveStroke/color".into(), if t < 0.5 { col(1.0, 0.0, 0.0) } else { col(0.0, 1.0, 0.0) }); + Some(p) + } + } + let env = crate::EffectEnv { host: Some(&Src), ..Default::default() }; + let ic = rw( + &[ + ("waveType", Value::Enum(1)), + ("imageContour/sourceLayer", Value::Layer(Some(1))), + ("imageContour/preBlur", num(0.0)), + ("imageContour/tolerance", num(0.0)), + ], + 0.1, + env, + ); + assert!(ic.get(47, 32)[3] > 0.9 && ic.get(32, 32)[3] < 0.01, "{:?}", ic.get(47, 32)); + // Source Center off the square shifts the contour onto the producer point accordingly. + let sh = rw( + &[ + ("waveType", Value::Enum(1)), + ("imageContour/sourceLayer", Value::Layer(Some(1))), + ("imageContour/sourceCenter", Value::Vec2([22.0, 32.0])), + ("imageContour/preBlur", num(0.0)), + ("imageContour/tolerance", num(0.0)), + ], + 0.1, + env, + ); + assert!(sh.get(57, 32)[3] > 0.9, "{:?}", sh.get(57, 32)); + // Profile: Taper Both thins the stroke edges; Square is solid across. + let wide = [("waveStroke/startWidth", num(10.0)), ("waveStroke/endWidth", num(10.0))]; + let sqr = rw(&wide, 0.2, crate::EffectEnv::default()); + let mut tap = wide.to_vec(); + tap.push(("waveStroke/profile", Value::Enum(3))); + let tap = rw(&tap, 0.2, crate::EffectEnv::default()); + assert!(sqr.get(55, 32)[3] > 0.9 && tap.get(55, 32)[3] < 0.6, "{:?} {:?}", sqr.get(55, 32), tap.get(55, 32)); + assert!(tap.get(52, 32)[3] > 0.8); + // Reflection: a fast wave bounces back off the right edge. + let fast = [("waveMotion/velocity", num(200.0)), ("waveMotion/expansion", num(10.0))]; + let gone = rw(&fast, 0.3, crate::EffectEnv::default()); + let mut rf = fast.to_vec(); + rf.push(("waveMotion/reflection", Value::Bool(true))); + let back = rw(&rf, 0.3, crate::EffectEnv::default()); + let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::(); + assert!(sum(&back) > sum(&gone) + 5.0, "{} vs {}", sum(&back), sum(&gone)); + // Parameters Are Set At Birth: the older wave keeps red, the newer one is green. + let both = rw(&[("parametersAreSetAt", Value::Enum(0)), ("waveMotion/frequency", num(1.0)), ("waveMotion/expansion", num(20.0))], 1.15, env); + let (old, new) = (both.get(55, 32), both.get(35, 32)); + assert!(old[0] > 0.9 && old[1] < 0.1, "{old:?}"); + assert!(new[1] > 0.9 && new[0] < 0.1, "{new:?}"); +} + +#[test] +fn cell_pattern_hq_variants_antialias() { + let s = crate::find("ec.generate.cellpattern").unwrap(); + let r = |pat: u32| { + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + params.values.insert("cellPattern".into(), Value::Enum(pat)); + params.values.insert("size".into(), num(12.0)); + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [48.0, 48.0], seed: 1, adjustment: false, env: Default::default() }; + crate::apply(s, &ctx, Buf { img: Image::new(48, 48), offset: [0.0; 2], scale: 1.0 }).img + }; + let distinct = |img: &Image| { + let mut v: Vec = img.data.iter().map(|p| (p[0] * 10000.0) as u32).collect(); + v.sort_unstable(); + v.dedup(); + v.len() + }; + let (std, hq) = (r(4), r(9)); + assert_eq!(CELL_PATTERNS[9], "Crystallize HQ"); + assert_ne!(std, hq); + assert!(distinct(&hq) > distinct(&std) * 2, "{} vs {}", distinct(&hq), distinct(&std)); +} diff --git a/crates/effects/src/generate3.rs b/crates/effects/src/generate3.rs index 6beec4cb7..a61c3f769 100644 --- a/crates/effects/src/generate3.rs +++ b/crates/effects/src/generate3.rs @@ -17,2430 +17,26 @@ use rayon::prelude::*; use crate::util::{Plane, gauss_plane, hash1, layer_or_self, morph_frac, poly_length, premul, smoothstep, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, Params, col, num, p, popup, slider}; -fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { - EffectSpec { id, name, category: "Generate", params, render, gpu: false, float: true } -} - -fn pt(x: f64, y: f64) -> Value { - Value::Vec2([x, y]) -} - -fn pct() -> ParamUi { - slider(0.0, 100.0, 0.0, 100.0, 1) -} - -// ---------------------------------------------------------------- shared rasterisation - -/// A round-capped line segment in buffer pixels with radius `r` and intensity `v`. -#[derive(Clone, Copy, Debug)] -pub struct Seg { - pub a: [f64; 2], - pub b: [f64; 2], - pub r: f64, - pub v: f32, -} - -/// Rasterise segments into a coverage plane (max-combined). `hardness` (0..1) is the fraction -/// of the radius that is fully opaque; the rest falls off smoothly. -pub(crate) fn raster_segs(w: usize, h: usize, segs: &[Seg], hardness: f64) -> Plane { - let mut out = Plane::new(w, h); - if w == 0 || h == 0 || segs.is_empty() { - return out; - } - let mut buckets: Vec> = vec![Vec::new(); h]; - for (i, s) in segs.iter().enumerate() { - let pad = s.r + 1.0; - let y0 = (s.a[1].min(s.b[1]) - pad).floor().max(0.0) as usize; - let y1 = (s.a[1].max(s.b[1]) + pad).ceil().min(h as f64); - if y1 <= 0.0 || y0 >= h { - continue; - } - for row in buckets.iter_mut().take(y1 as usize).skip(y0) { - row.push(i as u32); - } - } - let hard = hardness.clamp(0.0, 1.0); - out.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { - let py = y as f64 + 0.5; - for &i in &buckets[y] { - let s = segs[i as usize]; - let pad = s.r + 1.0; - let x0 = (s.a[0].min(s.b[0]) - pad).floor().max(0.0) as usize; - let x1 = ((s.a[0].max(s.b[0]) + pad).ceil().max(0.0) as usize).min(w); - let (dx, dy) = (s.b[0] - s.a[0], s.b[1] - s.a[1]); - let l2 = dx * dx + dy * dy; - let inner = s.r * hard - 0.5; - let outer = s.r + 0.5; - for (x, o) in row.iter_mut().enumerate().take(x1).skip(x0) { - let px = x as f64 + 0.5; - let t = if l2 > 1e-12 { (((px - s.a[0]) * dx + (py - s.a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 }; - let d = ((px - s.a[0] - dx * t).powi(2) + (py - s.a[1] - dy * t).powi(2)).sqrt(); - if d >= outer { - continue; - } - let c = if d <= inner { 1.0 } else { 1.0 - smoothstep(inner as f32, outer as f32, d as f32) } * s.v; - if c > *o { - *o = c; - } - } - } - }); - out -} - -/// Segments along a polyline (buffer px). -pub fn poly_segs(pts: &[[f64; 2]], closed: bool, r: f64, v: f32, out: &mut Vec) { - let n = pts.len(); - if n == 1 { - out.push(Seg { a: pts[0], b: pts[0], r, v }); - } - if n < 2 { - return; - } - let segs = if closed { n } else { n - 1 }; - for i in 0..segs { - out.push(Seg { a: pts[i], b: pts[(i + 1) % n], r, v }); - } -} - -/// Portion `[s0, s1]` (arc length) of a polyline. -pub(crate) fn trim_poly(pts: &[[f64; 2]], closed: bool, s0: f64, s1: f64) -> Vec<[f64; 2]> { - let n = pts.len(); - let mut out = Vec::new(); - if n < 2 || s1 <= s0 { - return out; - } - let segs = if closed { n } else { n - 1 }; - let mut acc = 0.0; - for i in 0..segs { - let (a, b) = (pts[i], pts[(i + 1) % n]); - let l = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt(); - let (e0, e1) = (acc, acc + l); - acc = e1; - if e1 < s0 || e0 > s1 || l <= 0.0 { - continue; - } - let at = |s: f64| { - let t = ((s - e0) / l).clamp(0.0, 1.0); - [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t] - }; - let p0 = at(s0.max(e0)); - if out.last() != Some(&p0) { - out.push(p0); - } - out.push(at(s1.min(e1))); - } - out -} - -/// How painted coverage meets the layer: 0 On Original Image, 1 On Transparent, 2 Reveal -/// Original Image. -pub(crate) fn paint(img: &mut Image, cov: &Plane, color: [f32; 4], opacity: f32, style: u32) { - img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &c)| { - let a = (c * opacity * color[3]).clamp(0.0, 1.0); - let s = [color[0] * a, color[1] * a, color[2] * a, a]; - *px = match style { - 1 => s, - 2 => [px[0] * a, px[1] * a, px[2] * a, px[3] * a], - _ => over(*px, s), - }; - }); -} - -/// Coverage of a plan-drawn generator: round-capped segments rasterised by [`raster_segs`] -/// with `hardness`, or the union of convex polygons ([`raster_convex`]). -#[derive(Clone, Debug)] -pub enum Coverage { - Segs { segs: Vec, hardness: f64 }, - Convex(Vec>), -} - -impl Coverage { - fn raster(&self, w: usize, h: usize) -> Plane { - match self { - Coverage::Segs { segs, hardness } => raster_segs(w, h, segs, *hardness), - Coverage::Convex(polys) => raster_convex(w, h, polys), - } - } -} - -/// Stroke, Scribble, Vegas and Write-on, shared with the GPU compositor (effectcraft-gpu -/// `fx_gen2`): the coverage and how it is painted. `style` 0–2 as [`paint`] (On Original -/// Image, On Transparent, Reveal Original Image), 3 = under the layer (Vegas' Composite Under). -#[derive(Clone, Debug)] -pub struct PaintPlan { - pub cov: Coverage, - pub color: [f32; 4], - pub opacity: f32, - pub style: u32, -} - -/// Draw a [`PaintPlan`]. -fn draw_paint(b: &mut Buf, plan: &PaintPlan) { - let cov = plan.cov.raster(b.img.width as usize, b.img.height as usize); - if plan.style == 3 { - let color = plan.color; - b.img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &c)| { - let a = c * color[3]; - *px = over([color[0] * a, color[1] * a, color[2] * a, a], *px); - }); - } else { - paint(&mut b.img, &cov, plan.color, plan.opacity, plan.style); - } -} - -/// Generators drawn from a plan that need the layer's own pixels (`b.img`) to build it. -pub fn plan_reads_pixels(id: &str, ctx: &EffectCtx) -> bool { - match id { - // Image Contours read the layer itself when no (resolvable) Input Layer is chosen. - "ec.generate.vegas" => ctx.params.e("stroke") == 0, - "ec.generate.paintbucket" | "ec.generate.eyedropperfill" => true, - "ec.generate.advancedlightning" => ctx.params.f("alphaObstacle").clamp(-10.0, 10.0) != 0.0, - _ => false, - } -} - -/// [`PaintPlan`] of Stroke, Scribble, Vegas or Write-on on `b`'s geometry (`b.img` is read -/// only when [`plan_reads_pixels`]). `None`: Scribble without a usable mask (the layer is -/// cleared when it composites On Transparent, else kept). -pub fn paint_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option { - match id { - "ec.generate.stroke" => Some(stroke_plan(ctx, b)), - "ec.generate.scribble" => scribble_plan(ctx, b), - "ec.generate.vegas" => Some(vegas_plan(ctx, b)), - "ec.generate.writeon" => Some(write_on_plan(ctx, b)), - _ => None, - } -} - -/// Scribble's Composite as a [`paint`] style. -pub fn scribble_style(ctx: &EffectCtx) -> u32 { - match ctx.params.e("composite") { - 0 => 0, - 2 => 2, - _ => 1, - } -} - -#[inline] -fn over(dst: Px, src: Px) -> Px { - let k = 1.0 - src[3]; - [src[0] + dst[0] * k, src[1] + dst[1] * k, src[2] + dst[2] * k, src[3] + dst[3] * k] -} - -/// Mask `idx` (1-based; 0 = none) mapped to buffer pixels. -pub(crate) fn mask_px(ctx: &EffectCtx, b: &Buf, idx: usize) -> Option<(Vec<[f64; 2]>, bool, bool)> { - let m = ctx.env.masks.get(idx.checked_sub(1)?)?; - if m.points.is_empty() { - return None; - } - Some((m.points.iter().map(|&q| [q[0] * b.scale + b.offset[0], q[1] * b.scale + b.offset[1]]).collect(), m.closed, m.inverted)) -} - -/// All masks mapped to buffer pixels. -fn all_masks_px(ctx: &EffectCtx, b: &Buf) -> Vec<(Vec<[f64; 2]>, bool, bool)> { - (1..=ctx.env.masks.len()).filter_map(|i| mask_px(ctx, b, i)).collect() -} - -/// Chaikin corner cutting (open polyline, endpoints kept). -fn chaikin(pts: &[[f64; 2]], iters: usize) -> Vec<[f64; 2]> { - let mut cur = pts.to_vec(); - for _ in 0..iters { - if cur.len() < 3 { - return cur; - } - let mut nx = Vec::with_capacity(cur.len() * 2); - nx.push(cur[0]); - for w in cur.windows(2) { - let (a, b) = (w[0], w[1]); - nx.push([a[0] * 0.75 + b[0] * 0.25, a[1] * 0.75 + b[1] * 0.25]); - nx.push([a[0] * 0.25 + b[0] * 0.75, a[1] * 0.25 + b[1] * 0.75]); - } - nx.push(*cur.last().unwrap_or(&[0.0; 2])); - cur = nx; - } - cur -} - -// ---------------------------------------------------------------- Fractal - -#[derive(Clone, Copy)] -struct FracSet { - /// Mandelbrot iterations start at the Julia centre (the "Over Julia" set choices). - over_julia: bool, - julia: bool, - inverse: bool, -} - -/// Smooth escape count, or `None` inside the set. -fn escape(z0: (f64, f64), c: (f64, f64), power: u32, limit: f64, max_iter: u32) -> Option { - let (mut x, mut y) = z0; - let lim2 = limit * limit; - for i in 0..max_iter { - let (nx, ny) = match power { - 3 => (x * x * x - 3.0 * x * y * y, 3.0 * x * x * y - y * y * y), - 4 => { - let (a, b) = (x * x - y * y, 2.0 * x * y); - (a * a - b * b, 2.0 * a * b) - } - _ => (x * x - y * y, 2.0 * x * y), - }; - x = nx + c.0; - y = ny + c.1; - let m2 = x * x + y * y; - if m2 > lim2 { - let lz = m2.ln() * 0.5; - let nu = (lz / limit.max(1.0001).ln()).max(1e-9).ln() / (power as f64).ln(); - return Some((i as f64 + 1.0 - nu).max(0.0)); - } - } - None -} - -/// Fractal Set Choice options. -pub(crate) const FRACTAL_SETS: [&str; 6] = - ["Mandelbrot", "Mandelbrot Inverse", "Mandelbrot Over Julia", "Mandelbrot Inverse Over Julia", "Julia", "Julia Inverse"]; -/// Fractal Palette options. -pub(crate) const FRACTAL_PALETTES: [&str; 4] = ["Lightness Gradient", "Hue Wheel", "Black And White", "Solid Color"]; -/// Fractal Oversample Method options. -pub(crate) const FRACTAL_OVERSAMPLE: [&str; 2] = ["Edge Detect-Fast-May Miss Pixels", "Brute Force-Slow-Every Pixel"]; - -fn fractal(ctx: &EffectCtx, mut b: Buf) -> Buf { - let pr = ctx.params; - let set_choice = pr.e("setChoice"); - let power = 2 + pr.e("equation").min(2); - let mc = (pr.f("mandelbrot/mandelbrotX"), pr.f("mandelbrot/mandelbrotY")); - let mmag = pr.f("mandelbrot/mandelbrotMagnification"); - let mlim = pr.f("mandelbrot/mandelbrotEscapeLimit").max(1.01); - let jc = (pr.f("julia/juliaX"), pr.f("julia/juliaY")); - let jmag = pr.f("julia/juliaMagnification"); - let jlim = pr.f("julia/juliaEscapeLimit").max(1.01); - let post = (pr.f("postInversionOffset/postInversionX"), pr.f("postInversionOffset/postInversionY")); - let overlay = pr.b("fractalColor/overlay"); - let transparency = pr.b("fractalColor/transparency"); - let transparent_inside = transparency || overlay; - let palette = pr.e("fractalColor/palette"); - let hue = (pr.f("fractalColor/hue") / 360.0) as f32; - let steps = pr.f("fractalColor/cycleSteps").max(1.0); - let cyc_off = pr.f("fractalColor/cycleOffset") / 360.0; - let edge = pr.b("fractalColor/edgeHighlight"); - let every = pr.e("highQualitySettings/samplingMethod") == 1; - let factor = pr.f("highQualitySettings/samplingFactor").round().clamp(1.0, 9.0) as usize; - let (w, h) = (b.img.width as usize, b.img.height as usize); - let lw = ctx.layer_size[0].max(1.0); - let lh = ctx.layer_size[1].max(1.0); - let inv = 1.0 / b.scale.max(1e-9); - let (off_x, off_y) = (b.offset[0], b.offset[1]); - // FRACTAL_SETS order. - let set = |julia, inverse, over_julia| FracSet { over_julia, julia, inverse }; - let sets: (FracSet, Option) = match set_choice { - 1 => (set(false, true, false), None), - 2 => (set(false, false, true), None), - 3 => (set(false, true, true), None), - 4 => (set(true, false, false), None), - 5 => (set(true, true, false), None), - _ => (set(false, false, false), None), - }; - let iter_for = |mag: f64| (96.0 + 48.0 * mag.max(0.0)).clamp(64.0, 3000.0) as u32; - let eval_set = |s: FracSet, lx: f64, ly: f64| -> Option { - let (center, mag, lim) = if s.julia { (jc, jmag, jlim) } else { (mc, mmag, mlim) }; - let span = 3.5 / 2f64.powf(mag); - let mut u = (lx - lw * 0.5) / lw * span + center.0; - let mut v = (ly - lh * 0.5) / lw * span + center.1; - if s.inverse { - let d = (u * u + v * v).max(1e-12); - u = u / d + post.0; - v = -v / d + post.1; - } - if s.julia { - escape((u, v), mc, power, lim, iter_for(mag)) - } else { - let z0 = if s.over_julia { jc } else { (0.0, 0.0) }; - escape(z0, (u, v), power, lim, iter_for(mag)) - } - }; - let eval = |lx: f64, ly: f64| -> Option { - let a = eval_set(sets.0, lx, ly); - match (a, sets.1) { - (None, Some(s2)) => eval_set(s2, lx, ly), - _ => a, - } - }; - let shade = |n: Option| -> Px { - // Solid Color: only the inside of the set is drawn (Transparency flips the sides). - if palette == 3 { - let (r, g, bl) = hsl_to_rgb(hue, 1.0, 0.5); - return if n.is_none() != transparency { [r, g, bl, 1.0] } else { [0.0; 4] }; - } - let Some(n) = n else { - return if transparent_inside { [0.0; 4] } else { [0.0, 0.0, 0.0, 1.0] }; - }; - let u = n / steps + cyc_off; - let band = (u.rem_euclid(1.0)) as f32; - let (r, g, bl) = match palette { - // Hue Wheel: the full hue circle at full saturation and brightness. - 1 => hsl_to_rgb((hue + band).rem_euclid(1.0), 1.0, 0.5), - // Black And White: alternating bands. - 2 => { - let v = if (u.floor() as i64).rem_euclid(2) == 0 { 0.0 } else { 1.0 }; - (v, v, v) - } - // Lightness Gradient: black → hue → white, the hue moving 45° each cycle. - _ => hsl_to_rgb((hue + u.floor() as f32 * 0.125).rem_euclid(1.0), 0.85, band.clamp(0.0, 1.0)), - }; - let mut c = [r, g, bl]; - if edge && n.fract() < 0.12 { - c = [c[0] * 0.4 + 0.6, c[1] * 0.4 + 0.6, c[2] * 0.4 + 0.6]; - } - [c[0], c[1], c[2], 1.0] - }; - // First pass: one sample per pixel. - let mut first: Vec> = vec![None; w * h]; - first.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { - for (x, v) in row.iter_mut().enumerate() { - *v = eval((x as f64 + 0.5 - off_x) * inv, (y as f64 + 0.5 - off_y) * inv); - } - }); - let src = if overlay { Some(b.img.clone()) } else { None }; - b.img.rows_mut().for_each(|(y, row)| { - for (x, px) in row.iter_mut().enumerate() { - let here = first[y * w + x]; - let needs_ss = factor > 1 - && (every || { - let mut diff = false; - for (dx, dy) in [(-1i64, 0i64), (1, 0), (0, -1), (0, 1)] { - let (nx, ny) = (x as i64 + dx, y as i64 + dy); - if nx < 0 || ny < 0 || nx >= w as i64 || ny >= h as i64 { - continue; - } - let o = first[ny as usize * w + nx as usize]; - diff |= match (here, o) { - (Some(a), Some(c)) => (a - c).abs() > steps * 0.25, - (None, None) => false, - _ => true, - }; - } - diff - }); - let c = if needs_ss { - let mut acc = [0.0f32; 4]; - for sy in 0..factor { - for sx in 0..factor { - let fx = x as f64 + (sx as f64 + 0.5) / factor as f64; - let fy = y as f64 + (sy as f64 + 0.5) / factor as f64; - let s = shade(eval((fx - off_x) * inv, (fy - off_y) * inv)); - let a = s[3]; - for k in 0..3 { - acc[k] += s[k] * a; - } - acc[3] += a; - } - } - let n = (factor * factor) as f32; - [acc[0] / n, acc[1] / n, acc[2] / n, acc[3] / n] - } else { - let s = shade(here); - [s[0] * s[3], s[1] * s[3], s[2] * s[3], s[3]] - }; - *px = match &src { - Some(o) => over(o.data[y * w + x], c), - None => c, - }; - } - }); - b -} - -// ---------------------------------------------------------------- Scribble - -/// Scan runs (start, end along direction `d`) of the fill region on the line `n·p = s`. -#[allow(clippy::too_many_arguments)] -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 { - let m = pts.len(); - if m < 2 { - continue; - } - // Crossings: (t along d, |sin| of crossing angle). - let mut xs: Vec<(f64, f64)> = Vec::new(); - for i in 0..m { - let (a, b) = (pts[i], pts[(i + 1) % m]); - let (na, nb) = (a[0] * n[0] + a[1] * n[1] - s, b[0] * n[0] + b[1] * n[1] - s); - if (na > 0.0) == (nb > 0.0) { - continue; - } - let t = na / (na - nb); - let q = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; - let (ex, ey) = (b[0] - a[0], b[1] - a[1]); - let el = (ex * ex + ey * ey).sqrt().max(1e-9); - let sin = ((ex * n[0] + ey * n[1]) / el).abs().max(0.2); - xs.push((q[0] * d[0] + q[1] * d[1], sin)); - } - xs.sort_by(|a, b| a.0.total_cmp(&b.0)); - // Signed area decides which side is "left". - let area: f64 = (0..m).map(|i| pts[i][0] * pts[(i + 1) % m][1] - pts[(i + 1) % m][0] * pts[i][1]).sum(); - let left_is_inside = area < 0.0; - let mode = match fill { - 4 => { - if left_is_inside { - 2 - } else { - 3 - } - } - 5 => { - if left_is_inside { - 3 - } else { - 2 - } - } - f => f, - }; - let mode = if *inverted && mode == 0 { 6 } else { mode }; - for (k, &(t, sin)) in xs.iter().enumerate() { - let entering = k % 2 == 0; - let l = ew / sin; - match mode { - 0 if entering && k + 1 < xs.len() => { - out.push((t, xs[k + 1].0)); - } - 1 => out.push((t - l * 0.5, t + l * 0.5)), - 2 => out.push(if entering { (t, t + l) } else { (t - l, t) }), - 3 => out.push(if entering { (t - l, t) } else { (t, t + l) }), - _ => {} - } - } - if mode == 6 { - // Inverted Inside: outside of the polygon within the bounds. - let lo = pts.iter().map(|q| q[0] * d[0] + q[1] * d[1]).fold(f64::INFINITY, f64::min) - ew; - let hi = pts.iter().map(|q| q[0] * d[0] + q[1] * d[1]).fold(f64::NEG_INFINITY, f64::max) + ew; - let mut cur = lo; - for pair in xs.chunks(2) { - out.push((cur, pair[0].0)); - cur = pair.get(1).map(|v| v.0).unwrap_or(hi); - } - out.push((cur, hi)); - } - } - out.sort_by(|a, b| a.0.total_cmp(&b.0)); - // Merge overlaps. - let mut merged: Vec<(f64, f64)> = Vec::with_capacity(out.len()); - for &(a, b) in out.iter() { - if let Some(last) = merged.last_mut() - && a <= last.1 - { - last.1 = last.1.max(b); - continue; - } - merged.push((a, b)); - } - *out = merged; -} - -/// End Cap and Join options (Scribble's Edge Options). -const END_CAPS: [&str; 3] = ["Round", "Butt", "Projecting"]; -const JOINS: [&str; 3] = ["Round", "Miter", "Bevel"]; - -/// Convex polygons whose union is the stroke of the polylines `lines` with half width `r`: -/// one quad per segment, plus the joins (Round discs, Miter wedges up to `miter_limit` × the -/// half width, Bevel triangles) and the caps (Round discs, Butt none, Projecting square ends). -pub(crate) fn stroke_outline(lines: &[Vec<[f64; 2]>], r: f64, cap: u32, join: u32, miter_limit: f64) -> Vec> { - let disc = |c: [f64; 2]| -> Vec<[f64; 2]> { - (0..16).map(|k| (k as f64 / 16.0 * std::f64::consts::TAU).sin_cos()).map(|(s, co)| [c[0] + co * r, c[1] + s * r]).collect() - }; - let mut out = Vec::new(); - for l in lines { - let pts: Vec<[f64; 2]> = l.iter().copied().fold(Vec::new(), |mut v, p| { - if v.last().is_none_or(|q: &[f64; 2]| (q[0] - p[0]).hypot(q[1] - p[1]) > 1e-9) { - v.push(p); - } - v - }); - if pts.len() < 2 { - if let (Some(p), 0) = (pts.first(), cap) { - out.push(disc(*p)); - } - continue; - } - let dir = |a: [f64; 2], b: [f64; 2]| { - let (dx, dy) = (b[0] - a[0], b[1] - a[1]); - let l = dx.hypot(dy).max(1e-12); - [dx / l, dy / l] - }; - let n = pts.len(); - for k in 0..n - 1 { - let (mut a, mut b) = (pts[k], pts[k + 1]); - let d = dir(a, b); - // Projecting caps extend the end segments by the half width. - if cap == 2 && k == 0 { - a = [a[0] - d[0] * r, a[1] - d[1] * r]; - } - if cap == 2 && k == n - 2 { - b = [b[0] + d[0] * r, b[1] + d[1] * r]; - } - let nn = [-d[1] * r, d[0] * r]; - out.push(vec![[a[0] + nn[0], a[1] + nn[1]], [b[0] + nn[0], b[1] + nn[1]], [b[0] - nn[0], b[1] - nn[1]], [a[0] - nn[0], a[1] - nn[1]]]); - } - // Joins. - for k in 1..n - 1 { - let (p, d0, d1) = (pts[k], dir(pts[k - 1], pts[k]), dir(pts[k], pts[k + 1])); - let cross = d0[0] * d1[1] - d0[1] * d1[0]; - if cross.abs() < 1e-9 && d0[0] * d1[0] + d0[1] * d1[1] > 0.0 { - continue; - } - if join == 0 { - out.push(disc(p)); - continue; - } - // The outer side of the turn. - let s = if cross > 0.0 { -1.0 } else { 1.0 }; - let o0 = [p[0] - d0[1] * r * s, p[1] + d0[0] * r * s]; - let o1 = [p[0] - d1[1] * r * s, p[1] + d1[0] * r * s]; - let bevel = vec![p, o0, o1]; - if join == 1 { - // Miter point: where the two offset edges meet. - let bis = [d0[0] - d1[0], d0[1] - d1[1]]; - let bl = bis[0].hypot(bis[1]); - let cos_half = ((1.0 + d0[0] * d1[0] + d0[1] * d1[1]) * 0.5).max(0.0).sqrt(); - let len = if cos_half > 1e-6 { r / cos_half } else { f64::INFINITY }; - if bl > 1e-9 && len <= miter_limit * r { - let m = [p[0] + (o0[0] + o1[0] - 2.0 * p[0]) * 0.5, p[1] + (o0[1] + o1[1] - 2.0 * p[1]) * 0.5]; - let ml = (m[0] - p[0]).hypot(m[1] - p[1]).max(1e-12); - let tip = [p[0] + (m[0] - p[0]) / ml * len, p[1] + (m[1] - p[1]) / ml * len]; - out.push(vec![p, o0, tip, o1]); - continue; - } - } - out.push(bevel); - } - if cap == 0 { - out.push(disc(pts[0])); - out.push(disc(pts[n - 1])); - } - } - out -} - -/// Coverage (union, 4×4 samples per pixel) of convex polygons on a `w`×`h` grid. -pub(crate) fn raster_convex(w: usize, h: usize, polys: &[Vec<[f64; 2]>]) -> Plane { - let mut out = Plane::new(w, h); - let inside = |poly: &[[f64; 2]], x: f64, y: f64| { - let n = poly.len(); - let mut sign = 0.0; - for i in 0..n { - let (a, b) = (poly[i], poly[(i + 1) % n]); - let c = (b[0] - a[0]) * (y - a[1]) - (b[1] - a[1]) * (x - a[0]); - if c.abs() < 1e-12 { - continue; - } - if sign == 0.0 { - sign = c.signum(); - } else if c.signum() != sign { - return false; - } - } - true - }; - for poly in polys { - if poly.len() < 3 { - continue; - } - let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); - for p in poly { - x0 = x0.min(p[0]); - y0 = y0.min(p[1]); - x1 = x1.max(p[0]); - y1 = y1.max(p[1]); - } - let (xa, xb) = (x0.floor().max(0.0) as usize, (x1.ceil().max(0.0) as usize).min(w)); - let (ya, yb) = (y0.floor().max(0.0) as usize, (y1.ceil().max(0.0) as usize).min(h)); - for y in ya..yb { - for x in xa..xb { - let mut hit = 0; - for sy in 0..4 { - for sx in 0..4 { - if inside(poly, x as f64 + (sx as f64 + 0.5) / 4.0, y as f64 + (sy as f64 + 0.5) / 4.0) { - hit += 1; - } - } - } - let v = &mut out.data[y * w + x]; - *v = v.max(hit as f32 / 16.0); - } - } - } - out -} - -fn scribble(ctx: &EffectCtx, mut b: Buf) -> Buf { - match scribble_plan(ctx, &b) { - Some(plan) => draw_paint(&mut b, &plan), - None if scribble_style(ctx) == 1 => b.img = Image::new(b.img.width, b.img.height), - None => {} - } - b -} - -fn scribble_plan(ctx: &EffectCtx, b: &Buf) -> Option { - let pr = ctx.params; - let which = pr.e("scribble"); - let polys: Vec<_> = if which == 0 { mask_px(ctx, b, pr.f("mask").round().max(1.0) as usize).into_iter().collect() } else { all_masks_px(ctx, b) }; - let polys: Vec<_> = polys.into_iter().filter(|p| p.0.len() >= 3).map(|(p, c, i)| (p, c, i && which == 2)).collect(); - let style = scribble_style(ctx); - if polys.is_empty() { - return None; - } - let sc = b.scale; - let fill = pr.e("fillType"); - let ew = pr.f("edgeOptions/edgeWidth").max(0.1) * sc; - let ang = pr.f("angle").to_radians(); - let d = [ang.cos(), ang.sin()]; - let n = [-d[1], d[0]]; - let width = (pr.f("strokeWidth") * sc).max(0.1); - let curv = pr.f("strokeOptions/curviness") / 100.0; - let curv_var = pr.f("strokeOptions/curvinessVariation") / 100.0; - let spacing = (pr.f("strokeOptions/spacing") * sc).max(0.75).max(width * 0.25); - let spacing_var = pr.f("strokeOptions/spacingVariation") / 100.0; - let overlap = pr.f("strokeOptions/pathOverlap") / 100.0; - let overlap_var = pr.f("strokeOptions/pathOverlapVariation") / 100.0; - let start = pr.f("start").clamp(0.0, 100.0) / 100.0; - let end = pr.f("end").clamp(0.0, 100.0) / 100.0; - let sequential = pr.b("fillPathsSequentially"); - let wiggle = pr.e("wiggleType"); - let wps = pr.f("wigglesPerSecond").max(0.0); - let seed = pr.f("randomSeed") as i64 as u32 ^ ctx.seed; - let (step, frac) = match wiggle { - 0 => (0u32, 0.0f32), - 1 => ((ctx.time * wps).floor().max(0.0) as u32, 0.0), - _ => { - let u = (ctx.time * wps).max(0.0); - (u.floor() as u32, smoothstep(0.0, 1.0, u.fract() as f32)) - } - }; - let rnd = |i: u32, k: u32| { - let a = hash1(i, k.wrapping_mul(31).wrapping_add(step.wrapping_mul(7919)), seed) * 2.0 - 1.0; - if frac > 0.0 { - let c = hash1(i, k.wrapping_mul(31).wrapping_add((step + 1).wrapping_mul(7919)), seed) * 2.0 - 1.0; - a + (c - a) * frac - } else { - a - } - }; - let groups: Vec, bool, bool)>> = - if which == 0 || sequential { vec![polys.clone()] } else { polys.iter().map(|p| vec![p.clone()]).collect() }; - // Per group a zig-zag polyline (in buffer px). - let mut lines: Vec>> = Vec::new(); - let mut runs = Vec::new(); - for (gi, g) in groups.iter().enumerate() { - let mut nmin = f64::INFINITY; - let mut nmax = f64::NEG_INFINITY; - for (pts, _, _) in g { - for q in pts { - let v = q[0] * n[0] + q[1] * n[1]; - nmin = nmin.min(v); - nmax = nmax.max(v); - } - } - nmin -= ew; - nmax += ew; - // Chains of zig-zags: each run joins the nearest chain that ended on the previous - // scan line (so separate regions get separate strokes). - let mut chains: Vec<(Vec<[f64; 2]>, u32)> = Vec::new(); - let mut s = nmin + spacing * 0.5; - let mut li = 0u32; - let reach = spacing * 3.0 + ew; - while s < nmax && li < 20_000 { - scan_runs(g, fill, ew, d, n, s, &mut runs); - for (ri, &(a, bb)) in runs.iter().enumerate() { - let k = (gi as u32) << 20 | ri as u32; - let ov = spacing * (overlap + overlap_var * rnd(li, k * 4) as f64 * 0.5).max(-0.45); - let ov2 = spacing * (overlap + overlap_var * rnd(li, k * 4 + 1) as f64 * 0.5).max(-0.45); - let (a, bb) = (a - ov, bb + ov2); - if bb <= a { - continue; - } - let at = |t: f64| [d[0] * t + n[0] * s, d[1] * t + n[1] * s]; - let (pa, pb) = (at(a), at(bb)); - let dist = |p: [f64; 2], q: [f64; 2]| ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2)).sqrt(); - let best = chains - .iter() - .enumerate() - .filter(|(_, c)| li > 0 && c.1 == li - 1) - .map(|(i, c)| { - let last = *c.0.last().unwrap_or(&pa); - (i, dist(last, pa).min(dist(last, pb))) - }) - .filter(|&(_, dd)| dd < reach) - .min_by(|x, y| x.1.total_cmp(&y.1)); - match best { - Some((ci, _)) => { - let last = *chains[ci].0.last().unwrap_or(&pa); - let (p0, p1, dir) = if dist(last, pa) <= dist(last, pb) { (pa, pb, -1.0) } else { (pb, pa, 1.0) }; - // Curved turn: an extra point pushed past the edge. - let c = (curv + curv_var * rnd(li, k * 4 + 2) as f64 * 0.5).max(0.0); - let mid = [(last[0] + p0[0]) * 0.5 + d[0] * dir * c * spacing, (last[1] + p0[1]) * 0.5 + d[1] * dir * c * spacing]; - let ch = &mut chains[ci]; - ch.0.extend([mid, p0, p1]); - ch.1 = li; - } - None => chains.push((vec![pa, pb], li)), - } - } - s += spacing * (1.0 + spacing_var * rnd(li, 3) as f64 * 0.5).max(0.25); - li += 1; - } - lines.push(chains.into_iter().map(|(z, _)| if curv > 0.0 { chaikin(&z, 2) } else { z }).collect()); - } - let r = width * 0.5; - // Start / End Apply To: all of a mask's strokes as one path, or each stroke on its own. - let each = pr.e("startEndApplyTo") == 1; - let mut parts: Vec> = Vec::new(); - for group in &lines { - let lens: Vec = group.iter().map(|l| poly_length(l, false)).collect(); - let total: f64 = lens.iter().sum(); - let (g0, g1) = (total * start, total * end); - let mut acc = 0.0; - for (l, len) in group.iter().zip(lens) { - let part = - if each { trim_poly(l, false, len * start, len * end) } else { trim_poly(l, false, (g0 - acc).clamp(0.0, len), (g1 - acc).clamp(0.0, len)) }; - acc += len; - parts.push(part); - } - } - let cap = pr.e("edgeOptions/endCap"); - let join = pr.e("edgeOptions/join"); - let cov = if cap == 0 && join == 0 { - let mut segs = Vec::new(); - for part in &parts { - poly_segs(part, false, r, 1.0, &mut segs); - } - Coverage::Segs { segs, hardness: 0.9 } - } else { - Coverage::Convex(stroke_outline(&parts, r, cap, join, pr.f("edgeOptions/miterLimit").max(1.0))) - }; - Some(PaintPlan { cov, color: pr.color("color"), opacity: (pr.f("opacity") / 100.0) as f32, style }) -} - -// ---------------------------------------------------------------- Stroke - -fn stroke(ctx: &EffectCtx, mut b: Buf) -> Buf { - let plan = stroke_plan(ctx, &b); - draw_paint(&mut b, &plan); - b -} - -fn stroke_plan(ctx: &EffectCtx, b: &Buf) -> PaintPlan { - let pr = ctx.params; - let all = pr.b("allMasks"); - let polys: Vec<_> = if all { all_masks_px(ctx, b) } else { mask_px(ctx, b, pr.f("path").round() as usize).into_iter().collect() }; - let style = pr.e("paintStyle"); - let size = (pr.f("brushSize") * b.scale).max(0.1); - let r = size * 0.5; - let hard = pr.f("brushHardness") / 100.0; - let start = pr.f("start").clamp(0.0, 100.0) / 100.0; - let end = pr.f("end").clamp(0.0, 100.0) / 100.0; - let spacing = pr.f("spacing") / 100.0; - let sequential = pr.b("strokeSequentially"); - let lens: Vec = polys.iter().map(|(p, c, _)| poly_length(p, *c)).collect(); - let total: f64 = lens.iter().sum(); - let mut segs = Vec::new(); - let mut acc = 0.0; - for ((pts, closed, _), &len) in polys.iter().zip(&lens) { - let (s0, s1) = if all && sequential { - let (g0, g1) = (total * start, total * end); - ((g0 - acc).clamp(0.0, len), (g1 - acc).clamp(0.0, len)) - } else { - (len * start, len * end) - }; - acc += len; - if s1 <= s0 { - continue; - } - if spacing <= 0.5 { - let part = trim_poly(pts, *closed, s0, s1); - poly_segs(&part, false, r, 1.0, &mut segs); - } else { - let step = (size * spacing).max(0.5); - let mut s = s0; - while s <= s1 + 1e-9 && segs.len() < 200_000 { - let (q, _) = crate::util::poly_point_at(pts, *closed, s); - segs.push(Seg { a: q, b: q, r, v: 1.0 }); - s += step; - } - } - } - PaintPlan { cov: Coverage::Segs { segs, hardness: hard }, color: pr.color("color"), opacity: (pr.f("opacity") / 100.0) as f32, style } -} - -// ---------------------------------------------------------------- Vegas - -/// Marching-squares iso-contours of `pl` at `thr`, chained into polylines (closed flag). -fn contours(pl: &Plane, thr: f32) -> Vec<(Vec<[f64; 2]>, bool)> { - use std::collections::HashMap; - let (w, h) = (pl.w, pl.h); - if w < 2 || h < 2 { - return Vec::new(); - } - // Edge key: horizontal edge (x,y)-(x+1,y) = 2*(y*w+x), vertical (x,y)-(x,y+1) = 2*(y*w+x)+1. - let hk = |x: usize, y: usize| 2 * (y * w + x) as u64; - let vk = |x: usize, y: usize| 2 * (y * w + x) as u64 + 1; - let mut pos: HashMap = HashMap::new(); - let mut adj: HashMap> = HashMap::new(); - let v = |x: usize, y: usize| pl.data[y * w + x]; - let interp = |a: f32, b: f32| if (b - a).abs() > 1e-9 { ((thr - a) / (b - a)).clamp(0.0, 1.0) as f64 } else { 0.5 }; - for y in 0..h - 1 { - for x in 0..w - 1 { - let (a, bb, c, d) = (v(x, y), v(x + 1, y), v(x + 1, y + 1), v(x, y + 1)); - let idx = (a > thr) as u8 | ((bb > thr) as u8) << 1 | ((c > thr) as u8) << 2 | ((d > thr) as u8) << 3; - if idx == 0 || idx == 15 { - continue; - } - // Edge points (pixel centres at +0.5). - let top = (hk(x, y), [x as f64 + 0.5 + interp(a, bb), y as f64 + 0.5]); - let right = (vk(x + 1, y), [x as f64 + 1.5, y as f64 + 0.5 + interp(bb, c)]); - let bottom = (hk(x, y + 1), [x as f64 + 0.5 + interp(d, c), y as f64 + 1.5]); - let left = (vk(x, y), [x as f64 + 0.5, y as f64 + 0.5 + interp(a, d)]); - let pairs: &[(usize, usize)] = match idx { - 1 | 14 => &[(3, 0)], - 2 | 13 => &[(0, 1)], - 3 | 12 => &[(3, 1)], - 4 | 11 => &[(1, 2)], - 5 => &[(3, 0), (1, 2)], - 6 | 9 => &[(0, 2)], - 7 | 8 => &[(3, 2)], - 10 => &[(0, 1), (2, 3)], - _ => &[], - }; - let e = [top, right, bottom, left]; - for &(i, j) in pairs { - pos.insert(e[i].0, e[i].1); - pos.insert(e[j].0, e[j].1); - adj.entry(e[i].0).or_default().push(e[j].0); - adj.entry(e[j].0).or_default().push(e[i].0); - } - } - } - let mut keys: Vec = adj.keys().copied().collect(); - keys.sort_unstable(); - // Start open chains at endpoints (degree 1) first, then closed loops. - keys.sort_by_key(|k| adj[k].len() != 1); - let mut used: std::collections::HashSet<(u64, u64)> = std::collections::HashSet::new(); - let mut out = Vec::new(); - for &k0 in &keys { - for &first in &adj[&k0] { - if used.contains(&(k0.min(first), k0.max(first))) { - continue; - } - let mut chain = vec![pos[&k0]]; - let (mut prev, mut cur) = (k0, first); - used.insert((prev.min(cur), prev.max(cur))); - let mut closed = false; - loop { - chain.push(pos[&cur]); - if cur == k0 { - closed = true; - chain.pop(); - break; - } - let next = adj[&cur].iter().copied().find(|&n| n != prev && !used.contains(&(cur.min(n), cur.max(n)))); - match next { - Some(n) => { - used.insert((cur.min(n), cur.max(n))); - prev = cur; - cur = n; - } - None => break, - } - } - if chain.len() >= 2 { - out.push((chain, closed)); - } - } - } - out -} - -fn vegas(ctx: &EffectCtx, mut b: Buf) -> Buf { - let plan = vegas_plan(ctx, &b); - draw_paint(&mut b, &plan); - b -} - -fn vegas_plan(ctx: &EffectCtx, b: &Buf) -> PaintPlan { - let pr = ctx.params; - let (w, h) = (b.img.width as usize, b.img.height as usize); - let sc = b.scale; - let paths: Vec<(Vec<[f64; 2]>, bool)> = if pr.e("stroke") == 1 { - mask_px(ctx, b, pr.f("path").round() as usize).map(|(p, c, _)| vec![(p, c)]).unwrap_or_default() - } else { - let src = layer_or_self(ctx, b, "inputLayer", true, pr.e("ifLayerSizesDiffer") == 1); - let ch = pr.e("channel"); - let inv = pr.b("invertInput"); - let mut pl = Plane::from_image(&src, |px| { - let (c, a) = unpremul(px); - let v = match ch { - 1 => c[0], - 2 => c[1], - 3 => c[2], - 4 => a, - _ => luminance(c[0], c[1], c[2]) * a, - }; - if inv { 1.0 - v } else { v } - }); - let blur = pr.f("preBlur") * sc; - if blur > 0.05 { - pl = gauss_plane(&pl, blur, blur); - } - let mut cs = contours(&pl, (pr.f("threshold") / 100.0) as f32); - // Simplify by tolerance (drop points closer than tol × size). - let tol = pr.f("tolerance").max(0.0) * (w.max(h) as f64) * 0.1; - if tol > 0.0 { - for (c, _) in cs.iter_mut() { - let mut keep: Vec<[f64; 2]> = Vec::with_capacity(c.len()); - for &q in c.iter() { - if keep.last().is_none_or(|l: &[f64; 2]| ((l[0] - q[0]).powi(2) + (l[1] - q[1]).powi(2)).sqrt() >= tol) { - keep.push(q); - } - } - *c = keep; - } - } - cs.sort_by(|a, b| poly_length(&b.0, b.1).total_cmp(&poly_length(&a.0, a.1))); - if pr.e("renderMode") == 1 { - let sel = (pr.f("selectedContour").round().max(1.0) as usize) - 1; - cs.into_iter().skip(sel).take(1).collect() - } else { - cs - } - }; - let nseg = pr.f("segments").round().clamp(1.0, 1000.0) as usize; - // Shorter Contours Have Fewer Segments: segment count scales with contour length. - let fewer = pr.e("stroke") == 0 && pr.e("shorterContoursHave") == 1; - let longest = paths.iter().map(|(p, c)| poly_length(p, *c)).fold(0.0, f64::max); - let seg_len = pr.f("length").clamp(0.0, 1.0); - let bunched = pr.e("segmentDistribution") == 0; - let rot = pr.f("rotation") / 360.0; - let random_phase = pr.b("randomPhase"); - let seed = pr.f("randomSeed") as i64 as u32 ^ ctx.seed; - let width = (pr.f("width") * sc).max(0.1); - let hard = pr.f("hardness").clamp(0.0, 1.0); - let (o0, om, o1, mp) = (pr.f("startOpacity") as f32, pr.f("midpointOpacity") as f32, pr.f("endOpacity") as f32, pr.f("midpointPosition").clamp(0.01, 0.99)); - let mut segs = Vec::new(); - for (ci, (pts, closed)) in paths.iter().enumerate() { - let len = poly_length(pts, *closed); - if len <= 0.0 { - continue; - } - let phase = rot + if random_phase { hash1(ci as u32, 1, seed) as f64 } else { 0.0 }; - let nseg = if fewer && longest > 0.0 { ((nseg as f64 * len / longest).round() as usize).max(1) } else { nseg }; - let each = len / nseg as f64 * seg_len; - for i in 0..nseg { - let base = if bunched { (i as f64 + (hash1(ci as u32, i as u32 + 7, seed) as f64 - 0.5) * 0.8) / nseg as f64 } else { i as f64 / nseg as f64 }; - let s0 = (base + phase).rem_euclid(1.0) * len; - // Sub-sample the segment so opacity can ramp along it. - let sub = 12usize; - let mut prev: Option<[f64; 2]> = None; - for k in 0..=sub { - let f = k as f64 / sub as f64; - let mut s = s0 + each * f; - if *closed { - s = s.rem_euclid(len); - } else if s > len { - break; - } - let (q, _) = crate::util::poly_point_at(pts, *closed, s); - let op = if f < mp { o0 + (om - o0) * (f / mp) as f32 } else { om + (o1 - om) * ((f - mp) / (1.0 - mp)) as f32 }; - if let Some(pv) = prev - && ((pv[0] - q[0]).powi(2) + (pv[1] - q[1]).powi(2)) < (each * 0.5).powi(2) + 4.0 - { - segs.push(Seg { a: pv, b: q, r: width * 0.5, v: op.clamp(0.0, 1.0) }); - } - prev = Some(q); - } - } - } - let style = match pr.e("blendMode") { - 1 => 0, - 2 => 3, - 3 => 2, - _ => 1, - }; - PaintPlan { cov: Coverage::Segs { segs, hardness: hard }, color: pr.color("color"), opacity: 1.0, style } -} - -// ---------------------------------------------------------------- Write-on / Glue Gun - -/// Write-on draws a stroke along the animated Brush Position, as in After Effects: a dab every -/// Brush Spacing seconds from the layer's start up to now, each one lasting Stroke Length -/// seconds (0: for ever). The dabs' positions come from the host's parameters at their times; -/// with Paint Time Properties ▸ Opacity or Brush Time Properties ▸ Size each dab keeps its own -/// opacity or size, else the current ones apply to every dab. (Colour and hardness are one per -/// stroke: the current ones.) Without a host (or dabs) the current dab draws. Deterministic. -fn write_on(ctx: &EffectCtx, mut b: Buf) -> Buf { - let plan = write_on_plan(ctx, &b); - draw_paint(&mut b, &plan); - b -} - -/// The most dabs one Write-on frame draws; longer strokes lay them further apart, joined. -const WRITE_ON_MAX_DABS: i64 = 4096; - -fn write_on_plan(ctx: &EffectCtx, b: &Buf) -> PaintPlan { - let pr = ctx.params; - let own_opacity = pr.e("paintTimeProperties") == 1; - let own_size = matches!(pr.e("brushTimeProperties"), 1 | 3); - let opacity = |p: &Params| (p.f("brushOpacity") / 100.0).clamp(0.0, 1.0) as f32; - let radius = |p: &Params| (p.f("brushSize") * b.scale * 0.5).max(0.05); - let dab = |p: &Params| { - let q = b.to_px(p.v2("brushPosition")); - ([q.0, q.1], radius(if own_size { p } else { pr }), if own_opacity { opacity(p) } else { 1.0 }) - }; - let mut segs = vec![]; - let t = ctx.time; - let spacing = pr.f("brushSpacing").max(0.001); - let life = pr.f("strokeLength").max(0.0); - if let Some(host) = ctx.env.host - && t.is_finite() - && t >= 0.0 - { - let start = if life > 0.0 { (t - life).max(0.0) } else { 0.0 }; - let k0 = (start / spacing - 1e-9).ceil().max(0.0) as i64; - let k1 = (t / spacing + 1e-9).floor().max(0.0) as i64; - let n = k1.saturating_sub(k0).saturating_add(1); - let stride = (n / WRITE_ON_MAX_DABS).saturating_add(1).max(1); - let mut prev: Option<[f64; 2]> = None; - let mut k = k0; - while k <= k1 { - let tk = k as f64 * spacing; - let at = if (tk - t).abs() < 1e-9 { Some(dab(pr)) } else { host.params_at(tk).map(|p| dab(&p)) }; - if let Some((q, r, v)) = at { - // Dabs laid further apart than the brush spacing are joined into a line. - let a = if stride > 1 { prev.unwrap_or(q) } else { q }; - segs.push(Seg { a, b: q, r, v }); - prev = Some(q); - } - k = k.saturating_add(stride); - } - } - if segs.is_empty() { - let (q, r, v) = dab(pr); - segs.push(Seg { a: q, b: q, r, v }); - } - PaintPlan { - cov: Coverage::Segs { segs, hardness: pr.f("brushHardness") / 100.0 }, - color: pr.color("color"), - opacity: if own_opacity { 1.0 } else { opacity(pr) }, - style: pr.e("paintStyle"), - } -} - -fn glue_gun(ctx: &EffectCtx, mut b: Buf) -> Buf { - let pr = ctx.params; - let q = b.to_px(pr.v2("brushPosition")); - let rad = (pr.f("strokeWidth") * b.scale * 0.5).max(0.5); - let density = pr.f("density").max(0.0); - let refl = pr.f("reflection").to_radians(); - let strength = (pr.f("strength") / 100.0) as f32; - let glue_only = pr.e("style") == 1; - let src = b.img.clone(); - let light = [refl.cos() * 0.6, -refl.sin() * 0.6, 0.53]; - let beads = (density.round() as usize).clamp(1, 32); - let seed = ctx.seed; - let centers: Vec<([f64; 2], f64)> = (0..beads) - .map(|i| { - let a = hash1(i as u32, 1, seed) as f64 * 2.0 * PI; - let d = if i == 0 { 0.0 } else { hash1(i as u32, 2, seed) as f64 * rad * 0.35 }; - ([q.0 + a.cos() * d, q.1 + a.sin() * d], rad * (0.75 + 0.25 * hash1(i as u32, 3, seed) as f64)) - }) - .collect(); - b.img.rows_mut().for_each(|(y, row)| { - for (x, px) in row.iter_mut().enumerate() { - let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5); - // Height field: union of hemispheres. - let mut best = (0.0f64, [0.0f64; 2], 1.0f64); - for &(c, r) in ¢ers { - let (dx, dy) = ((fx - c[0]) / r, (fy - c[1]) / r); - let d2 = dx * dx + dy * dy; - if d2 < 1.0 { - let hgt = (1.0 - d2).sqrt(); - if hgt > best.0 { - best = (hgt, [dx, dy], r); - } - } - } - if best.0 <= 0.0 { - if glue_only { - *px = [0.0; 4]; - } - continue; - } - let nrm = [best.1[0], best.1[1], best.0]; - let ndl = (nrm[0] * light[0] + nrm[1] * light[1] + nrm[2] * light[2]).max(0.0); - let spec = ndl.powi(24) as f32; - // Refraction: look through the bead. - let s = src.sample_bilinear_clamped(fx - nrm[0] * best.2 * 0.5 * strength as f64, fy - nrm[1] * best.2 * 0.5 * strength as f64); - let edge = smoothstep(0.0, 0.15, best.0 as f32); - let shade = 0.65 + 0.35 * ndl as f32; - let g = [s[0] * shade + spec, s[1] * shade + spec, s[2] * shade + spec, s[3].max(0.6)]; - let gp = [g[0].min(g[3] + spec), g[1].min(g[3] + spec), g[2].min(g[3] + spec), g[3].min(1.0)]; - let base = if glue_only { [0.0; 4] } else { *px }; - *px = crate::util::lerp4(base, gp, edge * strength.clamp(0.0, 1.0).max(0.3)); - px[3] = px[3].clamp(0.0, 1.0); - } - }); - b -} - -// ---------------------------------------------------------------- Paint Bucket - -/// Paint Bucket's filled region (1 inside) on `b`: the flood fill from the Fill Point over the -/// layer's pixels (shared with the GPU compositor, which reads the layer back for it). -pub fn paint_bucket_region(ctx: &EffectCtx, b: &Buf) -> Plane { - let pr = ctx.params; - let (w, h) = (b.img.width as usize, b.img.height as usize); - let fp = b.to_px(pr.v2("fillPoint")); - let (sx, sy) = (fp.0.floor() as i64, fp.1.floor() as i64); - let sel = pr.e("fillSelector"); - let tol = (pr.f("tolerance") / 255.0) as f32; - let key = |p: Px| -> [f32; 4] { - let (c, a) = unpremul(p); - match sel { - 1 => [c[0], c[1], c[2], 0.0], - 2 => [0.0, 0.0, 0.0, if a <= 1e-3 { 0.0 } else { 1.0 }], - 3 | 4 => [0.0, 0.0, 0.0, a], - _ => p, - } - }; - let mut region = Plane::new(w, h); - if sx >= 0 && sy >= 0 && (sx as usize) < w && (sy as usize) < h { - let seed = key(b.img.data[sy as usize * w + sx as usize]); - let within = |i: usize| { - let k = key(b.img.data[i]); - if sel == 2 { - return k[3] == seed[3]; - } - (0..4).all(|j| (k[j] - seed[j]).abs() <= tol + 1e-6) - }; - let mut seen = vec![false; w * h]; - let mut stack = vec![(sx as usize, sy as usize)]; - while let Some((x, y)) = stack.pop() { - let row = y * w; - if seen[row + x] || !within(row + x) { - continue; - } - let mut l = x; - while l > 0 && !seen[row + l - 1] && within(row + l - 1) { - l -= 1; - } - let mut r = x; - while r + 1 < w && !seen[row + r + 1] && within(row + r + 1) { - r += 1; - } - for i in l..=r { - seen[row + i] = true; - region.data[row + i] = 1.0; - if y > 0 && !seen[row - w + i] { - stack.push((i, y - 1)); - } - if y + 1 < h && !seen[row + w + i] { - stack.push((i, y + 1)); - } - } - } - } - region -} - -fn paint_bucket(ctx: &EffectCtx, mut b: Buf) -> Buf { - let pr = ctx.params; - let (w, h) = (b.img.width as usize, b.img.height as usize); - if w == 0 || h == 0 { - return b; - } - let region = paint_bucket_region(ctx, &b); - if pr.b("viewThreshold") { - b.img.data.par_iter_mut().zip(region.data.par_iter()).for_each(|(px, &v)| *px = [v, v, v, 1.0]); - return b; - } - let sc = b.scale; - let mut cov = match pr.e("stroke") { - 1 => { - let s = pr.f("featherSoftness") * sc * 0.5; - if s > 0.05 { gauss_plane(®ion, s, s) } else { region.clone() } - } - 2 => morph_frac(®ion, pr.f("spreadRadius") * sc, true), - 3 => morph_frac(®ion, pr.f("spreadRadius") * sc, false), - 4 => { - let r = pr.f("strokeWidth") * sc; - let out = morph_frac(®ion, r * 0.5, true); - let inn = morph_frac(®ion, r * 0.5, false); - out.zip_map(&inn, |a, b| (a - b).max(0.0)) - } - _ => gauss_plane(®ion, 0.5, 0.5), - }; - if pr.e("stroke") == 0 { - // Antialias: keep the hard interior, soften only the boundary. - cov = cov.zip_map(®ion, |a, r| if r > 0.0 { a.max(0.5) } else { a }); - } - let color = pr.color("color"); - let op = (pr.f("opacity") / 100.0) as f32; - let mode = pr.e("blendingMode"); - b.img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &c)| { - let k = (c * op * color[3]).clamp(0.0, 1.0); - if k <= 0.0 { - if mode == 9 { - *px = [0.0; 4]; - } - return; - } - let (dc, da) = unpremul(*px); - let fc = [color[0], color[1], color[2]]; - let mixed = match mode { - 1 => [dc[0] + fc[0], dc[1] + fc[1], dc[2] + fc[2]], - 2 => [dc[0] * fc[0], dc[1] * fc[1], dc[2] * fc[2]], - 3 => [1.0 - (1.0 - dc[0]) * (1.0 - fc[0]), 1.0 - (1.0 - dc[1]) * (1.0 - fc[1]), 1.0 - (1.0 - dc[2]) * (1.0 - fc[2])], - 4..=7 => { - let (dh, ds, dl) = rgb_to_hsl(dc[0], dc[1], dc[2]); - let (fh, fs, fl) = rgb_to_hsl(fc[0], fc[1], fc[2]); - let (r, g, bl) = match mode { - 4 => hsl_to_rgb(fh, ds, dl), - 5 => hsl_to_rgb(dh, fs, dl), - 6 => hsl_to_rgb(fh, fs, dl), - _ => hsl_to_rgb(dh, ds, fl), - }; - [r, g, bl] - } - _ => fc, - }; - *px = match mode { - 8 => over(premul(fc, k), *px), - 9 => premul(fc, k), - 0 => over(*px, premul(fc, k)), - _ => { - if da <= 1e-6 { - return; - } - let c3 = crate::util::lerp3(dc, mixed, k); - premul(c3, da) - } - }; - }); - b -} - -// ---------------------------------------------------------------- Eyedropper Fill - -fn eyedropper_fill(ctx: &EffectCtx, mut b: Buf) -> Buf { - let pr = ctx.params; - let fill = eyedropper_color(ctx, &b); - let keep_alpha = pr.b("maintainOriginalAlpha"); - let blend = (pr.f("blendWithOriginal") / 100.0) as f32; - b.img.data.par_iter_mut().for_each(|px| { - let a = if keep_alpha { px[3] } else { fill[3] }; - let f = premul([fill[0], fill[1], fill[2]], a); - *px = crate::util::lerp4(f, *px, blend); - }); - b -} - -/// Eyedropper Fill's sampled colour (straight RGB, alpha) from the layer's pixels (shared with -/// the GPU compositor, which reads the layer back for it). -pub fn eyedropper_color(ctx: &EffectCtx, b: &Buf) -> Px { - let pr = ctx.params; - let (w, h) = (b.img.width as i64, b.img.height as i64); - let c = b.to_px(pr.v2("samplePoint")); - let r = (pr.f("sampleRadius") * b.scale).max(0.0); - let mode = pr.e("averagePixelColors"); - let (cx, cy) = (c.0.floor() as i64, c.1.floor() as i64); - let ri = r.ceil() as i64; - let mut acc = [0.0f64; 4]; - let mut n = 0.0f64; - for y in (cy - ri).max(0)..=(cy + ri).min(h - 1) { - for x in (cx - ri).max(0)..=(cx + ri).min(w - 1) { - if ((x - cx).pow(2) + (y - cy).pow(2)) as f64 > r * r + 0.5 { - continue; - } - let px = b.img.data[(y * w + x) as usize]; - match mode { - 0 => { - if px[3] > 1e-4 { - let (s, _) = unpremul(px); - for k in 0..3 { - acc[k] += s[k] as f64; - } - acc[3] += 1.0; - n += 1.0; - } - } - 1 => { - let (s, _) = unpremul(px); - for k in 0..3 { - acc[k] += s[k] as f64; - } - acc[3] += 1.0; - n += 1.0; - } - 2 => { - for k in 0..3 { - acc[k] += px[k] as f64; - } - acc[3] += 1.0; - n += 1.0; - } - _ => { - for k in 0..4 { - acc[k] += px[k] as f64; - } - n += 1.0; - } - } - } - } - if n > 0.0 { - if mode == 3 { - let a = (acc[3] / n) as f32; - let s = if a > 1e-6 { [(acc[0] / n) as f32 / a, (acc[1] / n) as f32 / a, (acc[2] / n) as f32 / a] } else { [0.0; 3] }; - [s[0], s[1], s[2], a] - } else { - [(acc[0] / n) as f32, (acc[1] / n) as f32, (acc[2] / n) as f32, 1.0] - } - } else { - [0.0, 0.0, 0.0, 0.0] - } -} - -// ---------------------------------------------------------------- CC Threads - -fn threads(ctx: &EffectCtx, mut b: Buf) -> Buf { - let pr = ctx.params; - let sc = b.scale; - let tw = (pr.f("width") * sc).max(1.0); - let th = (pr.f("height") * sc).max(1.0); - let overlaps = pr.f("overlaps").round().clamp(1.0, 20.0) as i64; - let ang = pr.f("direction").to_radians(); - let (sn, cs) = ang.sin_cos(); - let c = b.to_px(pr.v2("center")); - let coverage = (pr.f("coverage") / 100.0).clamp(0.0, 1.0); - let shadow = (pr.f("shadowing") / 100.0) as f32; - let texture = (pr.f("texture") / 100.0) as f32; - let src = b.img.clone(); - let to_px = |u: f64, v: f64| (c.0 + u * cs - v * sn, c.1 + u * sn + v * cs); - b.img.rows_mut().for_each(|(y, row)| { - for (x, px) in row.iter_mut().enumerate() { - let (dx, dy) = (x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1); - let u = dx * cs + dy * sn; - let v = -dx * sn + dy * cs; - let (ci, cj) = ((u / tw).floor() as i64, (v / th).floor() as i64); - let (fu, fv) = (u / tw - ci as f64, v / th - cj as f64); - // Horizontal thread j occupies the band |fv-0.5| < coverage/2; vertical thread i the - // band |fu-0.5| < coverage/2. - let in_h = (fv - 0.5).abs() < coverage * 0.5; - let in_v = (fu - 0.5).abs() < coverage * 0.5; - // Twill: horizontal is on top when (i + j) mod (2·overlaps) < overlaps. - let h_top = (ci + cj).rem_euclid(2 * overlaps) < overlaps; - let which = match (in_h, in_v) { - (true, true) => Some(h_top), - (true, false) => Some(true), - (false, true) => Some(false), - _ => None, - }; - let Some(horizontal) = which else { - *px = [0.0; 4]; - continue; - }; - // Thread colour: the source sampled along the thread centre line. - let (sx, sy) = if horizontal { to_px(u, (cj as f64 + 0.5) * th) } else { to_px((ci as f64 + 0.5) * tw, v) }; - let s = src.sample_bilinear_clamped(sx, sy); - // Round profile across the thread + darkening where it dives under its neighbour. - let across = if horizontal { (fv - 0.5) / (coverage * 0.5).max(1e-6) } else { (fu - 0.5) / (coverage * 0.5).max(1e-6) }; - let along = if horizontal { fu } else { fv }; - let profile = (1.0 - across * across).max(0.0).sqrt() as f32; - let dive = 1.0 - (along - 0.5).abs() as f32 * 2.0; - let shade = 1.0 - shadow * (1.0 - profile) * 0.8 - shadow * 0.25 * (1.0 - dive) * if horizontal == h_top { 0.0 } else { 1.0 }; - let fib = - if texture > 0.0 { 1.0 - texture * 0.3 * hash1((along * 64.0) as u32, (across * 8.0 + 8.0) as u32, ci as u32 ^ (cj as u32) << 8) } else { 1.0 }; - let k = (shade * fib).max(0.0); - *px = [s[0] * k, s[1] * k, s[2] * k, s[3]]; - } - }); - b -} - -// ---------------------------------------------------------------- Audio Spectrum / Waveform - -/// In-place iterative radix-2 FFT (`re`, `im` lengths a power of two). -pub(crate) fn fft(re: &mut [f64], im: &mut [f64]) { - let n = re.len(); - if n < 2 { - return; - } - let mut j = 0; - for i in 1..n { - let mut bit = n >> 1; - while j & bit != 0 { - j ^= bit; - bit >>= 1; - } - j |= bit; - if i < j { - re.swap(i, j); - im.swap(i, j); - } - } - let mut len = 2; - while len <= n { - let ang = -2.0 * PI / len as f64; - let (ws, wc) = ang.sin_cos(); - for start in (0..n).step_by(len) { - let (mut cr, mut ci) = (1.0, 0.0); - for k in 0..len / 2 { - let (a, bb) = (start + k, start + k + len / 2); - let tr = re[bb] * cr - im[bb] * ci; - let ti = re[bb] * ci + im[bb] * cr; - re[bb] = re[a] - tr; - im[bb] = im[a] - ti; - re[a] += tr; - im[a] += ti; - let ncr = cr * wc - ci * ws; - ci = cr * ws + ci * wc; - cr = ncr; - } - } - len <<= 1; - } -} - -const AUDIO_RATE: u32 = 48_000; - -/// Fetch mono (or one channel) samples of the audio layer around the current time. -fn audio_window(ctx: &EffectCtx, dur_ms: f64, offset_ms: f64, channel: u32) -> Option> { - let id = ctx.params.get("audioLayer")?.as_layer()?; - let host = ctx.env.host?; - let dur = (dur_ms / 1000.0).max(0.001); - let frames = ((dur * AUDIO_RATE as f64) as usize).clamp(16, 1 << 16); - let start = ctx.env.comp_time + offset_ms / 1000.0 - dur * 0.5; - let s = host.audio(id, start, frames, AUDIO_RATE)?; - Some( - (0..frames) - .map(|i| { - let (l, r) = (*s.get(2 * i).unwrap_or(&0.0) as f64, *s.get(2 * i + 1).unwrap_or(&0.0) as f64); - match channel { - 1 => l, - 2 => r, - _ => (l + r) * 0.5, - } - }) - .collect(), - ) -} - -/// The drawing baseline: a polyline in buffer px (mask path or start→end line), sampled at -/// parameter u∈[0,1] → (point, unit normal). -struct Baseline { - pts: Vec<[f64; 2]>, - closed: bool, - len: f64, - polar: bool, - center: [f64; 2], - radius: f64, -} - -impl Baseline { - fn new(ctx: &EffectCtx, b: &Buf) -> Baseline { - let pr = ctx.params; - let polar = pr.b("usePolarPath"); - let (pts, closed) = match mask_px(ctx, b, pr.f("path").round() as usize) { - Some((p, c, _)) if !polar => (p, c), - _ => { - let s = b.to_px(pr.v2("startPoint")); - let e = b.to_px(pr.v2("endPoint")); - (vec![[s.0, s.1], [e.0, e.1]], false) - } - }; - let len = poly_length(&pts, closed); - let center = pts[0]; - let radius = if pts.len() > 1 { ((pts[1][0] - pts[0][0]).powi(2) + (pts[1][1] - pts[0][1]).powi(2)).sqrt() } else { 1.0 }; - Baseline { pts, closed, len, polar, center, radius } - } - fn at(&self, u: f64) -> ([f64; 2], [f64; 2]) { - if self.polar { - let a = u * 2.0 * PI - PI * 0.5; - let (s, c) = a.sin_cos(); - return ([self.center[0] + c * self.radius * 0.5, self.center[1] + s * self.radius * 0.5], [c, s]); - } - let (q, d) = crate::util::poly_point_at(&self.pts, self.closed, u * self.len); - (q, [d[1], -d[0]]) - } -} - -/// Draw line/dot marks with core (inside colour) and soft halo (outside colour). -fn draw_marks(b: &mut Buf, segs_core: &[Seg], thickness: f64, softness: f64, inside: [f32; 4], outside: [f32; 4], composite: bool) { - let (w, h) = (b.img.width as usize, b.img.height as usize); - let core = raster_segs(w, h, segs_core, 1.0); - let halo_segs: Vec = segs_core.iter().map(|s| Seg { r: s.r + thickness * softness.max(0.0) + 0.5, ..*s }).collect(); - let halo = if softness > 0.0 { raster_segs(w, h, &halo_segs, 0.0) } else { core.clone() }; - b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { - let (c, hl) = (core.data[i], halo.data[i]); - let a = c.max(hl); - let base = if composite { *px } else { [0.0; 4] }; - if a <= 0.0 { - *px = base; - return; - } - let t = if a > 0.0 { c / a } else { 0.0 }; - let col = crate::util::lerp3([outside[0], outside[1], outside[2]], [inside[0], inside[1], inside[2]], t); - *px = over(base, premul(col, a)); - }); -} - -/// Audio Spectrum / Audio Waveform, shared with the GPU compositor (effectcraft-gpu `fx_gen2`): -/// the marks (the audio analysis runs on the CPU) and how [`draw_marks`] colours them. -#[derive(Clone, Debug)] -pub struct MarksPlan { - pub segs: Vec, - pub thickness: f64, - pub softness: f64, - pub inside: [f32; 4], - pub outside: [f32; 4], - pub composite: bool, -} - -/// [`MarksPlan`] of Audio Spectrum or Audio Waveform on `b`'s geometry (`None` = no audio: -/// the layer passes through). -pub fn marks_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option { - match id { - "ec.generate.audiospectrum" => spectrum_plan(ctx, b), - "ec.generate.audiowaveform" => waveform_plan(ctx, b), - _ => None, - } -} - -fn marks(ctx: &EffectCtx, segs: Vec, thick: f64, soft: f64) -> MarksPlan { - let pr = ctx.params; - MarksPlan { - segs, - thickness: thick, - softness: soft * 2.0, - inside: pr.color("insideColor"), - outside: pr.color("outsideColor"), - composite: pr.b("compositeOnOriginal"), - } -} - -fn audio_spectrum(ctx: &EffectCtx, mut b: Buf) -> Buf { - if let Some(m) = spectrum_plan(ctx, &b) { - draw_marks(&mut b, &m.segs, m.thickness, m.softness, m.inside, m.outside, m.composite); - } - b -} - -fn audio_waveform(ctx: &EffectCtx, mut b: Buf) -> Buf { - if let Some(m) = waveform_plan(ctx, &b) { - draw_marks(&mut b, &m.segs, m.thickness, m.softness, m.inside, m.outside, m.composite); - } - b -} - -fn compute_fft_mags(samples: &[f64]) -> (usize, f64, Vec) { - let m = samples.len(); - let n = m.next_power_of_two().max(64); - let mut re = vec![0.0; n]; - let mut im = vec![0.0; n]; - let denom = (m.max(2) - 1) as f64; - for (i, s) in samples.iter().enumerate() { - let w = 0.5 - 0.5 * (2.0 * PI * i as f64 / denom).cos(); - if let Some(r) = re.get_mut(i) { - *r = s * w; - } - } - fft(&mut re, &mut im); - let hz_per_bin = AUDIO_RATE as f64 / n as f64; - let scale = 4.0 / m.max(1) as f64; - let num_bins = n / 2 + 1; - let mut mags = Vec::with_capacity(num_bins); - for k in 0..num_bins { - let r = re.get(k).copied().unwrap_or(0.0); - let im_val = im.get(k).copied().unwrap_or(0.0); - let mag = (r * r + im_val * im_val).sqrt() * scale; - mags.push(mag); - } - (n, hz_per_bin, mags) -} - -fn spectrum_plan(ctx: &EffectCtx, b: &Buf) -> Option { - let pr = ctx.params; - let samples = audio_window(ctx, pr.f("audioDuration"), pr.f("audioOffset"), 0)?; - let (_n, hz_per_bin, mags) = compute_fft_mags(&samples); - - let bands = pr.f("frequencyBands").round().clamp(1.0, 4096.0) as usize; - let f0 = pr.f("startFrequency").max(0.0); - let f1 = pr.f("endFrequency").max(f0 + 1.0); - let max_bin = mags.len().saturating_sub(1); - - let get_band_mag = |fc: f64| -> f64 { - if mags.is_empty() || hz_per_bin <= 0.0 { - return 0.0; - } - let k_float = (fc / hz_per_bin).clamp(0.0, max_bin as f64); - let k0 = (k_float.floor() as usize).min(max_bin.saturating_sub(1)); - let k1 = (k0 + 1).min(max_bin); - let frac = (k_float - k0 as f64).clamp(0.0, 1.0); - let m0 = mags.get(k0).copied().unwrap_or(0.0); - let m1 = mags.get(k1).copied().unwrap_or(0.0); - m0 * (1.0 - frac) + m1 * frac - }; - - let max_h = pr.f("maximumHeight") * b.scale; - let thick = (pr.f("thickness") * b.scale).max(0.1); - let soft = pr.f("softness") / 100.0; - let disp = pr.e("displayOptions"); - let side = pr.e("sideOptions"); - let base = Baseline::new(ctx, b); - let mut tops = Vec::with_capacity(bands); - let mut segs = Vec::new(); - for i in 0..bands { - let u = if bands > 1 { i as f64 / (bands - 1) as f64 } else { 0.5 }; - let fc = f0 + (f1 - f0) * u; - let v = get_band_mag(fc); - let hgt = if (v * max_h).is_finite() { (v * max_h).clamp(0.0, max_h.max(0.0)) } else { 0.0 }; - let (q, nrm) = base.at(u); - let (ua, ub) = match side { - 0 => (0.0, hgt), - 1 => (-hgt, 0.0), - _ => (-hgt, hgt), - }; - let pa = [q[0] + nrm[0] * ua, q[1] + nrm[1] * ua]; - let pb = [q[0] + nrm[0] * ub, q[1] + nrm[1] * ub]; - match disp { - 0 => segs.push(Seg { a: pa, b: pb, r: thick * 0.5, v: 1.0 }), - _ => tops.push((pa, pb)), - } - } - if disp == 1 { - for w2 in tops.windows(2) { - segs.push(Seg { a: w2[0].1, b: w2[1].1, r: thick * 0.5, v: 1.0 }); - if side == 2 { - segs.push(Seg { a: w2[0].0, b: w2[1].0, r: thick * 0.5, v: 1.0 }); - } - } - } else if disp == 2 { - for &(pa, pb) in &tops { - segs.push(Seg { a: pb, b: pb, r: thick * 0.5, v: 1.0 }); - if side == 2 { - segs.push(Seg { a: pa, b: pa, r: thick * 0.5, v: 1.0 }); - } - } - } - Some(marks(ctx, segs, thick, soft)) -} - -fn waveform_plan(ctx: &EffectCtx, b: &Buf) -> Option { - let pr = ctx.params; - let samples = audio_window(ctx, pr.f("audioDuration"), pr.f("audioOffset"), pr.e("waveformOptions"))?; - let count = pr.f("displayedSamples").round().clamp(2.0, 4096.0) as usize; - let max_h = pr.f("maximumHeight") * b.scale; - let thick = (pr.f("thickness") * b.scale).max(0.1); - let soft = pr.f("softness") / 100.0; - let disp = pr.e("displayOptions"); - let base = Baseline::new(ctx, b); - let m = samples.len(); - let mut pts = Vec::with_capacity(count); - let mut segs = Vec::new(); - for i in 0..count { - let u = i as f64 / (count - 1) as f64; - let a = ((u * (m - 1) as f64) as usize).min(m - 1); - let bnd = (((i + 1) as f64 / count as f64 * (m - 1) as f64) as usize).clamp(a, m - 1); - // Peak with sign of the window for a stable digital display. - let (mut lo, mut hi) = (0.0f64, 0.0f64); - for s in &samples[a..=bnd] { - lo = lo.min(*s); - hi = hi.max(*s); - } - let (q, nrm) = base.at(u); - match disp { - 0 => segs.push(Seg { - a: [q[0] + nrm[0] * lo * max_h, q[1] + nrm[1] * lo * max_h], - b: [q[0] + nrm[0] * hi * max_h, q[1] + nrm[1] * hi * max_h], - r: thick * 0.5, - v: 1.0, - }), - _ => { - let v = samples[a] * max_h; - pts.push([q[0] + nrm[0] * v, q[1] + nrm[1] * v]); - } - } - } - if disp == 1 { - poly_segs(&pts, false, thick * 0.5, 1.0, &mut segs); - } else if disp == 2 { - segs.extend(pts.iter().map(|&q| Seg { a: q, b: q, r: thick * 0.5, v: 1.0 })); - } - Some(marks(ctx, segs, thick, soft)) -} - -// ---------------------------------------------------------------- registry - -pub fn specs() -> Vec { - let ang = || ParamUi::Angle; - let check = |id: &'static str, name: &'static str, v: bool| p(id, name, Value::Bool(v), ParamUi::Checkbox); - let paint_style = || popup(&["On Original Image", "On Transparent", "Reveal Original Image"]); - let mask_idx = |id: &'static str, name: &'static str, d: f64| p(id, name, num(d), ParamUi::Mask); - let display = || popup(&["Digital", "Analog lines", "Analog dots"]); - vec![ - spec( - "ec.generate.fractal", - "Fractal", - vec![ - p("setChoice", "Set Choice", Value::Enum(0), popup(&FRACTAL_SETS)), - p("equation", "Equation", Value::Enum(0), popup(&["z = z^2 + c", "z = z^3 + c", "z = z^4 + c"])), - p("mandelbrot/mandelbrotX", "X (Real)", num(-0.74), slider(-2.0, 2.0, -2.0, 2.0, 4)), - p("mandelbrot/mandelbrotY", "Y (Imaginary)", num(0.15), slider(-2.0, 2.0, -2.0, 2.0, 4)), - p("mandelbrot/mandelbrotMagnification", "Magnification", num(0.0), slider(-5.0, 30.0, -5.0, 30.0, 2)), - p("mandelbrot/mandelbrotEscapeLimit", "Escape Limit", num(4.0), slider(1.0, 1000.0, 1.0, 100.0, 1)), - p("julia/juliaX", "X (Real)", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)), - p("julia/juliaY", "Y (Imaginary)", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)), - p("julia/juliaMagnification", "Magnification", num(0.0), slider(-5.0, 30.0, -5.0, 30.0, 2)), - p("julia/juliaEscapeLimit", "Escape Limit", num(4.0), slider(1.0, 1000.0, 1.0, 100.0, 1)), - p("postInversionOffset/postInversionX", "X Offset", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)), - p("postInversionOffset/postInversionY", "Y Offset", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)), - check("fractalColor/overlay", "Overlay", false), - check("fractalColor/transparency", "Transparency", false), - p("fractalColor/palette", "Palette", Value::Enum(0), popup(&FRACTAL_PALETTES)), - p("fractalColor/hue", "Hue", num(0.0), ang()), - p("fractalColor/cycleSteps", "Cycle Steps", num(32.0), slider(1.0, 1000.0, 1.0, 256.0, 0)), - p("fractalColor/cycleOffset", "Cycle Offset", num(0.0), ang()), - check("fractalColor/edgeHighlight", "Edge Highlight", false), - p("highQualitySettings/samplingMethod", "Oversample Method", Value::Enum(0), popup(&FRACTAL_OVERSAMPLE)), - p("highQualitySettings/samplingFactor", "Oversample Factor", num(2.0), slider(1.0, 9.0, 1.0, 9.0, 0)), - ], - fractal, - ), - spec( - "ec.generate.scribble", - "Scribble", - vec![ - p("scribble", "Scribble", Value::Enum(0), popup(&["Single Mask", "All Masks", "All Masks Using Modes"])), - mask_idx("mask", "Mask", 1.0), - p("fillType", "Fill Type", Value::Enum(0), popup(&["Inside", "Centered Edge", "Inside Edge", "Outside Edge", "Left Edge", "Right Edge"])), - p("edgeOptions/edgeWidth", "Edge Width", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), - p("edgeOptions/endCap", "End Cap", Value::Enum(0), popup(&END_CAPS)), - p("edgeOptions/join", "Join", Value::Enum(0), popup(&JOINS)), - p("edgeOptions/miterLimit", "Miter Limit", num(4.0), slider(1.0, 100.0, 1.0, 20.0, 1)), - p("color", "Color", col(1.0, 0.0, 0.0), ParamUi::Color), - p("opacity", "Opacity", num(100.0), pct()), - p("angle", "Angle", num(45.0), ang()), - p("strokeWidth", "Stroke Width", num(2.0), slider(0.1, 50.0, 0.1, 50.0, 1)), - p("strokeOptions/curviness", "Curviness", num(50.0), pct()), - p("strokeOptions/curvinessVariation", "Curviness Variation", num(50.0), pct()), - p("strokeOptions/spacing", "Spacing", num(5.0), slider(0.1, 100.0, 0.1, 50.0, 1)), - p("strokeOptions/spacingVariation", "Spacing Variation", num(50.0), pct()), - p("strokeOptions/pathOverlap", "Path Overlap", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), - p("strokeOptions/pathOverlapVariation", "Path Overlap Variation", num(50.0), pct()), - p("start", "Start", num(0.0), pct()), - p("end", "End", num(100.0), pct()), - p("startEndApplyTo", "Start/End Apply To", Value::Enum(0), popup(&["All Strokes", "Each Stroke"])), - check("fillPathsSequentially", "Fill Paths Sequentially", true), - p("wiggleType", "Wiggle Type", Value::Enum(1), popup(&["Static", "Jumpy", "Smooth"])), - p("wigglesPerSecond", "Wiggles/Second", num(5.0), slider(0.0, 100.0, 0.0, 30.0, 2)), - p("randomSeed", "Random Seed", num(1.0), slider(0.0, 10000.0, 0.0, 100.0, 0)), - p("composite", "Composite", Value::Enum(1), popup(&["On Original Image", "On Transparent", "Reveal Original Image"])), - ], - scribble, - ), - spec( - "ec.generate.stroke", - "Stroke", - vec![ - mask_idx("path", "Path", 1.0), - check("allMasks", "All Masks", false), - check("strokeSequentially", "Stroke Sequentially", true), - p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color), - p("brushSize", "Brush Size", num(2.0), slider(0.0, 50.0, 0.0, 50.0, 1)), - p("brushHardness", "Brush Hardness", num(75.0), pct()), - p("opacity", "Opacity", num(100.0), pct()), - p("start", "Start", num(0.0), pct()), - p("end", "End", num(100.0), pct()), - p("spacing", "Spacing", num(15.0), slider(0.0, 100.0, 0.0, 100.0, 1)), - p("paintStyle", "Paint Style", Value::Enum(0), paint_style()), - ], - stroke, - ), - spec( - "ec.generate.vegas", - "Vegas", - vec![ - p("stroke", "Stroke", Value::Enum(0), popup(&["Image Contours", "Mask/Path"])), - p("inputLayer", "Image Contours: Input Layer", Value::Layer(None), ParamUi::Layer), - check("invertInput", "Image Contours: Invert Input", false), - p("ifLayerSizesDiffer", "Image Contours: If Layer Sizes Differ", Value::Enum(0), popup(&["Center", "Stretch to Fit"])), - p("channel", "Image Contours: Channel", Value::Enum(0), popup(&["Intensity", "Red", "Green", "Blue", "Alpha"])), - p("threshold", "Image Contours: Threshold", num(50.0), pct()), - p("preBlur", "Image Contours: Pre-Blur", num(1.0), slider(0.0, 100.0, 0.0, 20.0, 1)), - p("tolerance", "Image Contours: Tolerance", num(0.01), slider(0.0, 1.0, 0.0, 0.1, 3)), - p("renderMode", "Image Contours: Render", Value::Enum(0), popup(&["All Contours", "Selected Contour"])), - p("selectedContour", "Image Contours: Selected Contour", num(1.0), slider(1.0, 1000.0, 1.0, 20.0, 0)), - p("shorterContoursHave", "Image Contours: Shorter Contours Have", Value::Enum(0), popup(&["Same Number of Segments", "Fewer Segments"])), - mask_idx("path", "Mask/Path: Path", 1.0), - p("segments", "Segments: Segments", num(32.0), slider(1.0, 1000.0, 1.0, 100.0, 0)), - p("length", "Segments: Length", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("segmentDistribution", "Segments: Segment Distribution", Value::Enum(0), popup(&["Bunched", "Even"])), - p("rotation", "Segments: Rotation", num(0.0), ang()), - check("randomPhase", "Segments: Random Phase", false), - p("randomSeed", "Segments: Random Seed", num(1.0), slider(1.0, 10000.0, 1.0, 100.0, 0)), - p("blendMode", "Rendering: Blend Mode", Value::Enum(1), popup(&["Transparent", "Over", "Under", "Stencil"])), - p("color", "Rendering: Color", col(1.0, 1.0, 1.0), ParamUi::Color), - p("width", "Rendering: Width", num(2.0), slider(0.0, 100.0, 0.0, 20.0, 2)), - p("hardness", "Rendering: Hardness", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("startOpacity", "Rendering: Start Opacity", num(1.0), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("midpointOpacity", "Rendering: Mid-point Opacity", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("midpointPosition", "Rendering: Mid-point Position", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("endOpacity", "Rendering: End Opacity", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), - ], - vegas, - ), - spec( - "ec.generate.writeon", - "Write-on", - vec![ - p("brushPosition", "Brush Position", pt(0.5, 0.5), ParamUi::Point), - p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color), - p("brushSize", "Brush Size", num(6.0), slider(0.0, 50.0, 0.0, 50.0, 1)), - p("brushHardness", "Brush Hardness", num(75.0), pct()), - p("brushOpacity", "Brush Opacity", num(100.0), pct()), - p("strokeLength", "Stroke Length (secs)", num(0.0), slider(0.0, 30.0, 0.0, 30.0, 1)), - p("brushSpacing", "Brush Spacing (secs)", num(0.001), slider(0.001, 5.0, 0.001, 5.0, 3)), - p("paintTimeProperties", "Paint Time Properties", Value::Enum(0), popup(&["None", "Opacity", "Color"])), - p("brushTimeProperties", "Brush Time Properties", Value::Enum(0), popup(&["None", "Size", "Hardness", "Size & Hardness"])), - p("paintStyle", "Paint Style", Value::Enum(0), paint_style()), - ], - write_on, - ), - spec( - "ec.generate.paintbucket", - "Paint Bucket", - vec![ - p("fillPoint", "Fill Point", pt(0.5, 0.5), ParamUi::Point), - p("fillSelector", "Fill Selector", Value::Enum(0), popup(&["Color & Alpha", "Straight Color", "Transparency", "Opacity", "Alpha Channel"])), - p("tolerance", "Tolerance", num(10.0), slider(0.0, 255.0, 0.0, 100.0, 1)), - check("viewThreshold", "View Threshold", false), - p("stroke", "Stroke", Value::Enum(0), popup(&["Antialias", "Feather", "Spread", "Choke", "Stroke"])), - p("featherSoftness", "Feather Softness", num(10.0), slider(0.0, 100.0, 0.0, 50.0, 1)), - p("spreadRadius", "Spread Radius", num(3.0), slider(0.0, 100.0, 0.0, 20.0, 1)), - p("strokeWidth", "Stroke Width", num(2.0), slider(0.0, 100.0, 0.0, 20.0, 1)), - p("color", "Color", col(1.0, 0.0, 0.0), ParamUi::Color), - p("opacity", "Opacity", num(100.0), pct()), - p( - "blendingMode", - "Blending Mode", - Value::Enum(0), - popup(&["Normal", "Add", "Multiply", "Screen", "Hue", "Saturation", "Color", "Luminosity", "Behind", "Fill Only"]), - ), - ], - paint_bucket, - ), - spec( - "ec.generate.eyedropperfill", - "Eyedropper Fill", - vec![ - p("samplePoint", "Sample Point", pt(0.5, 0.5), ParamUi::Point), - p("sampleRadius", "Sample Radius", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), - p("averagePixelColors", "Average Pixel Colors", Value::Enum(0), popup(&["Skip Empty", "All", "All Premultiplied", "Including Alpha"])), - check("maintainOriginalAlpha", "Maintain Original Alpha", false), - p("blendWithOriginal", "Blend With Original", num(0.0), pct()), - ], - eyedropper_fill, - ), - spec( - "ec.generate.ccgluegun", - "CC Glue Gun", - vec![ - p("brushPosition", "Brush Position", pt(0.5, 0.5), ParamUi::Point), - p("strokeWidth", "Stroke Width", num(25.0), slider(0.0, 500.0, 0.0, 100.0, 1)), - p("density", "Density", num(5.0), slider(0.0, 32.0, 0.0, 32.0, 1)), - p("timeSpan", "Time Span (sec)", num(1.0), slider(0.0, 60.0, 0.0, 10.0, 2)), - p("reflection", "Reflection", num(45.0), ang()), - p("strength", "Strength", num(100.0), pct()), - p("style", "Style", Value::Enum(0), popup(&["Over Layer", "Glue Only"])), - ], - glue_gun, - ), - spec( - "ec.generate.ccthreads", - "CC Threads", - vec![ - p("width", "Width", num(50.0), slider(1.0, 1000.0, 1.0, 200.0, 1)), - p("height", "Height", num(50.0), slider(1.0, 1000.0, 1.0, 200.0, 1)), - p("overlaps", "Overlaps", num(1.0), slider(1.0, 20.0, 1.0, 10.0, 0)), - p("direction", "Direction", num(0.0), ang()), - p("center", "Center", pt(0.5, 0.5), ParamUi::Point), - p("coverage", "Coverage", num(80.0), pct()), - p("shadowing", "Shadowing", num(50.0), pct()), - p("texture", "Texture", num(10.0), pct()), - ], - threads, - ), - spec( - "ec.generate.audiospectrum", - "Audio Spectrum", - vec![ - p("audioLayer", "Audio Layer", Value::Layer(None), ParamUi::Layer), - p("startPoint", "Start Point", pt(0.1, 0.5), ParamUi::Point), - p("endPoint", "End Point", pt(0.9, 0.5), ParamUi::Point), - mask_idx("path", "Path", 0.0), - check("usePolarPath", "Use Polar Path", false), - p("startFrequency", "Start Frequency", num(20.0), slider(1.0, 20000.0, 1.0, 2000.0, 0)), - p("endFrequency", "End Frequency", num(2000.0), slider(1.0, 20000.0, 1.0, 10000.0, 0)), - p("frequencyBands", "Frequency bands", num(64.0), slider(1.0, 4096.0, 1.0, 256.0, 0)), - p("maximumHeight", "Maximum Height", num(50.0), slider(0.0, 10000.0, 0.0, 500.0, 1)), - p("audioDuration", "Audio Duration (milliseconds)", num(90.0), slider(1.0, 30000.0, 1.0, 1000.0, 1)), - p("audioOffset", "Audio Offset (milliseconds)", num(0.0), slider(-30000.0, 30000.0, -1000.0, 1000.0, 1)), - p("thickness", "Thickness", num(3.0), slider(0.0, 1000.0, 0.0, 50.0, 2)), - p("softness", "Softness", num(50.0), pct()), - p("insideColor", "Inside Color", col(1.0, 1.0, 1.0), ParamUi::Color), - p("outsideColor", "Outside Color", col(0.2, 0.4, 1.0), ParamUi::Color), - check("blendOverlappingColors", "Blend Overlapping Colors", true), - p("hueInterpolation", "Hue Interpolation", num(0.0), ang()), - check("dynamicHuePhase", "Dynamic Hue Phase", false), - check("colorSymmetry", "Color Symmetry", false), - p("displayOptions", "Display Options", Value::Enum(0), display()), - p("sideOptions", "Side Options", Value::Enum(0), popup(&["Side A", "Side B", "Side A & B"])), - check("durationAveraging", "Duration Averaging", false), - check("compositeOnOriginal", "Composite On Original", false), - ], - audio_spectrum, - ), - spec( - "ec.generate.audiowaveform", - "Audio Waveform", - vec![ - p("audioLayer", "Audio Layer", Value::Layer(None), ParamUi::Layer), - p("startPoint", "Start Point", pt(0.1, 0.5), ParamUi::Point), - p("endPoint", "End Point", pt(0.9, 0.5), ParamUi::Point), - mask_idx("path", "Path", 0.0), - check("usePolarPath", "Use Polar Path", false), - p("displayedSamples", "Displayed Samples", num(200.0), slider(2.0, 4096.0, 2.0, 1000.0, 0)), - p("maximumHeight", "Maximum Height", num(100.0), slider(0.0, 10000.0, 0.0, 500.0, 1)), - p("audioDuration", "Audio Duration (milliseconds)", num(200.0), slider(1.0, 30000.0, 1.0, 1000.0, 1)), - p("audioOffset", "Audio Offset (milliseconds)", num(0.0), slider(-30000.0, 30000.0, -1000.0, 1000.0, 1)), - p("thickness", "Thickness", num(2.0), slider(0.0, 1000.0, 0.0, 50.0, 2)), - p("softness", "Softness", num(10.0), pct()), - p("randomSeed", "Random Seed (Analog)", num(1.0), slider(1.0, 10000.0, 1.0, 100.0, 0)), - p("insideColor", "Inside Color", col(1.0, 1.0, 1.0), ParamUi::Color), - p("outsideColor", "Outside Color", col(0.2, 0.4, 1.0), ParamUi::Color), - p("waveformOptions", "Waveform Options", Value::Enum(0), popup(&["Mono", "Left", "Right"])), - p("displayOptions", "Display Options", Value::Enum(1), display()), - check("compositeOnOriginal", "Composite On Original", false), - ], - audio_waveform, - ), - ] -} +mod audio; +mod common; +mod fill; +mod fractal; +mod paths; +mod raster; +mod registry; #[cfg(test)] -mod tests { - use super::*; - use crate::{EffectEnv, EffectHost, LayerPixels, MaskShape, Params}; +pub(crate) use audio::fft; +pub use audio::{MarksPlan, marks_plan}; +pub use fill::{eyedropper_color, paint_bucket_region}; +#[cfg(test)] +use paths::contours; +pub use paths::{Coverage, PaintPlan, paint_plan, plan_reads_pixels, scribble_style}; +pub use raster::{Seg, poly_segs}; +pub(crate) use raster::{mask_px, raster_segs}; +#[cfg(test)] +pub(crate) use raster::{raster_convex, stroke_outline}; +pub use registry::specs; - /// Run with point defaults scaled to the layer size. - pub(crate) fn run_env(id: &str, set: &[(&str, Value)], img: Image, time: f64, env: EffectEnv) -> Buf { - let s = crate::find(id).unwrap(); - let ls = [img.width as f64, img.height as f64]; - let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; - for p in &s.params { - if let (ParamUi::Point, Value::Vec2(v)) = (&p.ui, &p.default) { - params.values.insert(p.id.to_string(), Value::Vec2([v[0] * ls[0], v[1] * ls[1]])); - } - } - for (k, v) in set { - assert!(params.values.contains_key(*k), "{id}: unknown {k}"); - params.values.insert(k.to_string(), v.clone()); - } - let ctx = EffectCtx { params: ¶ms, time, layer_size: ls, seed: 3, adjustment: false, env }; - crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }) - } - - fn run(id: &str, set: &[(&str, Value)], img: Image) -> Buf { - run_env(id, set, img, 0.5, EffectEnv::default()) - } - - fn square_mask() -> Vec { - vec![MaskShape { name: "Mask 1".into(), points: vec![[16.0, 16.0], [48.0, 16.0], [48.0, 48.0], [16.0, 48.0]], closed: true, inverted: false }] - } - - fn alpha_sum(img: &Image, x0: usize, y0: usize, x1: usize, y1: usize) -> f32 { - let mut s = 0.0; - for y in y0..y1 { - for x in x0..x1 { - s += img.data[y * img.width as usize + x][3]; - } - } - s - } - - #[test] - fn audio_spectrum_linear_frequency_and_linear_amplitude() { - let host = Tone; // Tone produces 440 Hz - let env = EffectEnv { host: Some(&host), comp_time: 1.0, ..Default::default() }; - let img = Image::new(200, 100); - let out = run_env( - "ec.generate.audiospectrum", - &[ - ("audioLayer", Value::Layer(Some(1))), - ("startPoint", Value::Vec2([0.0, 50.0])), - ("endPoint", Value::Vec2([200.0, 50.0])), - ("startFrequency", num(20.0)), - ("endFrequency", num(2000.0)), - ("frequencyBands", num(100.0)), - ("maximumHeight", num(40.0)), - ("thickness", num(2.0)), - ("softness", num(0.0)), - ], - img, - 1.0, - env, - ); - // Verify 440 Hz tone produces a peak above baseline, while high frequencies have zero bar height - let peak_zone = alpha_sum(&out.img, 36, 10, 48, 48); - let high_zone = alpha_sum(&out.img, 120, 10, 150, 48); - let silent_zone = alpha_sum(&out.img, 170, 10, 195, 48); - assert!(peak_zone > 5.0, "peak zone has energy: {peak_zone}"); - assert!(peak_zone > high_zone * 2.0, "linear peak is distinctly taller than far frequencies: {peak_zone} vs {high_zone}"); - assert_eq!(silent_zone, 0.0, "silent end has zero height, not boosted by dB scale"); - } - - #[test] - fn fractal_deterministic_and_has_inside_set() { - let img = Image::new(64, 48); - let a = run("ec.generate.fractal", &[], img.clone()); - let b = run("ec.generate.fractal", &[], img.clone()); - assert_eq!(a.img.data, b.img.data); - // The default view contains the main cardioid (black, opaque) and escaped (coloured) points. - assert!(a.img.data.iter().any(|p| p[3] > 0.99 && p[0] + p[1] + p[2] < 0.01)); - assert!(a.img.data.iter().any(|p| p[0] + p[1] + p[2] > 0.3)); - let t = run("ec.generate.fractal", &[("fractalColor/transparency", Value::Bool(true))], img); - assert!(t.img.data.iter().any(|p| p[3] == 0.0)); - } - - #[test] - fn fractal_palettes_and_set_choices() { - let img = Image::new(64, 48); - // Solid Color: only the inside of the set is drawn; Transparency flips it. - let solid = run("ec.generate.fractal", &[("fractalColor/palette", Value::Enum(3))], img.clone()); - assert!(solid.img.data.iter().any(|p| p[3] == 0.0) && solid.img.data.iter().any(|p| p[3] > 0.99 && p[0] > 0.9)); - let flipped = run("ec.generate.fractal", &[("fractalColor/palette", Value::Enum(3)), ("fractalColor/transparency", Value::Bool(true))], img.clone()); - for (a, b) in solid.img.data.iter().zip(&flipped.img.data) { - assert!((a[3] > 0.5) != (b[3] > 0.5) || (a[3] - 0.5).abs() < 0.49); - } - // Black And White: escaped pixels are pure black or white (away from supersampled edges). - let bw = run("ec.generate.fractal", &[("fractalColor/palette", Value::Enum(2)), ("highQualitySettings/samplingFactor", num(1.0))], img.clone()); - assert!(bw.img.data.iter().all(|p| p[0] == p[1] && p[1] == p[2] && (p[0] == 0.0 || p[0] == 1.0))); - // Mandelbrot Over Julia depends on the Julia centre. - let o1 = run("ec.generate.fractal", &[("setChoice", Value::Enum(2)), ("julia/juliaX", num(0.3))], img.clone()); - let o2 = run("ec.generate.fractal", &[("setChoice", Value::Enum(2)), ("julia/juliaX", num(-0.2))], img.clone()); - assert_ne!(o1.img.data, o2.img.data); - let plain = run("ec.generate.fractal", &[("julia/juliaX", num(0.3))], img); - assert_ne!(o1.img.data, plain.img.data); - } - - #[test] - fn scribble_fills_mask_and_animates() { - let masks = square_mask(); - let env = EffectEnv { masks: &masks, ..Default::default() }; - let img = Image::filled(64, 64, [0.0, 0.0, 1.0, 1.0]); - let a = run_env("ec.generate.scribble", &[], img.clone(), 0.5, env); - let b = run_env("ec.generate.scribble", &[], img.clone(), 0.5, env); - assert_eq!(a.img.data, b.img.data); - let inside = alpha_sum(&a.img, 18, 18, 46, 46); - let outside = alpha_sum(&a.img, 0, 0, 10, 64); - assert!(inside > 100.0 && outside < 1.0, "{inside} {outside}"); - let c = run_env("ec.generate.scribble", &[], img.clone(), 1.5, env); - assert_ne!(a.img.data, c.img.data, "jumpy wiggle changes over time"); - let s = run_env("ec.generate.scribble", &[("wiggleType", Value::Enum(0))], img.clone(), 0.5, env); - let s2 = run_env("ec.generate.scribble", &[("wiggleType", Value::Enum(0))], img.clone(), 3.5, env); - assert_eq!(s.img.data, s2.img.data, "static wiggle is constant"); - let half = run_env("ec.generate.scribble", &[("end", num(50.0))], img, 0.5, env); - assert!(alpha_sum(&half.img, 0, 0, 64, 64) < alpha_sum(&a.img, 0, 0, 64, 64)); - } - - #[test] - fn scribble_edge_types() { - let masks = square_mask(); - let env = EffectEnv { masks: &masks, ..Default::default() }; - let img = Image::new(64, 64); - let o = run_env("ec.generate.scribble", &[("fillType", Value::Enum(3)), ("edgeOptions/edgeWidth", num(6.0))], img, 0.5, env); - // Outside edge: paint near the border outside, nothing in the centre. - assert!(alpha_sum(&o.img, 26, 26, 38, 38) < 0.5); - assert!(alpha_sum(&o.img, 8, 8, 16, 56) > 5.0); - } - - #[test] - fn stroke_draws_along_mask() { - let masks = square_mask(); - let env = EffectEnv { masks: &masks, ..Default::default() }; - let img = Image::new(64, 64); - let a = run_env("ec.generate.stroke", &[("brushSize", num(4.0)), ("paintStyle", Value::Enum(1))], img.clone(), 0.0, env); - assert!(a.img.get(32, 16)[3] > 0.9, "on the top edge"); - assert!(a.img.get(32, 32)[3] < 0.01, "centre empty"); - let b = run_env("ec.generate.stroke", &[("brushSize", num(4.0)), ("end", num(25.0))], img.clone(), 0.0, env); - assert!(b.img.get(32, 16)[3] > 0.9 && b.img.get(48, 32)[3] < 0.01, "first quarter only"); - let d = run_env("ec.generate.stroke", &[("brushSize", num(4.0)), ("spacing", num(300.0))], img, 0.0, env); - assert!(alpha_sum(&d.img, 0, 0, 64, 64) < alpha_sum(&a.img, 0, 0, 64, 64), "spaced dabs"); - // No masks: unchanged. - let src = Image::filled(8, 8, [0.2, 0.3, 0.4, 1.0]); - assert_eq!(run("ec.generate.stroke", &[], src.clone()).img.data, src.data); - } - - #[test] - fn vegas_traces_contours() { - let mut img = Image::new(64, 64); - for y in 16..48 { - for x in 16..48 { - img.set(x, y, [1.0, 1.0, 1.0, 1.0]); - } - } - let a = run( - "ec.generate.vegas", - &[("blendMode", Value::Enum(0)), ("length", num(1.0)), ("midpointOpacity", num(1.0)), ("endOpacity", num(1.0))], - img.clone(), - ); - let b = run( - "ec.generate.vegas", - &[("blendMode", Value::Enum(0)), ("length", num(1.0)), ("midpointOpacity", num(1.0)), ("endOpacity", num(1.0))], - img.clone(), - ); - assert_eq!(a.img.data, b.img.data); - assert!(alpha_sum(&a.img, 14, 14, 50, 50) > 50.0, "edges drawn"); - assert!(alpha_sum(&a.img, 26, 26, 38, 38) < 0.5, "interior untouched"); - let r = run("ec.generate.vegas", &[("blendMode", Value::Enum(0)), ("rotation", num(90.0))], img); - assert_ne!(r.img.data, a.img.data); - } - - #[test] - fn contours_of_square_are_closed() { - let mut pl = Plane::new(20, 20); - for y in 5..15 { - for x in 5..15 { - pl.data[y * 20 + x] = 1.0; - } - } - let c = contours(&pl, 0.5); - assert_eq!(c.len(), 1); - assert!(c[0].1); - let l = poly_length(&c[0].0, true); - assert!((l - 40.0).abs() < 4.0, "{l}"); - } - - #[test] - fn write_on_and_glue_gun_draw_at_brush() { - let img = Image::new(32, 32); - let a = run("ec.generate.writeon", &[], img.clone()); - assert!(a.img.get(16, 16)[3] > 0.9 && a.img.get(2, 2)[3] == 0.0); - assert_eq!(a.img.data, run("ec.generate.writeon", &[], img.clone()).img.data); - let src = Image::filled(32, 32, [0.5, 0.2, 0.1, 1.0]); - let g = run("ec.generate.ccgluegun", &[], src.clone()); - assert_eq!(g.img.data, run("ec.generate.ccgluegun", &[], src.clone()).img.data); - assert_ne!(g.img.get(16, 16), src.get(16, 16)); - assert_eq!(g.img.get(0, 0), src.get(0, 0)); - } - - /// #536: an animated Brush Position draws a stroke that stays (Stroke Length 0), expires - /// after Stroke Length, and lays its dabs Brush Spacing seconds apart. - #[test] - fn write_on_accumulates_a_stroke_along_the_animated_brush() { - // Brush Position from [8, 16] at 0 s to [56, 16] at 1 s; red, hard, 6 px. - struct Brush(Vec<(&'static str, Value)>); - impl Brush { - fn at(&self, t: f64) -> Vec<(&'static str, Value)> { - let mut v = self.0.clone(); - v.push(("brushPosition", Value::Vec2([8.0 + 48.0 * t.clamp(0.0, 1.0), 16.0]))); - v - } - } - impl EffectHost for Brush { - fn layer(&self, _: u64, _: bool) -> Option { - None - } - fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { - None - } - fn params_at(&self, t: f64) -> Option { - let s = crate::find("ec.generate.writeon").unwrap(); - let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; - for (k, v) in self.at(t) { - p.values.insert(k.to_string(), v); - } - Some(p) - } - } - let render = |extra: &[(&'static str, Value)], t: f64| { - let mut set = vec![("color", col(1.0, 0.0, 0.0)), ("brushSize", num(6.0)), ("brushHardness", num(100.0)), ("brushSpacing", num(0.05))]; - set.extend(extra.iter().cloned()); - let host = Brush(set); - let env = EffectEnv { host: Some(&host), ..Default::default() }; - let at = host.at(t); - run_env("ec.generate.writeon", &at, Image::new(64, 32), t, env).img - }; - let on = |img: &Image, x: i64| img.get(x, 16)[3] > 0.9; - let half = render(&[], 0.5); - assert!(on(&half, 8) && on(&half, 20) && on(&half, 32) && !on(&half, 44) && !on(&half, 56)); - let end = render(&[], 1.0); - assert!([8, 20, 32, 44, 56].iter().all(|x| on(&end, *x))); - // Stroke Length 0.25 s: only the last quarter second's trail. - let trail = render(&[("strokeLength", num(0.25))], 1.0); - assert!(!on(&trail, 8) && !on(&trail, 32) && on(&trail, 48) && on(&trail, 56)); - // Brush Spacing 0.5 s: three separate marks. - let spaced = render(&[("brushSpacing", num(0.5))], 1.0); - assert!(on(&spaced, 8) && on(&spaced, 32) && on(&spaced, 56) && !on(&spaced, 20) && !on(&spaced, 44)); - // Paint Time Properties ▸ Opacity: each dab keeps the opacity it was laid with. - struct Fade; - impl EffectHost for Fade { - fn layer(&self, _: u64, _: bool) -> Option { - None - } - fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { - None - } - fn params_at(&self, t: f64) -> Option { - let mut p = Brush(vec![("brushHardness", num(100.0)), ("brushSpacing", num(0.05)), ("paintTimeProperties", Value::Enum(1))]).params_at(t)?; - p.values.insert("brushOpacity".into(), num(if t < 0.5 { 100.0 } else { 20.0 })); - Some(p) - } - } - let p1 = Fade.params_at(1.0).unwrap(); - let set: Vec<(&str, Value)> = p1.values.iter().map(|(k, v)| (k.as_str(), v.clone())).collect(); - let faded = run_env("ec.generate.writeon", &set, Image::new(64, 32), 1.0, EffectEnv { host: Some(&Fade), ..Default::default() }).img; - assert!(faded.get(8, 16)[3] > 0.9 && (faded.get(56, 16)[3] - 0.2).abs() < 0.05, "{:?} {:?}", faded.get(8, 16), faded.get(56, 16)); - } - - #[test] - fn paint_bucket_fills_enclosed_region_only() { - let mut img = Image::filled(32, 32, [1.0, 1.0, 1.0, 1.0]); - for i in 8..24 { - for &(x, y) in &[(i, 8), (i, 23), (8, i), (23, i)] { - img.set(x, y, [0.0, 0.0, 0.0, 1.0]); - } - } - let out = run("ec.generate.paintbucket", &[], img.clone()); - let inside = out.img.get(16, 16); - assert!(inside[0] > 0.99 && inside[1] < 0.01, "{inside:?}"); - assert_eq!(out.img.get(2, 2), [1.0, 1.0, 1.0, 1.0], "outside untouched"); - assert_eq!(out.img.get(8, 16), [0.0, 0.0, 0.0, 1.0], "border untouched"); - assert_eq!(out.img.data, run("ec.generate.paintbucket", &[], img).img.data); - } - - #[test] - fn eyedropper_fill_uses_sample() { - let mut img = Image::filled(16, 16, [0.0, 0.0, 1.0, 1.0]); - img.set(8, 8, [1.0, 0.0, 0.0, 1.0]); - let out = run("ec.generate.eyedropperfill", &[], img.clone()); - assert!(out.img.data.iter().all(|p| *p == [1.0, 0.0, 0.0, 1.0])); - let blended = run("ec.generate.eyedropperfill", &[("blendWithOriginal", num(100.0))], img.clone()); - assert_eq!(blended.img.data, img.data); - } - - #[test] - fn threads_weave_is_deterministic_and_gappy() { - let src = Image::filled(64, 64, [0.4, 0.6, 0.2, 1.0]); - let a = run("ec.generate.ccthreads", &[("width", num(16.0)), ("height", num(16.0)), ("coverage", num(50.0))], src.clone()); - assert_eq!(a.img.data, run("ec.generate.ccthreads", &[("width", num(16.0)), ("height", num(16.0)), ("coverage", num(50.0))], src).img.data); - assert!(a.img.data.iter().any(|p| p[3] == 0.0), "gaps between threads"); - assert!(a.img.data.iter().any(|p| p[3] > 0.99), "threads"); - } - - struct Tone; - impl EffectHost for Tone { - fn layer(&self, _: u64, _: bool) -> Option { - None - } - fn audio(&self, _: u64, start: f64, frames: usize, rate: u32) -> Option> { - Some( - (0..frames) - .flat_map(|i| { - let t = start + i as f64 / rate as f64; - let v = (2.0 * PI * 440.0 * t).sin() as f32 * 0.8; - [v, v] - }) - .collect(), - ) - } - } - - #[test] - fn audio_effects_without_audio_pass_through() { - let src = Image::filled(16, 16, [0.3, 0.3, 0.3, 1.0]); - for id in ["ec.generate.audiospectrum", "ec.generate.audiowaveform"] { - assert_eq!(run(id, &[], src.clone()).img.data, src.data, "{id}"); - } - } - - #[test] - fn audio_effects_draw_with_host() { - let host = Tone; - let env = EffectEnv { host: Some(&host), comp_time: 1.0, ..Default::default() }; - let img = Image::new(96, 64); - for id in ["ec.generate.audiospectrum", "ec.generate.audiowaveform"] { - let a = run_env(id, &[("audioLayer", Value::Layer(Some(1)))], img.clone(), 1.0, env); - let b = run_env(id, &[("audioLayer", Value::Layer(Some(1)))], img.clone(), 1.0, env); - assert_eq!(a.img.data, b.img.data, "{id}"); - assert!(alpha_sum(&a.img, 0, 0, 96, 64) > 10.0, "{id} drew nothing"); - } - } - - #[test] - fn fft_finds_tone() { - let n = 256; - let mut re: Vec = (0..n).map(|i| (2.0 * PI * 8.0 * i as f64 / n as f64).cos()).collect(); - let mut im = vec![0.0; n]; - fft(&mut re, &mut im); - let mags: Vec = (0..n / 2).map(|k| (re[k] * re[k] + im[k] * im[k]).sqrt()).collect(); - let peak = mags.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1)).unwrap().0; - assert_eq!(peak, 8); - } - - #[test] - fn stroke_outline_caps_and_joins() { - let line = vec![vec![[10.0, 10.0], [30.0, 10.0]]]; - let cov = |cap: u32| raster_convex(40, 20, &stroke_outline(&line, 3.0, cap, 0, 4.0)); - // Round caps reach 3 px past the ends (on the axis), Butt stops there, Projecting is square. - let (round, butt, proj) = (cov(0), cov(1), cov(2)); - assert!(round.get(8, 10) > 0.9 && butt.get(8, 10) < 0.1 && proj.get(8, 10) > 0.9); - assert!(proj.get(7, 7) > 0.9 && round.get(7, 7) < 0.5, "square corner"); - // A right-angle turn: Miter fills the outer corner, Bevel cuts it. - let corner = vec![vec![[5.0, 30.0], [30.0, 30.0], [30.0, 5.0]]]; - let j = |join: u32| raster_convex(40, 40, &stroke_outline(&corner, 3.0, 1, join, 4.0)); - let (miter, bevel) = (j(1), j(2)); - assert!(miter.get(32, 32) > 0.9 && bevel.get(32, 32) < 0.1, "{} {}", miter.get(32, 32), bevel.get(32, 32)); - // A tight miter limit falls back to a bevel. - let limited = raster_convex(40, 40, &stroke_outline(&corner, 3.0, 1, 1, 1.0)); - assert!(limited.get(32, 32) < 0.1); - } - - #[test] - fn scribble_caps_and_start_end_apply_to() { - let masks = square_mask(); - let env = EffectEnv { masks: &masks, ..Default::default() }; - let img = Image::new(64, 64); - let base = [("wiggleType", Value::Enum(0)), ("strokeWidth", num(3.0))]; - let round = run_env("ec.generate.scribble", &base, img.clone(), 0.0, env); - let butt = run_env( - "ec.generate.scribble", - &[base.as_slice(), &[("edgeOptions/endCap", Value::Enum(1)), ("edgeOptions/join", Value::Enum(2))]].concat(), - img.clone(), - 0.0, - env, - ); - assert_ne!(round.img.data, butt.img.data); - // Each Stroke: every stroke is trimmed on its own, so the half-way image has ink all over. - let two = vec![ - crate::MaskShape { name: String::new(), points: vec![[4.0, 4.0], [28.0, 4.0], [28.0, 60.0], [4.0, 60.0]], closed: true, inverted: false }, - crate::MaskShape { name: String::new(), points: vec![[36.0, 4.0], [60.0, 4.0], [60.0, 60.0], [36.0, 60.0]], closed: true, inverted: false }, - ]; - let env2 = EffectEnv { masks: &two, ..Default::default() }; - let seq = [("scribble", Value::Enum(1)), ("end", num(50.0))]; - let all = run_env("ec.generate.scribble", &[base.as_slice(), &seq].concat(), img.clone(), 0.0, env2); - let each = run_env("ec.generate.scribble", &[base.as_slice(), &seq, &[("startEndApplyTo", Value::Enum(1))]].concat(), img, 0.0, env2); - assert_ne!(all.img.data, each.img.data); - } -} +#[cfg(test)] +mod tests; diff --git a/crates/effects/src/generate3/audio.rs b/crates/effects/src/generate3/audio.rs new file mode 100644 index 000000000..6c8d66e4a --- /dev/null +++ b/crates/effects/src/generate3/audio.rs @@ -0,0 +1,314 @@ +use super::raster::{Seg, mask_px, over, poly_segs, raster_segs}; +use super::{Buf, EffectCtx, IndexedParallelIterator, IntoParallelRefMutIterator, PI, ParallelIterator, poly_length, premul}; + +// ---------------------------------------------------------------- Audio Spectrum / Waveform + +/// In-place iterative radix-2 FFT (`re`, `im` lengths a power of two). +pub(crate) fn fft(re: &mut [f64], im: &mut [f64]) { + let n = re.len(); + if n < 2 { + return; + } + let mut j = 0; + for i in 1..n { + let mut bit = n >> 1; + while j & bit != 0 { + j ^= bit; + bit >>= 1; + } + j |= bit; + if i < j { + re.swap(i, j); + im.swap(i, j); + } + } + let mut len = 2; + while len <= n { + let ang = -2.0 * PI / len as f64; + let (ws, wc) = ang.sin_cos(); + for start in (0..n).step_by(len) { + let (mut cr, mut ci) = (1.0, 0.0); + for k in 0..len / 2 { + let (a, bb) = (start + k, start + k + len / 2); + let tr = re[bb] * cr - im[bb] * ci; + let ti = re[bb] * ci + im[bb] * cr; + re[bb] = re[a] - tr; + im[bb] = im[a] - ti; + re[a] += tr; + im[a] += ti; + let ncr = cr * wc - ci * ws; + ci = cr * ws + ci * wc; + cr = ncr; + } + } + len <<= 1; + } +} + +const AUDIO_RATE: u32 = 48_000; + +/// Fetch mono (or one channel) samples of the audio layer around the current time. +fn audio_window(ctx: &EffectCtx, dur_ms: f64, offset_ms: f64, channel: u32) -> Option> { + let id = ctx.params.get("audioLayer")?.as_layer()?; + let host = ctx.env.host?; + let dur = (dur_ms / 1000.0).max(0.001); + let frames = ((dur * AUDIO_RATE as f64) as usize).clamp(16, 1 << 16); + let start = ctx.env.comp_time + offset_ms / 1000.0 - dur * 0.5; + let s = host.audio(id, start, frames, AUDIO_RATE)?; + Some( + (0..frames) + .map(|i| { + let (l, r) = (*s.get(2 * i).unwrap_or(&0.0) as f64, *s.get(2 * i + 1).unwrap_or(&0.0) as f64); + match channel { + 1 => l, + 2 => r, + _ => (l + r) * 0.5, + } + }) + .collect(), + ) +} + +/// The drawing baseline: a polyline in buffer px (mask path or start→end line), sampled at +/// parameter u∈[0,1] → (point, unit normal). +struct Baseline { + pts: Vec<[f64; 2]>, + closed: bool, + len: f64, + polar: bool, + center: [f64; 2], + radius: f64, +} + +impl Baseline { + fn new(ctx: &EffectCtx, b: &Buf) -> Baseline { + let pr = ctx.params; + let polar = pr.b("usePolarPath"); + let (pts, closed) = match mask_px(ctx, b, pr.f("path").round() as usize) { + Some((p, c, _)) if !polar => (p, c), + _ => { + let s = b.to_px(pr.v2("startPoint")); + let e = b.to_px(pr.v2("endPoint")); + (vec![[s.0, s.1], [e.0, e.1]], false) + } + }; + let len = poly_length(&pts, closed); + let center = pts[0]; + let radius = if pts.len() > 1 { ((pts[1][0] - pts[0][0]).powi(2) + (pts[1][1] - pts[0][1]).powi(2)).sqrt() } else { 1.0 }; + Baseline { pts, closed, len, polar, center, radius } + } + fn at(&self, u: f64) -> ([f64; 2], [f64; 2]) { + if self.polar { + let a = u * 2.0 * PI - PI * 0.5; + let (s, c) = a.sin_cos(); + return ([self.center[0] + c * self.radius * 0.5, self.center[1] + s * self.radius * 0.5], [c, s]); + } + let (q, d) = crate::util::poly_point_at(&self.pts, self.closed, u * self.len); + (q, [d[1], -d[0]]) + } +} + +/// Draw line/dot marks with core (inside colour) and soft halo (outside colour). +fn draw_marks(b: &mut Buf, segs_core: &[Seg], thickness: f64, softness: f64, inside: [f32; 4], outside: [f32; 4], composite: bool) { + let (w, h) = (b.img.width as usize, b.img.height as usize); + let core = raster_segs(w, h, segs_core, 1.0); + let halo_segs: Vec = segs_core.iter().map(|s| Seg { r: s.r + thickness * softness.max(0.0) + 0.5, ..*s }).collect(); + let halo = if softness > 0.0 { raster_segs(w, h, &halo_segs, 0.0) } else { core.clone() }; + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let (c, hl) = (core.data[i], halo.data[i]); + let a = c.max(hl); + let base = if composite { *px } else { [0.0; 4] }; + if a <= 0.0 { + *px = base; + return; + } + let t = if a > 0.0 { c / a } else { 0.0 }; + let col = crate::util::lerp3([outside[0], outside[1], outside[2]], [inside[0], inside[1], inside[2]], t); + *px = over(base, premul(col, a)); + }); +} + +/// Audio Spectrum / Audio Waveform, shared with the GPU compositor (effectcraft-gpu `fx_gen2`): +/// the marks (the audio analysis runs on the CPU) and how `draw_marks` colours them. +#[derive(Clone, Debug)] +pub struct MarksPlan { + pub segs: Vec, + pub thickness: f64, + pub softness: f64, + pub inside: [f32; 4], + pub outside: [f32; 4], + pub composite: bool, +} + +/// [`MarksPlan`] of Audio Spectrum or Audio Waveform on `b`'s geometry (`None` = no audio: +/// the layer passes through). +pub fn marks_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option { + match id { + "ec.generate.audiospectrum" => spectrum_plan(ctx, b), + "ec.generate.audiowaveform" => waveform_plan(ctx, b), + _ => None, + } +} + +fn marks(ctx: &EffectCtx, segs: Vec, thick: f64, soft: f64) -> MarksPlan { + let pr = ctx.params; + MarksPlan { + segs, + thickness: thick, + softness: soft * 2.0, + inside: pr.color("insideColor"), + outside: pr.color("outsideColor"), + composite: pr.b("compositeOnOriginal"), + } +} + +pub(super) fn audio_spectrum(ctx: &EffectCtx, mut b: Buf) -> Buf { + if let Some(m) = spectrum_plan(ctx, &b) { + draw_marks(&mut b, &m.segs, m.thickness, m.softness, m.inside, m.outside, m.composite); + } + b +} + +pub(super) fn audio_waveform(ctx: &EffectCtx, mut b: Buf) -> Buf { + if let Some(m) = waveform_plan(ctx, &b) { + draw_marks(&mut b, &m.segs, m.thickness, m.softness, m.inside, m.outside, m.composite); + } + b +} + +fn compute_fft_mags(samples: &[f64]) -> (usize, f64, Vec) { + let m = samples.len(); + let n = m.next_power_of_two().max(64); + let mut re = vec![0.0; n]; + let mut im = vec![0.0; n]; + let denom = (m.max(2) - 1) as f64; + for (i, s) in samples.iter().enumerate() { + let w = 0.5 - 0.5 * (2.0 * PI * i as f64 / denom).cos(); + if let Some(r) = re.get_mut(i) { + *r = s * w; + } + } + fft(&mut re, &mut im); + let hz_per_bin = AUDIO_RATE as f64 / n as f64; + let scale = 4.0 / m.max(1) as f64; + let num_bins = n / 2 + 1; + let mut mags = Vec::with_capacity(num_bins); + for k in 0..num_bins { + let r = re.get(k).copied().unwrap_or(0.0); + let im_val = im.get(k).copied().unwrap_or(0.0); + let mag = (r * r + im_val * im_val).sqrt() * scale; + mags.push(mag); + } + (n, hz_per_bin, mags) +} + +fn spectrum_plan(ctx: &EffectCtx, b: &Buf) -> Option { + let pr = ctx.params; + let samples = audio_window(ctx, pr.f("audioDuration"), pr.f("audioOffset"), 0)?; + let (_n, hz_per_bin, mags) = compute_fft_mags(&samples); + + let bands = pr.f("frequencyBands").round().clamp(1.0, 4096.0) as usize; + let f0 = pr.f("startFrequency").max(0.0); + let f1 = pr.f("endFrequency").max(f0 + 1.0); + let max_bin = mags.len().saturating_sub(1); + + let get_band_mag = |fc: f64| -> f64 { + if mags.is_empty() || hz_per_bin <= 0.0 { + return 0.0; + } + let k_float = (fc / hz_per_bin).clamp(0.0, max_bin as f64); + let k0 = (k_float.floor() as usize).min(max_bin.saturating_sub(1)); + let k1 = (k0 + 1).min(max_bin); + let frac = (k_float - k0 as f64).clamp(0.0, 1.0); + let m0 = mags.get(k0).copied().unwrap_or(0.0); + let m1 = mags.get(k1).copied().unwrap_or(0.0); + m0 * (1.0 - frac) + m1 * frac + }; + + let max_h = pr.f("maximumHeight") * b.scale; + let thick = (pr.f("thickness") * b.scale).max(0.1); + let soft = pr.f("softness") / 100.0; + let disp = pr.e("displayOptions"); + let side = pr.e("sideOptions"); + let base = Baseline::new(ctx, b); + let mut tops = Vec::with_capacity(bands); + let mut segs = Vec::new(); + for i in 0..bands { + let u = if bands > 1 { i as f64 / (bands - 1) as f64 } else { 0.5 }; + let fc = f0 + (f1 - f0) * u; + let v = get_band_mag(fc); + let hgt = if (v * max_h).is_finite() { (v * max_h).clamp(0.0, max_h.max(0.0)) } else { 0.0 }; + let (q, nrm) = base.at(u); + let (ua, ub) = match side { + 0 => (0.0, hgt), + 1 => (-hgt, 0.0), + _ => (-hgt, hgt), + }; + let pa = [q[0] + nrm[0] * ua, q[1] + nrm[1] * ua]; + let pb = [q[0] + nrm[0] * ub, q[1] + nrm[1] * ub]; + match disp { + 0 => segs.push(Seg { a: pa, b: pb, r: thick * 0.5, v: 1.0 }), + _ => tops.push((pa, pb)), + } + } + if disp == 1 { + for w2 in tops.windows(2) { + segs.push(Seg { a: w2[0].1, b: w2[1].1, r: thick * 0.5, v: 1.0 }); + if side == 2 { + segs.push(Seg { a: w2[0].0, b: w2[1].0, r: thick * 0.5, v: 1.0 }); + } + } + } else if disp == 2 { + for &(pa, pb) in &tops { + segs.push(Seg { a: pb, b: pb, r: thick * 0.5, v: 1.0 }); + if side == 2 { + segs.push(Seg { a: pa, b: pa, r: thick * 0.5, v: 1.0 }); + } + } + } + Some(marks(ctx, segs, thick, soft)) +} + +fn waveform_plan(ctx: &EffectCtx, b: &Buf) -> Option { + let pr = ctx.params; + let samples = audio_window(ctx, pr.f("audioDuration"), pr.f("audioOffset"), pr.e("waveformOptions"))?; + let count = pr.f("displayedSamples").round().clamp(2.0, 4096.0) as usize; + let max_h = pr.f("maximumHeight") * b.scale; + let thick = (pr.f("thickness") * b.scale).max(0.1); + let soft = pr.f("softness") / 100.0; + let disp = pr.e("displayOptions"); + let base = Baseline::new(ctx, b); + let m = samples.len(); + let mut pts = Vec::with_capacity(count); + let mut segs = Vec::new(); + for i in 0..count { + let u = i as f64 / (count - 1) as f64; + let a = ((u * (m - 1) as f64) as usize).min(m - 1); + let bnd = (((i + 1) as f64 / count as f64 * (m - 1) as f64) as usize).clamp(a, m - 1); + // Peak with sign of the window for a stable digital display. + let (mut lo, mut hi) = (0.0f64, 0.0f64); + for s in &samples[a..=bnd] { + lo = lo.min(*s); + hi = hi.max(*s); + } + let (q, nrm) = base.at(u); + match disp { + 0 => segs.push(Seg { + a: [q[0] + nrm[0] * lo * max_h, q[1] + nrm[1] * lo * max_h], + b: [q[0] + nrm[0] * hi * max_h, q[1] + nrm[1] * hi * max_h], + r: thick * 0.5, + v: 1.0, + }), + _ => { + let v = samples[a] * max_h; + pts.push([q[0] + nrm[0] * v, q[1] + nrm[1] * v]); + } + } + } + if disp == 1 { + poly_segs(&pts, false, thick * 0.5, 1.0, &mut segs); + } else if disp == 2 { + segs.extend(pts.iter().map(|&q| Seg { a: q, b: q, r: thick * 0.5, v: 1.0 })); + } + Some(marks(ctx, segs, thick, soft)) +} diff --git a/crates/effects/src/generate3/common.rs b/crates/effects/src/generate3/common.rs new file mode 100644 index 000000000..7e341ad8d --- /dev/null +++ b/crates/effects/src/generate3/common.rs @@ -0,0 +1,13 @@ +use super::{EffectSpec, ParamUi, Value, slider}; + +pub(super) fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Generate", params, render, gpu: false, float: true } +} + +pub(super) fn pt(x: f64, y: f64) -> Value { + Value::Vec2([x, y]) +} + +pub(super) fn pct() -> ParamUi { + slider(0.0, 100.0, 0.0, 100.0, 1) +} diff --git a/crates/effects/src/generate3/fill.rs b/crates/effects/src/generate3/fill.rs new file mode 100644 index 000000000..5df782a89 --- /dev/null +++ b/crates/effects/src/generate3/fill.rs @@ -0,0 +1,337 @@ +use super::raster::over; +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; + let q = b.to_px(pr.v2("brushPosition")); + let rad = (pr.f("strokeWidth") * b.scale * 0.5).max(0.5); + let density = pr.f("density").max(0.0); + let refl = pr.f("reflection").to_radians(); + let strength = (pr.f("strength") / 100.0) as f32; + let glue_only = pr.e("style") == 1; + let src = b.img.clone(); + let light = [refl.cos() * 0.6, -refl.sin() * 0.6, 0.53]; + let beads = (density.round() as usize).clamp(1, 32); + let seed = ctx.seed; + let centers: Vec<([f64; 2], f64)> = (0..beads) + .map(|i| { + let a = hash1(i as u32, 1, seed) as f64 * 2.0 * PI; + let d = if i == 0 { 0.0 } else { hash1(i as u32, 2, seed) as f64 * rad * 0.35 }; + ([q.0 + a.cos() * d, q.1 + a.sin() * d], rad * (0.75 + 0.25 * hash1(i as u32, 3, seed) as f64)) + }) + .collect(); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5); + // Height field: union of hemispheres. + let mut best = (0.0f64, [0.0f64; 2], 1.0f64); + for &(c, r) in ¢ers { + let (dx, dy) = ((fx - c[0]) / r, (fy - c[1]) / r); + let d2 = dx * dx + dy * dy; + if d2 < 1.0 { + let hgt = (1.0 - d2).sqrt(); + if hgt > best.0 { + best = (hgt, [dx, dy], r); + } + } + } + if best.0 <= 0.0 { + if glue_only { + *px = [0.0; 4]; + } + continue; + } + let nrm = [best.1[0], best.1[1], best.0]; + let ndl = (nrm[0] * light[0] + nrm[1] * light[1] + nrm[2] * light[2]).max(0.0); + let spec = ndl.powi(24) as f32; + // Refraction: look through the bead. + let s = src.sample_bilinear_clamped(fx - nrm[0] * best.2 * 0.5 * strength as f64, fy - nrm[1] * best.2 * 0.5 * strength as f64); + let edge = smoothstep(0.0, 0.15, best.0 as f32); + let shade = 0.65 + 0.35 * ndl as f32; + let g = [s[0] * shade + spec, s[1] * shade + spec, s[2] * shade + spec, s[3].max(0.6)]; + let gp = [g[0].min(g[3] + spec), g[1].min(g[3] + spec), g[2].min(g[3] + spec), g[3].min(1.0)]; + let base = if glue_only { [0.0; 4] } else { *px }; + *px = crate::util::lerp4(base, gp, edge * strength.clamp(0.0, 1.0).max(0.3)); + px[3] = px[3].clamp(0.0, 1.0); + } + }); + b +} + +// ---------------------------------------------------------------- Paint Bucket + +/// Paint Bucket's filled region (1 inside) on `b`: the flood fill from the Fill Point over the +/// layer's pixels (shared with the GPU compositor, which reads the layer back for it). +pub fn paint_bucket_region(ctx: &EffectCtx, b: &Buf) -> Plane { + let pr = ctx.params; + let (w, h) = (b.img.width as usize, b.img.height as usize); + let fp = b.to_px(pr.v2("fillPoint")); + let (sx, sy) = (fp.0.floor() as i64, fp.1.floor() as i64); + let sel = pr.e("fillSelector"); + let tol = (pr.f("tolerance") / 255.0) as f32; + let key = |p: Px| -> [f32; 4] { + let (c, a) = unpremul(p); + match sel { + 1 => [c[0], c[1], c[2], 0.0], + 2 => [0.0, 0.0, 0.0, if a <= 1e-3 { 0.0 } else { 1.0 }], + 3 | 4 => [0.0, 0.0, 0.0, a], + _ => p, + } + }; + let mut region = Plane::new(w, h); + if sx >= 0 && sy >= 0 && (sx as usize) < w && (sy as usize) < h { + let seed = key(b.img.data[sy as usize * w + sx as usize]); + let within = |i: usize| { + let k = key(b.img.data[i]); + if sel == 2 { + return k[3] == seed[3]; + } + (0..4).all(|j| (k[j] - seed[j]).abs() <= tol + 1e-6) + }; + let mut seen = vec![false; w * h]; + let mut stack = vec![(sx as usize, sy as usize)]; + while let Some((x, y)) = stack.pop() { + let row = y * w; + if seen[row + x] || !within(row + x) { + continue; + } + let mut l = x; + while l > 0 && !seen[row + l - 1] && within(row + l - 1) { + l -= 1; + } + let mut r = x; + while r + 1 < w && !seen[row + r + 1] && within(row + r + 1) { + r += 1; + } + for i in l..=r { + seen[row + i] = true; + region.data[row + i] = 1.0; + if y > 0 && !seen[row - w + i] { + stack.push((i, y - 1)); + } + if y + 1 < h && !seen[row + w + i] { + stack.push((i, y + 1)); + } + } + } + } + region +} + +pub(super) fn paint_bucket(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let (w, h) = (b.img.width as usize, b.img.height as usize); + if w == 0 || h == 0 { + return b; + } + let region = paint_bucket_region(ctx, &b); + if pr.b("viewThreshold") { + b.img.data.par_iter_mut().zip(region.data.par_iter()).for_each(|(px, &v)| *px = [v, v, v, 1.0]); + return b; + } + let sc = b.scale; + let mut cov = match pr.e("stroke") { + 1 => { + let s = pr.f("featherSoftness") * sc * 0.5; + if s > 0.05 { gauss_plane(®ion, s, s) } else { region.clone() } + } + 2 => morph_frac(®ion, pr.f("spreadRadius") * sc, true), + 3 => morph_frac(®ion, pr.f("spreadRadius") * sc, false), + 4 => { + let r = pr.f("strokeWidth") * sc; + let out = morph_frac(®ion, r * 0.5, true); + let inn = morph_frac(®ion, r * 0.5, false); + out.zip_map(&inn, |a, b| (a - b).max(0.0)) + } + _ => gauss_plane(®ion, 0.5, 0.5), + }; + if pr.e("stroke") == 0 { + // Antialias: keep the hard interior, soften only the boundary. + cov = cov.zip_map(®ion, |a, r| if r > 0.0 { a.max(0.5) } else { a }); + } + let color = pr.color("color"); + let op = (pr.f("opacity") / 100.0) as f32; + let mode = pr.e("blendingMode"); + b.img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &c)| { + let k = (c * op * color[3]).clamp(0.0, 1.0); + if k <= 0.0 { + if mode == 9 { + *px = [0.0; 4]; + } + return; + } + let (dc, da) = unpremul(*px); + let fc = [color[0], color[1], color[2]]; + let mixed = match mode { + 1 => [dc[0] + fc[0], dc[1] + fc[1], dc[2] + fc[2]], + 2 => [dc[0] * fc[0], dc[1] * fc[1], dc[2] * fc[2]], + 3 => [1.0 - (1.0 - dc[0]) * (1.0 - fc[0]), 1.0 - (1.0 - dc[1]) * (1.0 - fc[1]), 1.0 - (1.0 - dc[2]) * (1.0 - fc[2])], + 4..=7 => { + let (dh, ds, dl) = rgb_to_hsl(dc[0], dc[1], dc[2]); + let (fh, fs, fl) = rgb_to_hsl(fc[0], fc[1], fc[2]); + let (r, g, bl) = match mode { + 4 => hsl_to_rgb(fh, ds, dl), + 5 => hsl_to_rgb(dh, fs, dl), + 6 => hsl_to_rgb(fh, fs, dl), + _ => hsl_to_rgb(dh, ds, fl), + }; + [r, g, bl] + } + _ => fc, + }; + *px = match mode { + 8 => over(premul(fc, k), *px), + 9 => premul(fc, k), + 0 => over(*px, premul(fc, k)), + _ => { + if da <= 1e-6 { + return; + } + let c3 = crate::util::lerp3(dc, mixed, k); + premul(c3, da) + } + }; + }); + b +} + +// ---------------------------------------------------------------- Eyedropper Fill + +pub(super) fn eyedropper_fill(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let fill = eyedropper_color(ctx, &b); + let keep_alpha = pr.b("maintainOriginalAlpha"); + let blend = (pr.f("blendWithOriginal") / 100.0) as f32; + b.img.data.par_iter_mut().for_each(|px| { + let a = if keep_alpha { px[3] } else { fill[3] }; + let f = premul([fill[0], fill[1], fill[2]], a); + *px = crate::util::lerp4(f, *px, blend); + }); + b +} + +/// Eyedropper Fill's sampled colour (straight RGB, alpha) from the layer's pixels (shared with +/// the GPU compositor, which reads the layer back for it). +pub fn eyedropper_color(ctx: &EffectCtx, b: &Buf) -> Px { + let pr = ctx.params; + let (w, h) = (b.img.width as i64, b.img.height as i64); + let c = b.to_px(pr.v2("samplePoint")); + let r = (pr.f("sampleRadius") * b.scale).max(0.0); + let mode = pr.e("averagePixelColors"); + let (cx, cy) = (c.0.floor() as i64, c.1.floor() as i64); + let ri = r.ceil() as i64; + let mut acc = [0.0f64; 4]; + let mut n = 0.0f64; + for y in (cy - ri).max(0)..=(cy + ri).min(h - 1) { + for x in (cx - ri).max(0)..=(cx + ri).min(w - 1) { + if ((x - cx).pow(2) + (y - cy).pow(2)) as f64 > r * r + 0.5 { + continue; + } + let px = b.img.data[(y * w + x) as usize]; + match mode { + 0 => { + if px[3] > 1e-4 { + let (s, _) = unpremul(px); + for k in 0..3 { + acc[k] += s[k] as f64; + } + acc[3] += 1.0; + n += 1.0; + } + } + 1 => { + let (s, _) = unpremul(px); + for k in 0..3 { + acc[k] += s[k] as f64; + } + acc[3] += 1.0; + n += 1.0; + } + 2 => { + for k in 0..3 { + acc[k] += px[k] as f64; + } + acc[3] += 1.0; + n += 1.0; + } + _ => { + for k in 0..4 { + acc[k] += px[k] as f64; + } + n += 1.0; + } + } + } + } + if n > 0.0 { + if mode == 3 { + let a = (acc[3] / n) as f32; + let s = if a > 1e-6 { [(acc[0] / n) as f32 / a, (acc[1] / n) as f32 / a, (acc[2] / n) as f32 / a] } else { [0.0; 3] }; + [s[0], s[1], s[2], a] + } else { + [(acc[0] / n) as f32, (acc[1] / n) as f32, (acc[2] / n) as f32, 1.0] + } + } else { + [0.0, 0.0, 0.0, 0.0] + } +} + +// ---------------------------------------------------------------- CC Threads + +pub(super) fn threads(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let sc = b.scale; + let tw = (pr.f("width") * sc).max(1.0); + let th = (pr.f("height") * sc).max(1.0); + let overlaps = pr.f("overlaps").round().clamp(1.0, 20.0) as i64; + let ang = pr.f("direction").to_radians(); + let (sn, cs) = ang.sin_cos(); + let c = b.to_px(pr.v2("center")); + let coverage = (pr.f("coverage") / 100.0).clamp(0.0, 1.0); + let shadow = (pr.f("shadowing") / 100.0) as f32; + let texture = (pr.f("texture") / 100.0) as f32; + let src = b.img.clone(); + let to_px = |u: f64, v: f64| (c.0 + u * cs - v * sn, c.1 + u * sn + v * cs); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (dx, dy) = (x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1); + let u = dx * cs + dy * sn; + let v = -dx * sn + dy * cs; + let (ci, cj) = ((u / tw).floor() as i64, (v / th).floor() as i64); + let (fu, fv) = (u / tw - ci as f64, v / th - cj as f64); + // Horizontal thread j occupies the band |fv-0.5| < coverage/2; vertical thread i the + // band |fu-0.5| < coverage/2. + let in_h = (fv - 0.5).abs() < coverage * 0.5; + let in_v = (fu - 0.5).abs() < coverage * 0.5; + // Twill: horizontal is on top when (i + j) mod (2·overlaps) < overlaps. + let h_top = (ci + cj).rem_euclid(2 * overlaps) < overlaps; + let which = match (in_h, in_v) { + (true, true) => Some(h_top), + (true, false) => Some(true), + (false, true) => Some(false), + _ => None, + }; + let Some(horizontal) = which else { + *px = [0.0; 4]; + continue; + }; + // Thread colour: the source sampled along the thread centre line. + let (sx, sy) = if horizontal { to_px(u, (cj as f64 + 0.5) * th) } else { to_px((ci as f64 + 0.5) * tw, v) }; + let s = src.sample_bilinear_clamped(sx, sy); + // Round profile across the thread + darkening where it dives under its neighbour. + let across = if horizontal { (fv - 0.5) / (coverage * 0.5).max(1e-6) } else { (fu - 0.5) / (coverage * 0.5).max(1e-6) }; + let along = if horizontal { fu } else { fv }; + let profile = (1.0 - across * across).max(0.0).sqrt() as f32; + let dive = 1.0 - (along - 0.5).abs() as f32 * 2.0; + let shade = 1.0 - shadow * (1.0 - profile) * 0.8 - shadow * 0.25 * (1.0 - dive) * if horizontal == h_top { 0.0 } else { 1.0 }; + let fib = + if texture > 0.0 { 1.0 - texture * 0.3 * hash1((along * 64.0) as u32, (across * 8.0 + 8.0) as u32, ci as u32 ^ (cj as u32) << 8) } else { 1.0 }; + let k = (shade * fib).max(0.0); + *px = [s[0] * k, s[1] * k, s[2] * k, s[3]]; + } + }); + b +} diff --git a/crates/effects/src/generate3/fractal.rs b/crates/effects/src/generate3/fractal.rs new file mode 100644 index 000000000..05a76d5f0 --- /dev/null +++ b/crates/effects/src/generate3/fractal.rs @@ -0,0 +1,193 @@ +use super::raster::over; +use super::{Buf, EffectCtx, IndexedParallelIterator, ParallelIterator, ParallelSliceMut, Px, hsl_to_rgb}; + +// ---------------------------------------------------------------- Fractal + +#[derive(Clone, Copy)] +struct FracSet { + /// Mandelbrot iterations start at the Julia centre (the "Over Julia" set choices). + over_julia: bool, + julia: bool, + inverse: bool, +} + +/// Smooth escape count, or `None` inside the set. +fn escape(z0: (f64, f64), c: (f64, f64), power: u32, limit: f64, max_iter: u32) -> Option { + let (mut x, mut y) = z0; + let lim2 = limit * limit; + for i in 0..max_iter { + let (nx, ny) = match power { + 3 => (x * x * x - 3.0 * x * y * y, 3.0 * x * x * y - y * y * y), + 4 => { + let (a, b) = (x * x - y * y, 2.0 * x * y); + (a * a - b * b, 2.0 * a * b) + } + _ => (x * x - y * y, 2.0 * x * y), + }; + x = nx + c.0; + y = ny + c.1; + let m2 = x * x + y * y; + if m2 > lim2 { + let lz = m2.ln() * 0.5; + let nu = (lz / limit.max(1.0001).ln()).max(1e-9).ln() / (power as f64).ln(); + return Some((i as f64 + 1.0 - nu).max(0.0)); + } + } + None +} + +/// Fractal Set Choice options. +pub(crate) const FRACTAL_SETS: [&str; 6] = + ["Mandelbrot", "Mandelbrot Inverse", "Mandelbrot Over Julia", "Mandelbrot Inverse Over Julia", "Julia", "Julia Inverse"]; + +/// Fractal Palette options. +pub(crate) const FRACTAL_PALETTES: [&str; 4] = ["Lightness Gradient", "Hue Wheel", "Black And White", "Solid Color"]; + +/// Fractal Oversample Method options. +pub(crate) const FRACTAL_OVERSAMPLE: [&str; 2] = ["Edge Detect-Fast-May Miss Pixels", "Brute Force-Slow-Every Pixel"]; + +pub(super) fn fractal(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let set_choice = pr.e("setChoice"); + let power = 2 + pr.e("equation").min(2); + let mc = (pr.f("mandelbrot/mandelbrotX"), pr.f("mandelbrot/mandelbrotY")); + let mmag = pr.f("mandelbrot/mandelbrotMagnification"); + let mlim = pr.f("mandelbrot/mandelbrotEscapeLimit").max(1.01); + let jc = (pr.f("julia/juliaX"), pr.f("julia/juliaY")); + let jmag = pr.f("julia/juliaMagnification"); + let jlim = pr.f("julia/juliaEscapeLimit").max(1.01); + let post = (pr.f("postInversionOffset/postInversionX"), pr.f("postInversionOffset/postInversionY")); + let overlay = pr.b("fractalColor/overlay"); + let transparency = pr.b("fractalColor/transparency"); + let transparent_inside = transparency || overlay; + let palette = pr.e("fractalColor/palette"); + let hue = (pr.f("fractalColor/hue") / 360.0) as f32; + let steps = pr.f("fractalColor/cycleSteps").max(1.0); + let cyc_off = pr.f("fractalColor/cycleOffset") / 360.0; + let edge = pr.b("fractalColor/edgeHighlight"); + let every = pr.e("highQualitySettings/samplingMethod") == 1; + let factor = pr.f("highQualitySettings/samplingFactor").round().clamp(1.0, 9.0) as usize; + let (w, h) = (b.img.width as usize, b.img.height as usize); + let lw = ctx.layer_size[0].max(1.0); + let lh = ctx.layer_size[1].max(1.0); + let inv = 1.0 / b.scale.max(1e-9); + let (off_x, off_y) = (b.offset[0], b.offset[1]); + // FRACTAL_SETS order. + let set = |julia, inverse, over_julia| FracSet { over_julia, julia, inverse }; + let sets: (FracSet, Option) = match set_choice { + 1 => (set(false, true, false), None), + 2 => (set(false, false, true), None), + 3 => (set(false, true, true), None), + 4 => (set(true, false, false), None), + 5 => (set(true, true, false), None), + _ => (set(false, false, false), None), + }; + let iter_for = |mag: f64| (96.0 + 48.0 * mag.max(0.0)).clamp(64.0, 3000.0) as u32; + let eval_set = |s: FracSet, lx: f64, ly: f64| -> Option { + let (center, mag, lim) = if s.julia { (jc, jmag, jlim) } else { (mc, mmag, mlim) }; + let span = 3.5 / 2f64.powf(mag); + let mut u = (lx - lw * 0.5) / lw * span + center.0; + let mut v = (ly - lh * 0.5) / lw * span + center.1; + if s.inverse { + let d = (u * u + v * v).max(1e-12); + u = u / d + post.0; + v = -v / d + post.1; + } + if s.julia { + escape((u, v), mc, power, lim, iter_for(mag)) + } else { + let z0 = if s.over_julia { jc } else { (0.0, 0.0) }; + escape(z0, (u, v), power, lim, iter_for(mag)) + } + }; + let eval = |lx: f64, ly: f64| -> Option { + let a = eval_set(sets.0, lx, ly); + match (a, sets.1) { + (None, Some(s2)) => eval_set(s2, lx, ly), + _ => a, + } + }; + let shade = |n: Option| -> Px { + // Solid Color: only the inside of the set is drawn (Transparency flips the sides). + if palette == 3 { + let (r, g, bl) = hsl_to_rgb(hue, 1.0, 0.5); + return if n.is_none() != transparency { [r, g, bl, 1.0] } else { [0.0; 4] }; + } + let Some(n) = n else { + return if transparent_inside { [0.0; 4] } else { [0.0, 0.0, 0.0, 1.0] }; + }; + let u = n / steps + cyc_off; + let band = (u.rem_euclid(1.0)) as f32; + let (r, g, bl) = match palette { + // Hue Wheel: the full hue circle at full saturation and brightness. + 1 => hsl_to_rgb((hue + band).rem_euclid(1.0), 1.0, 0.5), + // Black And White: alternating bands. + 2 => { + let v = if (u.floor() as i64).rem_euclid(2) == 0 { 0.0 } else { 1.0 }; + (v, v, v) + } + // Lightness Gradient: black → hue → white, the hue moving 45° each cycle. + _ => hsl_to_rgb((hue + u.floor() as f32 * 0.125).rem_euclid(1.0), 0.85, band.clamp(0.0, 1.0)), + }; + let mut c = [r, g, bl]; + if edge && n.fract() < 0.12 { + c = [c[0] * 0.4 + 0.6, c[1] * 0.4 + 0.6, c[2] * 0.4 + 0.6]; + } + [c[0], c[1], c[2], 1.0] + }; + // First pass: one sample per pixel. + let mut first: Vec> = vec![None; w * h]; + first.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { + for (x, v) in row.iter_mut().enumerate() { + *v = eval((x as f64 + 0.5 - off_x) * inv, (y as f64 + 0.5 - off_y) * inv); + } + }); + let src = if overlay { Some(b.img.clone()) } else { None }; + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let here = first[y * w + x]; + let needs_ss = factor > 1 + && (every || { + let mut diff = false; + for (dx, dy) in [(-1i64, 0i64), (1, 0), (0, -1), (0, 1)] { + let (nx, ny) = (x as i64 + dx, y as i64 + dy); + if nx < 0 || ny < 0 || nx >= w as i64 || ny >= h as i64 { + continue; + } + let o = first[ny as usize * w + nx as usize]; + diff |= match (here, o) { + (Some(a), Some(c)) => (a - c).abs() > steps * 0.25, + (None, None) => false, + _ => true, + }; + } + diff + }); + let c = if needs_ss { + let mut acc = [0.0f32; 4]; + for sy in 0..factor { + for sx in 0..factor { + let fx = x as f64 + (sx as f64 + 0.5) / factor as f64; + let fy = y as f64 + (sy as f64 + 0.5) / factor as f64; + let s = shade(eval((fx - off_x) * inv, (fy - off_y) * inv)); + let a = s[3]; + for k in 0..3 { + acc[k] += s[k] * a; + } + acc[3] += a; + } + } + let n = (factor * factor) as f32; + [acc[0] / n, acc[1] / n, acc[2] / n, acc[3] / n] + } else { + let s = shade(here); + [s[0] * s[3], s[1] * s[3], s[2] * s[3], s[3]] + }; + *px = match &src { + Some(o) => over(o.data[y * w + x], c), + None => c, + }; + } + }); + b +} diff --git a/crates/effects/src/generate3/paths.rs b/crates/effects/src/generate3/paths.rs new file mode 100644 index 000000000..9158a274e --- /dev/null +++ b/crates/effects/src/generate3/paths.rs @@ -0,0 +1,565 @@ +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::{ + 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. +pub(crate) fn paint(img: &mut Image, cov: &Plane, color: [f32; 4], opacity: f32, style: u32) { + img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &c)| { + let a = (c * opacity * color[3]).clamp(0.0, 1.0); + let s = [color[0] * a, color[1] * a, color[2] * a, a]; + *px = match style { + 1 => s, + 2 => [px[0] * a, px[1] * a, px[2] * a, px[3] * a], + _ => over(*px, s), + }; + }); +} + +/// Coverage of a plan-drawn generator: round-capped segments rasterised by `raster_segs` +/// with `hardness`, or the union of convex polygons (`raster_convex`). +#[derive(Clone, Debug)] +pub enum Coverage { + Segs { segs: Vec, hardness: f64 }, + Convex(Vec>), +} + +impl Coverage { + fn raster(&self, w: usize, h: usize) -> Plane { + match self { + Coverage::Segs { segs, hardness } => raster_segs(w, h, segs, *hardness), + Coverage::Convex(polys) => raster_convex(w, h, polys), + } + } +} + +/// Stroke, Scribble, Vegas and Write-on, shared with the GPU compositor (effectcraft-gpu +/// `fx_gen2`): the coverage and how it is painted. `style` 0–2 as `paint` (On Original +/// Image, On Transparent, Reveal Original Image), 3 = under the layer (Vegas' Composite Under). +#[derive(Clone, Debug)] +pub struct PaintPlan { + pub cov: Coverage, + pub color: [f32; 4], + pub opacity: f32, + pub style: u32, +} + +/// Draw a [`PaintPlan`]. +fn draw_paint(b: &mut Buf, plan: &PaintPlan) { + let cov = plan.cov.raster(b.img.width as usize, b.img.height as usize); + if plan.style == 3 { + let color = plan.color; + b.img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &c)| { + let a = c * color[3]; + *px = over([color[0] * a, color[1] * a, color[2] * a, a], *px); + }); + } else { + paint(&mut b.img, &cov, plan.color, plan.opacity, plan.style); + } +} + +/// Generators drawn from a plan that need the layer's own pixels (`b.img`) to build it. +pub fn plan_reads_pixels(id: &str, ctx: &EffectCtx) -> bool { + match id { + // Image Contours read the layer itself when no (resolvable) Input Layer is chosen. + "ec.generate.vegas" => ctx.params.e("stroke") == 0, + "ec.generate.paintbucket" | "ec.generate.eyedropperfill" => true, + "ec.generate.advancedlightning" => ctx.params.f("alphaObstacle").clamp(-10.0, 10.0) != 0.0, + _ => false, + } +} + +/// [`PaintPlan`] of Stroke, Scribble, Vegas or Write-on on `b`'s geometry (`b.img` is read +/// only when [`plan_reads_pixels`]). `None`: Scribble without a usable mask (the layer is +/// cleared when it composites On Transparent, else kept). +pub fn paint_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option { + match id { + "ec.generate.stroke" => Some(stroke_plan(ctx, b)), + "ec.generate.scribble" => scribble_plan(ctx, b), + "ec.generate.vegas" => Some(vegas_plan(ctx, b)), + "ec.generate.writeon" => Some(write_on_plan(ctx, b)), + _ => None, + } +} + +/// Scribble's Composite as a `paint` style. +pub fn scribble_style(ctx: &EffectCtx) -> u32 { + match ctx.params.e("composite") { + 0 => 0, + 2 => 2, + _ => 1, + } +} + +pub(super) fn scribble(ctx: &EffectCtx, mut b: Buf) -> Buf { + match scribble_plan(ctx, &b) { + Some(plan) => draw_paint(&mut b, &plan), + None if scribble_style(ctx) == 1 => b.img = Image::new(b.img.width, b.img.height), + None => {} + } + b +} + +fn scribble_plan(ctx: &EffectCtx, b: &Buf) -> Option { + let pr = ctx.params; + let which = pr.e("scribble"); + let polys: Vec<_> = if which == 0 { mask_px(ctx, b, pr.f("mask").round().max(1.0) as usize).into_iter().collect() } else { all_masks_px(ctx, b) }; + let polys: Vec<_> = polys.into_iter().filter(|p| p.0.len() >= 3).map(|(p, c, i)| (p, c, i && which == 2)).collect(); + let style = scribble_style(ctx); + if polys.is_empty() { + return None; + } + let sc = b.scale; + let fill = pr.e("fillType"); + let ew = pr.f("edgeOptions/edgeWidth").max(0.1) * sc; + let ang = pr.f("angle").to_radians(); + let d = [ang.cos(), ang.sin()]; + let n = [-d[1], d[0]]; + let width = (pr.f("strokeWidth") * sc).max(0.1); + let curv = pr.f("strokeOptions/curviness") / 100.0; + let curv_var = pr.f("strokeOptions/curvinessVariation") / 100.0; + let spacing = (pr.f("strokeOptions/spacing") * sc).max(0.75).max(width * 0.25); + let spacing_var = pr.f("strokeOptions/spacingVariation") / 100.0; + let overlap = pr.f("strokeOptions/pathOverlap") / 100.0; + let overlap_var = pr.f("strokeOptions/pathOverlapVariation") / 100.0; + let start = pr.f("start").clamp(0.0, 100.0) / 100.0; + let end = pr.f("end").clamp(0.0, 100.0) / 100.0; + let sequential = pr.b("fillPathsSequentially"); + let wiggle = pr.e("wiggleType"); + let wps = pr.f("wigglesPerSecond").max(0.0); + let seed = pr.f("randomSeed") as i64 as u32 ^ ctx.seed; + let (step, frac) = match wiggle { + 0 => (0u32, 0.0f32), + 1 => ((ctx.time * wps).floor().max(0.0) as u32, 0.0), + _ => { + let u = (ctx.time * wps).max(0.0); + (u.floor() as u32, smoothstep(0.0, 1.0, u.fract() as f32)) + } + }; + let rnd = |i: u32, k: u32| { + let a = hash1(i, k.wrapping_mul(31).wrapping_add(step.wrapping_mul(7919)), seed) * 2.0 - 1.0; + if frac > 0.0 { + let c = hash1(i, k.wrapping_mul(31).wrapping_add((step + 1).wrapping_mul(7919)), seed) * 2.0 - 1.0; + a + (c - a) * frac + } else { + a + } + }; + let groups: Vec, bool, bool)>> = + if which == 0 || sequential { vec![polys.clone()] } else { polys.iter().map(|p| vec![p.clone()]).collect() }; + // Per group a zig-zag polyline (in buffer px). + let mut lines: Vec>> = Vec::new(); + let mut runs = Vec::new(); + for (gi, g) in groups.iter().enumerate() { + let mut nmin = f64::INFINITY; + let mut nmax = f64::NEG_INFINITY; + for (pts, _, _) in g { + for q in pts { + let v = q[0] * n[0] + q[1] * n[1]; + nmin = nmin.min(v); + nmax = nmax.max(v); + } + } + nmin -= ew; + nmax += ew; + // Chains of zig-zags: each run joins the nearest chain that ended on the previous + // scan line (so separate regions get separate strokes). + let mut chains: Vec<(Vec<[f64; 2]>, u32)> = Vec::new(); + let mut s = nmin + spacing * 0.5; + let mut li = 0u32; + let reach = spacing * 3.0 + ew; + while s < nmax && li < 20_000 { + scan_runs(g, fill, ew, d, n, s, &mut runs); + for (ri, &(a, bb)) in runs.iter().enumerate() { + let k = (gi as u32) << 20 | ri as u32; + let ov = spacing * (overlap + overlap_var * rnd(li, k * 4) as f64 * 0.5).max(-0.45); + let ov2 = spacing * (overlap + overlap_var * rnd(li, k * 4 + 1) as f64 * 0.5).max(-0.45); + let (a, bb) = (a - ov, bb + ov2); + if bb <= a { + continue; + } + let at = |t: f64| [d[0] * t + n[0] * s, d[1] * t + n[1] * s]; + let (pa, pb) = (at(a), at(bb)); + let dist = |p: [f64; 2], q: [f64; 2]| ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2)).sqrt(); + let best = chains + .iter() + .enumerate() + .filter(|(_, c)| li > 0 && c.1 == li - 1) + .map(|(i, c)| { + let last = *c.0.last().unwrap_or(&pa); + (i, dist(last, pa).min(dist(last, pb))) + }) + .filter(|&(_, dd)| dd < reach) + .min_by(|x, y| x.1.total_cmp(&y.1)); + match best { + Some((ci, _)) => { + let last = *chains[ci].0.last().unwrap_or(&pa); + let (p0, p1, dir) = if dist(last, pa) <= dist(last, pb) { (pa, pb, -1.0) } else { (pb, pa, 1.0) }; + // Curved turn: an extra point pushed past the edge. + let c = (curv + curv_var * rnd(li, k * 4 + 2) as f64 * 0.5).max(0.0); + let mid = [(last[0] + p0[0]) * 0.5 + d[0] * dir * c * spacing, (last[1] + p0[1]) * 0.5 + d[1] * dir * c * spacing]; + let ch = &mut chains[ci]; + ch.0.extend([mid, p0, p1]); + ch.1 = li; + } + None => chains.push((vec![pa, pb], li)), + } + } + s += spacing * (1.0 + spacing_var * rnd(li, 3) as f64 * 0.5).max(0.25); + li += 1; + } + lines.push(chains.into_iter().map(|(z, _)| if curv > 0.0 { chaikin(&z, 2) } else { z }).collect()); + } + let r = width * 0.5; + // Start / End Apply To: all of a mask's strokes as one path, or each stroke on its own. + let each = pr.e("startEndApplyTo") == 1; + let mut parts: Vec> = Vec::new(); + for group in &lines { + let lens: Vec = group.iter().map(|l| poly_length(l, false)).collect(); + let total: f64 = lens.iter().sum(); + let (g0, g1) = (total * start, total * end); + let mut acc = 0.0; + for (l, len) in group.iter().zip(lens) { + let part = + if each { trim_poly(l, false, len * start, len * end) } else { trim_poly(l, false, (g0 - acc).clamp(0.0, len), (g1 - acc).clamp(0.0, len)) }; + acc += len; + parts.push(part); + } + } + let cap = pr.e("edgeOptions/endCap"); + let join = pr.e("edgeOptions/join"); + let cov = if cap == 0 && join == 0 { + let mut segs = Vec::new(); + for part in &parts { + poly_segs(part, false, r, 1.0, &mut segs); + } + Coverage::Segs { segs, hardness: 0.9 } + } else { + Coverage::Convex(stroke_outline(&parts, r, cap, join, pr.f("edgeOptions/miterLimit").max(1.0))) + }; + Some(PaintPlan { cov, color: pr.color("color"), opacity: (pr.f("opacity") / 100.0) as f32, style }) +} + +// ---------------------------------------------------------------- Stroke + +pub(super) fn stroke(ctx: &EffectCtx, mut b: Buf) -> Buf { + let plan = stroke_plan(ctx, &b); + draw_paint(&mut b, &plan); + b +} + +fn stroke_plan(ctx: &EffectCtx, b: &Buf) -> PaintPlan { + let pr = ctx.params; + let all = pr.b("allMasks"); + let polys: Vec<_> = if all { all_masks_px(ctx, b) } else { mask_px(ctx, b, pr.f("path").round() as usize).into_iter().collect() }; + let style = pr.e("paintStyle"); + let size = (pr.f("brushSize") * b.scale).max(0.1); + let r = size * 0.5; + let hard = pr.f("brushHardness") / 100.0; + let start = pr.f("start").clamp(0.0, 100.0) / 100.0; + let end = pr.f("end").clamp(0.0, 100.0) / 100.0; + let spacing = pr.f("spacing") / 100.0; + let sequential = pr.b("strokeSequentially"); + let lens: Vec = polys.iter().map(|(p, c, _)| poly_length(p, *c)).collect(); + let total: f64 = lens.iter().sum(); + let mut segs = Vec::new(); + let mut acc = 0.0; + for ((pts, closed, _), &len) in polys.iter().zip(&lens) { + let (s0, s1) = if all && sequential { + let (g0, g1) = (total * start, total * end); + ((g0 - acc).clamp(0.0, len), (g1 - acc).clamp(0.0, len)) + } else { + (len * start, len * end) + }; + acc += len; + if s1 <= s0 { + continue; + } + if spacing <= 0.5 { + let part = trim_poly(pts, *closed, s0, s1); + poly_segs(&part, false, r, 1.0, &mut segs); + } else { + let step = (size * spacing).max(0.5); + let mut s = s0; + while s <= s1 + 1e-9 && segs.len() < 200_000 { + let (q, _) = crate::util::poly_point_at(pts, *closed, s); + segs.push(Seg { a: q, b: q, r, v: 1.0 }); + s += step; + } + } + } + PaintPlan { cov: Coverage::Segs { segs, hardness: hard }, color: pr.color("color"), opacity: (pr.f("opacity") / 100.0) as f32, style } +} + +// ---------------------------------------------------------------- Vegas + +/// Marching-squares iso-contours of `pl` at `thr`, chained into polylines (closed flag). +pub(super) fn contours(pl: &Plane, thr: f32) -> Vec<(Vec<[f64; 2]>, bool)> { + use std::collections::HashMap; + let (w, h) = (pl.w, pl.h); + if w < 2 || h < 2 { + return Vec::new(); + } + // Edge key: horizontal edge (x,y)-(x+1,y) = 2*(y*w+x), vertical (x,y)-(x,y+1) = 2*(y*w+x)+1. + let hk = |x: usize, y: usize| 2 * (y * w + x) as u64; + let vk = |x: usize, y: usize| 2 * (y * w + x) as u64 + 1; + let mut pos: HashMap = HashMap::new(); + let mut adj: HashMap> = HashMap::new(); + let v = |x: usize, y: usize| pl.data[y * w + x]; + let interp = |a: f32, b: f32| if (b - a).abs() > 1e-9 { ((thr - a) / (b - a)).clamp(0.0, 1.0) as f64 } else { 0.5 }; + for y in 0..h - 1 { + for x in 0..w - 1 { + let (a, bb, c, d) = (v(x, y), v(x + 1, y), v(x + 1, y + 1), v(x, y + 1)); + let idx = (a > thr) as u8 | ((bb > thr) as u8) << 1 | ((c > thr) as u8) << 2 | ((d > thr) as u8) << 3; + if idx == 0 || idx == 15 { + continue; + } + // Edge points (pixel centres at +0.5). + let top = (hk(x, y), [x as f64 + 0.5 + interp(a, bb), y as f64 + 0.5]); + let right = (vk(x + 1, y), [x as f64 + 1.5, y as f64 + 0.5 + interp(bb, c)]); + let bottom = (hk(x, y + 1), [x as f64 + 0.5 + interp(d, c), y as f64 + 1.5]); + let left = (vk(x, y), [x as f64 + 0.5, y as f64 + 0.5 + interp(a, d)]); + let pairs: &[(usize, usize)] = match idx { + 1 | 14 => &[(3, 0)], + 2 | 13 => &[(0, 1)], + 3 | 12 => &[(3, 1)], + 4 | 11 => &[(1, 2)], + 5 => &[(3, 0), (1, 2)], + 6 | 9 => &[(0, 2)], + 7 | 8 => &[(3, 2)], + 10 => &[(0, 1), (2, 3)], + _ => &[], + }; + let e = [top, right, bottom, left]; + for &(i, j) in pairs { + pos.insert(e[i].0, e[i].1); + pos.insert(e[j].0, e[j].1); + adj.entry(e[i].0).or_default().push(e[j].0); + adj.entry(e[j].0).or_default().push(e[i].0); + } + } + } + let mut keys: Vec = adj.keys().copied().collect(); + keys.sort_unstable(); + // Start open chains at endpoints (degree 1) first, then closed loops. + keys.sort_by_key(|k| adj[k].len() != 1); + let mut used: std::collections::HashSet<(u64, u64)> = std::collections::HashSet::new(); + let mut out = Vec::new(); + for &k0 in &keys { + for &first in &adj[&k0] { + if used.contains(&(k0.min(first), k0.max(first))) { + continue; + } + let mut chain = vec![pos[&k0]]; + let (mut prev, mut cur) = (k0, first); + used.insert((prev.min(cur), prev.max(cur))); + let mut closed = false; + loop { + chain.push(pos[&cur]); + if cur == k0 { + closed = true; + chain.pop(); + break; + } + let next = adj[&cur].iter().copied().find(|&n| n != prev && !used.contains(&(cur.min(n), cur.max(n)))); + match next { + Some(n) => { + used.insert((cur.min(n), cur.max(n))); + prev = cur; + cur = n; + } + None => break, + } + } + if chain.len() >= 2 { + out.push((chain, closed)); + } + } + } + out +} + +pub(super) fn vegas(ctx: &EffectCtx, mut b: Buf) -> Buf { + let plan = vegas_plan(ctx, &b); + draw_paint(&mut b, &plan); + b +} + +/// Sub-samples per Vegas segment: at least 12 (opacity ramp), more for long segments so each straight piece stays +/// short compared with the stroke width and the segment follows rounded corners smoothly. Always even. +pub(super) fn vegas_substeps(each: f64, width: f64) -> usize { + let step = width.max(2.0); + let n = ((each / step).ceil().max(12.0) as usize).min(512); + // Even, so the segment's midpoint (0.5) is always one of the samples. + n + (n & 1) +} + +fn vegas_plan(ctx: &EffectCtx, b: &Buf) -> PaintPlan { + let pr = ctx.params; + let (w, h) = (b.img.width as usize, b.img.height as usize); + let sc = b.scale; + let paths: Vec<(Vec<[f64; 2]>, bool)> = if pr.e("stroke") == 1 { + mask_px(ctx, b, pr.f("path").round() as usize).map(|(p, c, _)| vec![(p, c)]).unwrap_or_default() + } else { + let src = layer_or_self(ctx, b, "inputLayer", true, pr.e("ifLayerSizesDiffer") == 1); + let ch = pr.e("channel"); + let inv = pr.b("invertInput"); + let mut pl = Plane::from_image(&src, |px| { + let (c, a) = unpremul(px); + let v = match ch { + 1 => c[0], + 2 => c[1], + 3 => c[2], + 4 => a, + _ => luminance(c[0], c[1], c[2]) * a, + }; + if inv { 1.0 - v } else { v } + }); + let blur = pr.f("preBlur") * sc; + if blur > 0.05 { + pl = gauss_plane(&pl, blur, blur); + } + let mut cs = contours(&pl, (pr.f("threshold") / 100.0) as f32); + // Simplify by tolerance (drop points closer than tol × size). + let tol = pr.f("tolerance").max(0.0) * (w.max(h) as f64) * 0.1; + if tol > 0.0 { + for (c, _) in cs.iter_mut() { + let mut keep: Vec<[f64; 2]> = Vec::with_capacity(c.len()); + for &q in c.iter() { + if keep.last().is_none_or(|l: &[f64; 2]| ((l[0] - q[0]).powi(2) + (l[1] - q[1]).powi(2)).sqrt() >= tol) { + keep.push(q); + } + } + *c = keep; + } + } + cs.sort_by(|a, b| poly_length(&b.0, b.1).total_cmp(&poly_length(&a.0, a.1))); + if pr.e("renderMode") == 1 { + let sel = (pr.f("selectedContour").round().max(1.0) as usize) - 1; + cs.into_iter().skip(sel).take(1).collect() + } else { + cs + } + }; + let nseg = pr.f("segments").round().clamp(1.0, 1000.0) as usize; + // Shorter Contours Have Fewer Segments: segment count scales with contour length. + let fewer = pr.e("stroke") == 0 && pr.e("shorterContoursHave") == 1; + let longest = paths.iter().map(|(p, c)| poly_length(p, *c)).fold(0.0, f64::max); + let seg_len = pr.f("length").clamp(0.0, 1.0); + let bunched = pr.e("segmentDistribution") == 0; + let rot = pr.f("rotation") / 360.0; + let random_phase = pr.b("randomPhase"); + let seed = pr.f("randomSeed") as i64 as u32 ^ ctx.seed; + let width = (pr.f("width") * sc).max(0.1); + let hard = pr.f("hardness").clamp(0.0, 1.0); + let (o0, om, o1, mp) = (pr.f("startOpacity") as f32, pr.f("midpointOpacity") as f32, pr.f("endOpacity") as f32, pr.f("midpointPosition").clamp(0.01, 0.99)); + let mut segs = Vec::new(); + for (ci, (pts, closed)) in paths.iter().enumerate() { + let len = poly_length(pts, *closed); + if len <= 0.0 { + continue; + } + let phase = rot + if random_phase { hash1(ci as u32, 1, seed) as f64 } else { 0.0 }; + let nseg = if fewer && longest > 0.0 { ((nseg as f64 * len / longest).round() as usize).max(1) } else { nseg }; + let each = len / nseg as f64 * seg_len; + for i in 0..nseg { + let base = if bunched { (i as f64 + (hash1(ci as u32, i as u32 + 7, seed) as f64 - 0.5) * 0.8) / nseg as f64 } else { i as f64 / nseg as f64 }; + let s0 = (base + phase).rem_euclid(1.0) * len; + // Sub-sample the segment so opacity can ramp along it. + let sub = vegas_substeps(each, width); + let mut prev: Option<[f64; 2]> = None; + for k in 0..=sub { + let f = k as f64 / sub as f64; + let mut s = s0 + each * f; + if *closed { + s = s.rem_euclid(len); + } else if s > len { + break; + } + let (q, _) = crate::util::poly_point_at(pts, *closed, s); + let op = if f < mp { o0 + (om - o0) * (f / mp) as f32 } else { om + (o1 - om) * ((f - mp) / (1.0 - mp)) as f32 }; + if let Some(pv) = prev + && ((pv[0] - q[0]).powi(2) + (pv[1] - q[1]).powi(2)) < (each * 0.5).powi(2) + 4.0 + { + segs.push(Seg { a: pv, b: q, r: width * 0.5, v: op.clamp(0.0, 1.0) }); + } + prev = Some(q); + } + } + } + let style = match pr.e("blendMode") { + 1 => 0, + 2 => 3, + 3 => 2, + _ => 1, + }; + PaintPlan { cov: Coverage::Segs { segs, hardness: hard }, color: pr.color("color"), opacity: 1.0, style } +} + +// ---------------------------------------------------------------- Write-on / Glue Gun + +/// Write-on draws a stroke along the animated Brush Position, as in After Effects: a dab every +/// Brush Spacing seconds from the layer's start up to now, each one lasting Stroke Length +/// seconds (0: for ever). The dabs' positions come from the host's parameters at their times; +/// with Paint Time Properties ▸ Opacity or Brush Time Properties ▸ Size each dab keeps its own +/// opacity or size, else the current ones apply to every dab. (Colour and hardness are one per +/// stroke: the current ones.) Without a host (or dabs) the current dab draws. Deterministic. +pub(super) fn write_on(ctx: &EffectCtx, mut b: Buf) -> Buf { + let plan = write_on_plan(ctx, &b); + draw_paint(&mut b, &plan); + b +} + +/// The most dabs one Write-on frame draws; longer strokes lay them further apart, joined. +const WRITE_ON_MAX_DABS: i64 = 4096; + +fn write_on_plan(ctx: &EffectCtx, b: &Buf) -> PaintPlan { + let pr = ctx.params; + let own_opacity = pr.e("paintTimeProperties") == 1; + let own_size = matches!(pr.e("brushTimeProperties"), 1 | 3); + let opacity = |p: &Params| (p.f("brushOpacity") / 100.0).clamp(0.0, 1.0) as f32; + let radius = |p: &Params| (p.f("brushSize") * b.scale * 0.5).max(0.05); + let dab = |p: &Params| { + let q = b.to_px(p.v2("brushPosition")); + ([q.0, q.1], radius(if own_size { p } else { pr }), if own_opacity { opacity(p) } else { 1.0 }) + }; + let mut segs = vec![]; + let t = ctx.time; + let spacing = pr.f("brushSpacing").max(0.001); + let life = pr.f("strokeLength").max(0.0); + if let Some(host) = ctx.env.host + && t.is_finite() + && t >= 0.0 + { + let start = if life > 0.0 { (t - life).max(0.0) } else { 0.0 }; + let k0 = (start / spacing - 1e-9).ceil().max(0.0) as i64; + let k1 = (t / spacing + 1e-9).floor().max(0.0) as i64; + let n = k1.saturating_sub(k0).saturating_add(1); + let stride = (n / WRITE_ON_MAX_DABS).saturating_add(1).max(1); + let mut prev: Option<[f64; 2]> = None; + let mut k = k0; + while k <= k1 { + let tk = k as f64 * spacing; + let at = if (tk - t).abs() < 1e-9 { Some(dab(pr)) } else { host.params_at(tk).map(|p| dab(&p)) }; + if let Some((q, r, v)) = at { + // Dabs laid further apart than the brush spacing are joined into a line. + let a = if stride > 1 { prev.unwrap_or(q) } else { q }; + segs.push(Seg { a, b: q, r, v }); + prev = Some(q); + } + k = k.saturating_add(stride); + } + } + if segs.is_empty() { + let (q, r, v) = dab(pr); + segs.push(Seg { a: q, b: q, r, v }); + } + PaintPlan { + cov: Coverage::Segs { segs, hardness: pr.f("brushHardness") / 100.0 }, + color: pr.color("color"), + opacity: if own_opacity { 1.0 } else { opacity(pr) }, + style: pr.e("paintStyle"), + } +} diff --git a/crates/effects/src/generate3/raster.rs b/crates/effects/src/generate3/raster.rs new file mode 100644 index 000000000..1b85818d0 --- /dev/null +++ b/crates/effects/src/generate3/raster.rs @@ -0,0 +1,368 @@ +use super::{Buf, EffectCtx, IndexedParallelIterator, ParallelIterator, ParallelSliceMut, Plane, Px, smoothstep}; + +// ---------------------------------------------------------------- shared rasterisation + +/// A round-capped line segment in buffer pixels with radius `r` and intensity `v`. +#[derive(Clone, Copy, Debug)] +pub struct Seg { + pub a: [f64; 2], + pub b: [f64; 2], + pub r: f64, + pub v: f32, +} + +/// Rasterise segments into a coverage plane (max-combined). `hardness` (0..1) is the fraction +/// of the radius that is fully opaque; the rest falls off smoothly. +pub(crate) fn raster_segs(w: usize, h: usize, segs: &[Seg], hardness: f64) -> Plane { + let mut out = Plane::new(w, h); + if w == 0 || h == 0 || segs.is_empty() { + return out; + } + let mut buckets: Vec> = vec![Vec::new(); h]; + for (i, s) in segs.iter().enumerate() { + let pad = s.r + 1.0; + let y0 = (s.a[1].min(s.b[1]) - pad).floor().max(0.0) as usize; + let y1 = (s.a[1].max(s.b[1]) + pad).ceil().min(h as f64); + if y1 <= 0.0 || y0 >= h { + continue; + } + for row in buckets.iter_mut().take(y1 as usize).skip(y0) { + row.push(i as u32); + } + } + let hard = hardness.clamp(0.0, 1.0); + out.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + let py = y as f64 + 0.5; + for &i in &buckets[y] { + let s = segs[i as usize]; + let pad = s.r + 1.0; + let x0 = (s.a[0].min(s.b[0]) - pad).floor().max(0.0) as usize; + let x1 = ((s.a[0].max(s.b[0]) + pad).ceil().max(0.0) as usize).min(w); + let (dx, dy) = (s.b[0] - s.a[0], s.b[1] - s.a[1]); + let l2 = dx * dx + dy * dy; + let inner = s.r * hard - 0.5; + let outer = s.r + 0.5; + for (x, o) in row.iter_mut().enumerate().take(x1).skip(x0) { + let px = x as f64 + 0.5; + let t = if l2 > 1e-12 { (((px - s.a[0]) * dx + (py - s.a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 }; + let d = ((px - s.a[0] - dx * t).powi(2) + (py - s.a[1] - dy * t).powi(2)).sqrt(); + if d >= outer { + continue; + } + let c = if d <= inner { 1.0 } else { 1.0 - smoothstep(inner as f32, outer as f32, d as f32) } * s.v; + if c > *o { + *o = c; + } + } + } + }); + out +} + +/// Segments along a polyline (buffer px). +pub fn poly_segs(pts: &[[f64; 2]], closed: bool, r: f64, v: f32, out: &mut Vec) { + let n = pts.len(); + if n == 1 { + out.push(Seg { a: pts[0], b: pts[0], r, v }); + } + if n < 2 { + return; + } + let segs = if closed { n } else { n - 1 }; + for i in 0..segs { + out.push(Seg { a: pts[i], b: pts[(i + 1) % n], r, v }); + } +} + +/// Portion `[s0, s1]` (arc length) of a polyline. +pub(crate) fn trim_poly(pts: &[[f64; 2]], closed: bool, s0: f64, s1: f64) -> Vec<[f64; 2]> { + let n = pts.len(); + let mut out = Vec::new(); + if n < 2 || s1 <= s0 { + return out; + } + let segs = if closed { n } else { n - 1 }; + let mut acc = 0.0; + for i in 0..segs { + let (a, b) = (pts[i], pts[(i + 1) % n]); + let l = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt(); + let (e0, e1) = (acc, acc + l); + acc = e1; + if e1 < s0 || e0 > s1 || l <= 0.0 { + continue; + } + let at = |s: f64| { + let t = ((s - e0) / l).clamp(0.0, 1.0); + [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t] + }; + let p0 = at(s0.max(e0)); + if out.last() != Some(&p0) { + out.push(p0); + } + out.push(at(s1.min(e1))); + } + out +} + +#[inline] +pub(super) fn over(dst: Px, src: Px) -> Px { + let k = 1.0 - src[3]; + [src[0] + dst[0] * k, src[1] + dst[1] * k, src[2] + dst[2] * k, src[3] + dst[3] * k] +} + +/// Mask `idx` (1-based; 0 = none) mapped to buffer pixels. +pub(crate) fn mask_px(ctx: &EffectCtx, b: &Buf, idx: usize) -> Option<(Vec<[f64; 2]>, bool, bool)> { + let m = ctx.env.masks.get(idx.checked_sub(1)?)?; + if m.points.is_empty() { + return None; + } + Some((m.points.iter().map(|&q| [q[0] * b.scale + b.offset[0], q[1] * b.scale + b.offset[1]]).collect(), m.closed, m.inverted)) +} + +/// All masks mapped to buffer pixels. +pub(super) fn all_masks_px(ctx: &EffectCtx, b: &Buf) -> Vec<(Vec<[f64; 2]>, bool, bool)> { + (1..=ctx.env.masks.len()).filter_map(|i| mask_px(ctx, b, i)).collect() +} + +/// Chaikin corner cutting (open polyline, endpoints kept). +pub(super) fn chaikin(pts: &[[f64; 2]], iters: usize) -> Vec<[f64; 2]> { + let mut cur = pts.to_vec(); + for _ in 0..iters { + if cur.len() < 3 { + return cur; + } + let mut nx = Vec::with_capacity(cur.len() * 2); + nx.push(cur[0]); + for w in cur.windows(2) { + let (a, b) = (w[0], w[1]); + nx.push([a[0] * 0.75 + b[0] * 0.25, a[1] * 0.75 + b[1] * 0.25]); + nx.push([a[0] * 0.25 + b[0] * 0.75, a[1] * 0.25 + b[1] * 0.75]); + } + nx.push(*cur.last().unwrap_or(&[0.0; 2])); + cur = nx; + } + cur +} + +// ---------------------------------------------------------------- Scribble + +/// Scan runs (start, end along direction `d`) of the fill region on the line `n·p = s`. +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 { + let m = pts.len(); + if m < 2 { + continue; + } + // Crossings: (t along d, |sin| of crossing angle). + let mut xs: Vec<(f64, f64)> = Vec::new(); + for i in 0..m { + let (a, b) = (pts[i], pts[(i + 1) % m]); + let (na, nb) = (a[0] * n[0] + a[1] * n[1] - s, b[0] * n[0] + b[1] * n[1] - s); + if (na > 0.0) == (nb > 0.0) { + continue; + } + let t = na / (na - nb); + let q = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; + let (ex, ey) = (b[0] - a[0], b[1] - a[1]); + let el = (ex * ex + ey * ey).sqrt().max(1e-9); + let sin = ((ex * n[0] + ey * n[1]) / el).abs().max(0.2); + xs.push((q[0] * d[0] + q[1] * d[1], sin)); + } + xs.sort_by(|a, b| a.0.total_cmp(&b.0)); + // Signed area decides which side is "left". + let area: f64 = (0..m).map(|i| pts[i][0] * pts[(i + 1) % m][1] - pts[(i + 1) % m][0] * pts[i][1]).sum(); + let left_is_inside = area < 0.0; + let mode = match fill { + 4 => { + if left_is_inside { + 2 + } else { + 3 + } + } + 5 => { + if left_is_inside { + 3 + } else { + 2 + } + } + f => f, + }; + let mode = if *inverted && mode == 0 { 6 } else { mode }; + for (k, &(t, sin)) in xs.iter().enumerate() { + let entering = k % 2 == 0; + let l = ew / sin; + match mode { + 0 if entering && k + 1 < xs.len() => { + out.push((t, xs[k + 1].0)); + } + 1 => out.push((t - l * 0.5, t + l * 0.5)), + 2 => out.push(if entering { (t, t + l) } else { (t - l, t) }), + 3 => out.push(if entering { (t - l, t) } else { (t, t + l) }), + _ => {} + } + } + if mode == 6 { + // Inverted Inside: outside of the polygon within the bounds. + let lo = pts.iter().map(|q| q[0] * d[0] + q[1] * d[1]).fold(f64::INFINITY, f64::min) - ew; + let hi = pts.iter().map(|q| q[0] * d[0] + q[1] * d[1]).fold(f64::NEG_INFINITY, f64::max) + ew; + let mut cur = lo; + for pair in xs.chunks(2) { + out.push((cur, pair[0].0)); + cur = pair.get(1).map(|v| v.0).unwrap_or(hi); + } + out.push((cur, hi)); + } + } + out.sort_by(|a, b| a.0.total_cmp(&b.0)); + // Merge overlaps. + let mut merged: Vec<(f64, f64)> = Vec::with_capacity(out.len()); + for &(a, b) in out.iter() { + if let Some(last) = merged.last_mut() + && a <= last.1 + { + last.1 = last.1.max(b); + continue; + } + merged.push((a, b)); + } + *out = merged; +} + +/// End Cap and Join options (Scribble's Edge Options). +pub(super) const END_CAPS: [&str; 3] = ["Round", "Butt", "Projecting"]; + +pub(super) const JOINS: [&str; 3] = ["Round", "Miter", "Bevel"]; + +/// Convex polygons whose union is the stroke of the polylines `lines` with half width `r`: +/// one quad per segment, plus the joins (Round discs, Miter wedges up to `miter_limit` × the +/// half width, Bevel triangles) and the caps (Round discs, Butt none, Projecting square ends). +pub(crate) fn stroke_outline(lines: &[Vec<[f64; 2]>], r: f64, cap: u32, join: u32, miter_limit: f64) -> Vec> { + let disc = |c: [f64; 2]| -> Vec<[f64; 2]> { + (0..16).map(|k| (k as f64 / 16.0 * std::f64::consts::TAU).sin_cos()).map(|(s, co)| [c[0] + co * r, c[1] + s * r]).collect() + }; + let mut out = Vec::new(); + for l in lines { + let pts: Vec<[f64; 2]> = l.iter().copied().fold(Vec::new(), |mut v, p| { + if v.last().is_none_or(|q: &[f64; 2]| (q[0] - p[0]).hypot(q[1] - p[1]) > 1e-9) { + v.push(p); + } + v + }); + if pts.len() < 2 { + if let (Some(p), 0) = (pts.first(), cap) { + out.push(disc(*p)); + } + continue; + } + let dir = |a: [f64; 2], b: [f64; 2]| { + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let l = dx.hypot(dy).max(1e-12); + [dx / l, dy / l] + }; + let n = pts.len(); + for k in 0..n - 1 { + let (mut a, mut b) = (pts[k], pts[k + 1]); + let d = dir(a, b); + // Projecting caps extend the end segments by the half width. + if cap == 2 && k == 0 { + a = [a[0] - d[0] * r, a[1] - d[1] * r]; + } + if cap == 2 && k == n - 2 { + b = [b[0] + d[0] * r, b[1] + d[1] * r]; + } + let nn = [-d[1] * r, d[0] * r]; + out.push(vec![[a[0] + nn[0], a[1] + nn[1]], [b[0] + nn[0], b[1] + nn[1]], [b[0] - nn[0], b[1] - nn[1]], [a[0] - nn[0], a[1] - nn[1]]]); + } + // Joins. + for k in 1..n - 1 { + let (p, d0, d1) = (pts[k], dir(pts[k - 1], pts[k]), dir(pts[k], pts[k + 1])); + let cross = d0[0] * d1[1] - d0[1] * d1[0]; + if cross.abs() < 1e-9 && d0[0] * d1[0] + d0[1] * d1[1] > 0.0 { + continue; + } + if join == 0 { + out.push(disc(p)); + continue; + } + // The outer side of the turn. + let s = if cross > 0.0 { -1.0 } else { 1.0 }; + let o0 = [p[0] - d0[1] * r * s, p[1] + d0[0] * r * s]; + let o1 = [p[0] - d1[1] * r * s, p[1] + d1[0] * r * s]; + let bevel = vec![p, o0, o1]; + if join == 1 { + // Miter point: where the two offset edges meet. + let bis = [d0[0] - d1[0], d0[1] - d1[1]]; + let bl = bis[0].hypot(bis[1]); + let cos_half = ((1.0 + d0[0] * d1[0] + d0[1] * d1[1]) * 0.5).max(0.0).sqrt(); + let len = if cos_half > 1e-6 { r / cos_half } else { f64::INFINITY }; + if bl > 1e-9 && len <= miter_limit * r { + let m = [p[0] + (o0[0] + o1[0] - 2.0 * p[0]) * 0.5, p[1] + (o0[1] + o1[1] - 2.0 * p[1]) * 0.5]; + let ml = (m[0] - p[0]).hypot(m[1] - p[1]).max(1e-12); + let tip = [p[0] + (m[0] - p[0]) / ml * len, p[1] + (m[1] - p[1]) / ml * len]; + out.push(vec![p, o0, tip, o1]); + continue; + } + } + out.push(bevel); + } + if cap == 0 { + out.push(disc(pts[0])); + out.push(disc(pts[n - 1])); + } + } + out +} + +/// Coverage (union, 4×4 samples per pixel) of convex polygons on a `w`×`h` grid. +pub(crate) fn raster_convex(w: usize, h: usize, polys: &[Vec<[f64; 2]>]) -> Plane { + let mut out = Plane::new(w, h); + let inside = |poly: &[[f64; 2]], x: f64, y: f64| { + let n = poly.len(); + let mut sign = 0.0; + for i in 0..n { + let (a, b) = (poly[i], poly[(i + 1) % n]); + let c = (b[0] - a[0]) * (y - a[1]) - (b[1] - a[1]) * (x - a[0]); + if c.abs() < 1e-12 { + continue; + } + if sign == 0.0 { + sign = c.signum(); + } else if c.signum() != sign { + return false; + } + } + true + }; + for poly in polys { + if poly.len() < 3 { + continue; + } + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for p in poly { + x0 = x0.min(p[0]); + y0 = y0.min(p[1]); + x1 = x1.max(p[0]); + y1 = y1.max(p[1]); + } + let (xa, xb) = (x0.floor().max(0.0) as usize, (x1.ceil().max(0.0) as usize).min(w)); + let (ya, yb) = (y0.floor().max(0.0) as usize, (y1.ceil().max(0.0) as usize).min(h)); + for y in ya..yb { + for x in xa..xb { + let mut hit = 0; + for sy in 0..4 { + for sx in 0..4 { + if inside(poly, x as f64 + (sx as f64 + 0.5) / 4.0, y as f64 + (sy as f64 + 0.5) / 4.0) { + hit += 1; + } + } + } + let v = &mut out.data[y * w + x]; + *v = v.max(hit as f32 / 16.0); + } + } + } + out +} diff --git a/crates/effects/src/generate3/registry.rs b/crates/effects/src/generate3/registry.rs new file mode 100644 index 000000000..06c2b35fb --- /dev/null +++ b/crates/effects/src/generate3/registry.rs @@ -0,0 +1,265 @@ +use super::audio::{audio_spectrum, audio_waveform}; +use super::common::{pct, pt, spec}; +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::{EffectSpec, ParamUi, Value, col, num, p, popup, slider}; + +// ---------------------------------------------------------------- registry + +pub fn specs() -> Vec { + let ang = || ParamUi::Angle; + let check = |id: &'static str, name: &'static str, v: bool| p(id, name, Value::Bool(v), ParamUi::Checkbox); + let paint_style = || popup(&["On Original Image", "On Transparent", "Reveal Original Image"]); + let mask_idx = |id: &'static str, name: &'static str, d: f64| p(id, name, num(d), ParamUi::Mask); + let display = || popup(&["Digital", "Analog lines", "Analog dots"]); + vec![ + spec( + "ec.generate.fractal", + "Fractal", + vec![ + p("setChoice", "Set Choice", Value::Enum(0), popup(&FRACTAL_SETS)), + p("equation", "Equation", Value::Enum(0), popup(&["z = z^2 + c", "z = z^3 + c", "z = z^4 + c"])), + p("mandelbrot/mandelbrotX", "X (Real)", num(-0.74), slider(-2.0, 2.0, -2.0, 2.0, 4)), + p("mandelbrot/mandelbrotY", "Y (Imaginary)", num(0.15), slider(-2.0, 2.0, -2.0, 2.0, 4)), + p("mandelbrot/mandelbrotMagnification", "Magnification", num(0.0), slider(-5.0, 30.0, -5.0, 30.0, 2)), + p("mandelbrot/mandelbrotEscapeLimit", "Escape Limit", num(4.0), slider(1.0, 1000.0, 1.0, 100.0, 1)), + p("julia/juliaX", "X (Real)", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)), + p("julia/juliaY", "Y (Imaginary)", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)), + p("julia/juliaMagnification", "Magnification", num(0.0), slider(-5.0, 30.0, -5.0, 30.0, 2)), + p("julia/juliaEscapeLimit", "Escape Limit", num(4.0), slider(1.0, 1000.0, 1.0, 100.0, 1)), + p("postInversionOffset/postInversionX", "X Offset", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)), + p("postInversionOffset/postInversionY", "Y Offset", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)), + check("fractalColor/overlay", "Overlay", false), + check("fractalColor/transparency", "Transparency", false), + p("fractalColor/palette", "Palette", Value::Enum(0), popup(&FRACTAL_PALETTES)), + p("fractalColor/hue", "Hue", num(0.0), ang()), + p("fractalColor/cycleSteps", "Cycle Steps", num(32.0), slider(1.0, 1000.0, 1.0, 256.0, 0)), + p("fractalColor/cycleOffset", "Cycle Offset", num(0.0), ang()), + check("fractalColor/edgeHighlight", "Edge Highlight", false), + p("highQualitySettings/samplingMethod", "Oversample Method", Value::Enum(0), popup(&FRACTAL_OVERSAMPLE)), + p("highQualitySettings/samplingFactor", "Oversample Factor", num(2.0), slider(1.0, 9.0, 1.0, 9.0, 0)), + ], + fractal, + ), + spec( + "ec.generate.scribble", + "Scribble", + vec![ + p("scribble", "Scribble", Value::Enum(0), popup(&["Single Mask", "All Masks", "All Masks Using Modes"])), + mask_idx("mask", "Mask", 1.0), + p("fillType", "Fill Type", Value::Enum(0), popup(&["Inside", "Centered Edge", "Inside Edge", "Outside Edge", "Left Edge", "Right Edge"])), + p("edgeOptions/edgeWidth", "Edge Width", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("edgeOptions/endCap", "End Cap", Value::Enum(0), popup(&END_CAPS)), + p("edgeOptions/join", "Join", Value::Enum(0), popup(&JOINS)), + p("edgeOptions/miterLimit", "Miter Limit", num(4.0), slider(1.0, 100.0, 1.0, 20.0, 1)), + p("color", "Color", col(1.0, 0.0, 0.0), ParamUi::Color), + p("opacity", "Opacity", num(100.0), pct()), + p("angle", "Angle", num(45.0), ang()), + p("strokeWidth", "Stroke Width", num(2.0), slider(0.1, 50.0, 0.1, 50.0, 1)), + p("strokeOptions/curviness", "Curviness", num(50.0), pct()), + p("strokeOptions/curvinessVariation", "Curviness Variation", num(50.0), pct()), + p("strokeOptions/spacing", "Spacing", num(5.0), slider(0.1, 100.0, 0.1, 50.0, 1)), + p("strokeOptions/spacingVariation", "Spacing Variation", num(50.0), pct()), + p("strokeOptions/pathOverlap", "Path Overlap", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("strokeOptions/pathOverlapVariation", "Path Overlap Variation", num(50.0), pct()), + p("start", "Start", num(0.0), pct()), + p("end", "End", num(100.0), pct()), + p("startEndApplyTo", "Start/End Apply To", Value::Enum(0), popup(&["All Strokes", "Each Stroke"])), + check("fillPathsSequentially", "Fill Paths Sequentially", true), + p("wiggleType", "Wiggle Type", Value::Enum(1), popup(&["Static", "Jumpy", "Smooth"])), + p("wigglesPerSecond", "Wiggles/Second", num(5.0), slider(0.0, 100.0, 0.0, 30.0, 2)), + p("randomSeed", "Random Seed", num(1.0), slider(0.0, 10000.0, 0.0, 100.0, 0)), + p("composite", "Composite", Value::Enum(1), popup(&["On Original Image", "On Transparent", "Reveal Original Image"])), + ], + scribble, + ), + spec( + "ec.generate.stroke", + "Stroke", + vec![ + mask_idx("path", "Path", 1.0), + check("allMasks", "All Masks", false), + check("strokeSequentially", "Stroke Sequentially", true), + p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("brushSize", "Brush Size", num(2.0), slider(0.0, 50.0, 0.0, 50.0, 1)), + p("brushHardness", "Brush Hardness", num(75.0), pct()), + p("opacity", "Opacity", num(100.0), pct()), + p("start", "Start", num(0.0), pct()), + p("end", "End", num(100.0), pct()), + p("spacing", "Spacing", num(15.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("paintStyle", "Paint Style", Value::Enum(0), paint_style()), + ], + stroke, + ), + spec( + "ec.generate.vegas", + "Vegas", + vec![ + p("stroke", "Stroke", Value::Enum(0), popup(&["Image Contours", "Mask/Path"])), + p("inputLayer", "Image Contours: Input Layer", Value::Layer(None), ParamUi::Layer), + check("invertInput", "Image Contours: Invert Input", false), + p("ifLayerSizesDiffer", "Image Contours: If Layer Sizes Differ", Value::Enum(0), popup(&["Center", "Stretch to Fit"])), + p("channel", "Image Contours: Channel", Value::Enum(0), popup(&["Intensity", "Red", "Green", "Blue", "Alpha"])), + p("threshold", "Image Contours: Threshold", num(50.0), pct()), + p("preBlur", "Image Contours: Pre-Blur", num(1.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("tolerance", "Image Contours: Tolerance", num(0.01), slider(0.0, 1.0, 0.0, 0.1, 3)), + p("renderMode", "Image Contours: Render", Value::Enum(0), popup(&["All Contours", "Selected Contour"])), + p("selectedContour", "Image Contours: Selected Contour", num(1.0), slider(1.0, 1000.0, 1.0, 20.0, 0)), + p("shorterContoursHave", "Image Contours: Shorter Contours Have", Value::Enum(0), popup(&["Same Number of Segments", "Fewer Segments"])), + mask_idx("path", "Mask/Path: Path", 1.0), + p("segments", "Segments: Segments", num(32.0), slider(1.0, 1000.0, 1.0, 100.0, 0)), + p("length", "Segments: Length", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("segmentDistribution", "Segments: Segment Distribution", Value::Enum(0), popup(&["Bunched", "Even"])), + p("rotation", "Segments: Rotation", num(0.0), ang()), + check("randomPhase", "Segments: Random Phase", false), + p("randomSeed", "Segments: Random Seed", num(1.0), slider(1.0, 10000.0, 1.0, 100.0, 0)), + p("blendMode", "Rendering: Blend Mode", Value::Enum(1), popup(&["Transparent", "Over", "Under", "Stencil"])), + p("color", "Rendering: Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("width", "Rendering: Width", num(2.0), slider(0.0, 100.0, 0.0, 20.0, 2)), + p("hardness", "Rendering: Hardness", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("startOpacity", "Rendering: Start Opacity", num(1.0), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("midpointOpacity", "Rendering: Mid-point Opacity", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("midpointPosition", "Rendering: Mid-point Position", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("endOpacity", "Rendering: End Opacity", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), + ], + vegas, + ), + spec( + "ec.generate.writeon", + "Write-on", + vec![ + p("brushPosition", "Brush Position", pt(0.5, 0.5), ParamUi::Point), + p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("brushSize", "Brush Size", num(6.0), slider(0.0, 50.0, 0.0, 50.0, 1)), + p("brushHardness", "Brush Hardness", num(75.0), pct()), + p("brushOpacity", "Brush Opacity", num(100.0), pct()), + p("strokeLength", "Stroke Length (secs)", num(0.0), slider(0.0, 30.0, 0.0, 30.0, 1)), + p("brushSpacing", "Brush Spacing (secs)", num(0.001), slider(0.001, 5.0, 0.001, 5.0, 3)), + p("paintTimeProperties", "Paint Time Properties", Value::Enum(0), popup(&["None", "Opacity", "Color"])), + p("brushTimeProperties", "Brush Time Properties", Value::Enum(0), popup(&["None", "Size", "Hardness", "Size & Hardness"])), + p("paintStyle", "Paint Style", Value::Enum(0), paint_style()), + ], + write_on, + ), + spec( + "ec.generate.paintbucket", + "Paint Bucket", + vec![ + p("fillPoint", "Fill Point", pt(0.5, 0.5), ParamUi::Point), + p("fillSelector", "Fill Selector", Value::Enum(0), popup(&["Color & Alpha", "Straight Color", "Transparency", "Opacity", "Alpha Channel"])), + p("tolerance", "Tolerance", num(10.0), slider(0.0, 255.0, 0.0, 100.0, 1)), + check("viewThreshold", "View Threshold", false), + p("stroke", "Stroke", Value::Enum(0), popup(&["Antialias", "Feather", "Spread", "Choke", "Stroke"])), + p("featherSoftness", "Feather Softness", num(10.0), slider(0.0, 100.0, 0.0, 50.0, 1)), + p("spreadRadius", "Spread Radius", num(3.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("strokeWidth", "Stroke Width", num(2.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("color", "Color", col(1.0, 0.0, 0.0), ParamUi::Color), + p("opacity", "Opacity", num(100.0), pct()), + p( + "blendingMode", + "Blending Mode", + Value::Enum(0), + popup(&["Normal", "Add", "Multiply", "Screen", "Hue", "Saturation", "Color", "Luminosity", "Behind", "Fill Only"]), + ), + ], + paint_bucket, + ), + spec( + "ec.generate.eyedropperfill", + "Eyedropper Fill", + vec![ + p("samplePoint", "Sample Point", pt(0.5, 0.5), ParamUi::Point), + p("sampleRadius", "Sample Radius", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("averagePixelColors", "Average Pixel Colors", Value::Enum(0), popup(&["Skip Empty", "All", "All Premultiplied", "Including Alpha"])), + check("maintainOriginalAlpha", "Maintain Original Alpha", false), + p("blendWithOriginal", "Blend With Original", num(0.0), pct()), + ], + eyedropper_fill, + ), + spec( + "ec.generate.ccgluegun", + "CC Glue Gun", + vec![ + p("brushPosition", "Brush Position", pt(0.5, 0.5), ParamUi::Point), + p("strokeWidth", "Stroke Width", num(25.0), slider(0.0, 500.0, 0.0, 100.0, 1)), + p("density", "Density", num(5.0), slider(0.0, 32.0, 0.0, 32.0, 1)), + p("timeSpan", "Time Span (sec)", num(1.0), slider(0.0, 60.0, 0.0, 10.0, 2)), + p("reflection", "Reflection", num(45.0), ang()), + p("strength", "Strength", num(100.0), pct()), + p("style", "Style", Value::Enum(0), popup(&["Over Layer", "Glue Only"])), + ], + glue_gun, + ), + spec( + "ec.generate.ccthreads", + "CC Threads", + vec![ + p("width", "Width", num(50.0), slider(1.0, 1000.0, 1.0, 200.0, 1)), + p("height", "Height", num(50.0), slider(1.0, 1000.0, 1.0, 200.0, 1)), + p("overlaps", "Overlaps", num(1.0), slider(1.0, 20.0, 1.0, 10.0, 0)), + p("direction", "Direction", num(0.0), ang()), + p("center", "Center", pt(0.5, 0.5), ParamUi::Point), + p("coverage", "Coverage", num(80.0), pct()), + p("shadowing", "Shadowing", num(50.0), pct()), + p("texture", "Texture", num(10.0), pct()), + ], + threads, + ), + spec( + "ec.generate.audiospectrum", + "Audio Spectrum", + vec![ + p("audioLayer", "Audio Layer", Value::Layer(None), ParamUi::Layer), + p("startPoint", "Start Point", pt(0.1, 0.5), ParamUi::Point), + p("endPoint", "End Point", pt(0.9, 0.5), ParamUi::Point), + mask_idx("path", "Path", 0.0), + check("usePolarPath", "Use Polar Path", false), + p("startFrequency", "Start Frequency", num(20.0), slider(1.0, 20000.0, 1.0, 2000.0, 0)), + p("endFrequency", "End Frequency", num(2000.0), slider(1.0, 20000.0, 1.0, 10000.0, 0)), + p("frequencyBands", "Frequency bands", num(64.0), slider(1.0, 4096.0, 1.0, 256.0, 0)), + p("maximumHeight", "Maximum Height", num(50.0), slider(0.0, 10000.0, 0.0, 500.0, 1)), + p("audioDuration", "Audio Duration (milliseconds)", num(90.0), slider(1.0, 30000.0, 1.0, 1000.0, 1)), + p("audioOffset", "Audio Offset (milliseconds)", num(0.0), slider(-30000.0, 30000.0, -1000.0, 1000.0, 1)), + p("thickness", "Thickness", num(3.0), slider(0.0, 1000.0, 0.0, 50.0, 2)), + p("softness", "Softness", num(50.0), pct()), + p("insideColor", "Inside Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("outsideColor", "Outside Color", col(0.2, 0.4, 1.0), ParamUi::Color), + check("blendOverlappingColors", "Blend Overlapping Colors", true), + p("hueInterpolation", "Hue Interpolation", num(0.0), ang()), + check("dynamicHuePhase", "Dynamic Hue Phase", false), + check("colorSymmetry", "Color Symmetry", false), + p("displayOptions", "Display Options", Value::Enum(0), display()), + p("sideOptions", "Side Options", Value::Enum(0), popup(&["Side A", "Side B", "Side A & B"])), + check("durationAveraging", "Duration Averaging", false), + check("compositeOnOriginal", "Composite On Original", false), + ], + audio_spectrum, + ), + spec( + "ec.generate.audiowaveform", + "Audio Waveform", + vec![ + p("audioLayer", "Audio Layer", Value::Layer(None), ParamUi::Layer), + p("startPoint", "Start Point", pt(0.1, 0.5), ParamUi::Point), + p("endPoint", "End Point", pt(0.9, 0.5), ParamUi::Point), + mask_idx("path", "Path", 0.0), + check("usePolarPath", "Use Polar Path", false), + p("displayedSamples", "Displayed Samples", num(200.0), slider(2.0, 4096.0, 2.0, 1000.0, 0)), + p("maximumHeight", "Maximum Height", num(100.0), slider(0.0, 10000.0, 0.0, 500.0, 1)), + p("audioDuration", "Audio Duration (milliseconds)", num(200.0), slider(1.0, 30000.0, 1.0, 1000.0, 1)), + p("audioOffset", "Audio Offset (milliseconds)", num(0.0), slider(-30000.0, 30000.0, -1000.0, 1000.0, 1)), + p("thickness", "Thickness", num(2.0), slider(0.0, 1000.0, 0.0, 50.0, 2)), + p("softness", "Softness", num(10.0), pct()), + p("randomSeed", "Random Seed (Analog)", num(1.0), slider(1.0, 10000.0, 1.0, 100.0, 0)), + p("insideColor", "Inside Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("outsideColor", "Outside Color", col(0.2, 0.4, 1.0), ParamUi::Color), + p("waveformOptions", "Waveform Options", Value::Enum(0), popup(&["Mono", "Left", "Right"])), + p("displayOptions", "Display Options", Value::Enum(1), display()), + check("compositeOnOriginal", "Composite On Original", false), + ], + audio_waveform, + ), + ] +} diff --git a/crates/effects/src/generate3/tests.rs b/crates/effects/src/generate3/tests.rs new file mode 100644 index 000000000..deb0d7dda --- /dev/null +++ b/crates/effects/src/generate3/tests.rs @@ -0,0 +1,439 @@ +use super::*; +use crate::{EffectEnv, EffectHost, LayerPixels, MaskShape, Params}; + +/// Run with point defaults scaled to the layer size. +pub(crate) fn run_env(id: &str, set: &[(&str, Value)], img: Image, time: f64, env: EffectEnv) -> Buf { + let s = crate::find(id).unwrap(); + let ls = [img.width as f64, img.height as f64]; + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for p in &s.params { + if let (ParamUi::Point, Value::Vec2(v)) = (&p.ui, &p.default) { + params.values.insert(p.id.to_string(), Value::Vec2([v[0] * ls[0], v[1] * ls[1]])); + } + } + for (k, v) in set { + assert!(params.values.contains_key(*k), "{id}: unknown {k}"); + params.values.insert(k.to_string(), v.clone()); + } + let ctx = EffectCtx { params: ¶ms, time, layer_size: ls, seed: 3, adjustment: false, env }; + crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }) +} + +fn run(id: &str, set: &[(&str, Value)], img: Image) -> Buf { + run_env(id, set, img, 0.5, EffectEnv::default()) +} + +fn square_mask() -> Vec { + vec![MaskShape { name: "Mask 1".into(), points: vec![[16.0, 16.0], [48.0, 16.0], [48.0, 48.0], [16.0, 48.0]], closed: true, inverted: false }] +} + +fn alpha_sum(img: &Image, x0: usize, y0: usize, x1: usize, y1: usize) -> f32 { + let mut s = 0.0; + for y in y0..y1 { + for x in x0..x1 { + s += img.data[y * img.width as usize + x][3]; + } + } + s +} + +#[test] +fn audio_spectrum_linear_frequency_and_linear_amplitude() { + let host = Tone; // Tone produces 440 Hz + let env = EffectEnv { host: Some(&host), comp_time: 1.0, ..Default::default() }; + let img = Image::new(200, 100); + let out = run_env( + "ec.generate.audiospectrum", + &[ + ("audioLayer", Value::Layer(Some(1))), + ("startPoint", Value::Vec2([0.0, 50.0])), + ("endPoint", Value::Vec2([200.0, 50.0])), + ("startFrequency", num(20.0)), + ("endFrequency", num(2000.0)), + ("frequencyBands", num(100.0)), + ("maximumHeight", num(40.0)), + ("thickness", num(2.0)), + ("softness", num(0.0)), + ], + img, + 1.0, + env, + ); + // Verify 440 Hz tone produces a peak above baseline, while high frequencies have zero bar height + let peak_zone = alpha_sum(&out.img, 36, 10, 48, 48); + let high_zone = alpha_sum(&out.img, 120, 10, 150, 48); + let silent_zone = alpha_sum(&out.img, 170, 10, 195, 48); + assert!(peak_zone > 5.0, "peak zone has energy: {peak_zone}"); + assert!(peak_zone > high_zone * 2.0, "linear peak is distinctly taller than far frequencies: {peak_zone} vs {high_zone}"); + assert_eq!(silent_zone, 0.0, "silent end has zero height, not boosted by dB scale"); +} + +#[test] +fn fractal_deterministic_and_has_inside_set() { + let img = Image::new(64, 48); + let a = run("ec.generate.fractal", &[], img.clone()); + let b = run("ec.generate.fractal", &[], img.clone()); + assert_eq!(a.img.data, b.img.data); + // The default view contains the main cardioid (black, opaque) and escaped (coloured) points. + assert!(a.img.data.iter().any(|p| p[3] > 0.99 && p[0] + p[1] + p[2] < 0.01)); + assert!(a.img.data.iter().any(|p| p[0] + p[1] + p[2] > 0.3)); + let t = run("ec.generate.fractal", &[("fractalColor/transparency", Value::Bool(true))], img); + assert!(t.img.data.iter().any(|p| p[3] == 0.0)); +} + +#[test] +fn fractal_palettes_and_set_choices() { + let img = Image::new(64, 48); + // Solid Color: only the inside of the set is drawn; Transparency flips it. + let solid = run("ec.generate.fractal", &[("fractalColor/palette", Value::Enum(3))], img.clone()); + assert!(solid.img.data.iter().any(|p| p[3] == 0.0) && solid.img.data.iter().any(|p| p[3] > 0.99 && p[0] > 0.9)); + let flipped = run("ec.generate.fractal", &[("fractalColor/palette", Value::Enum(3)), ("fractalColor/transparency", Value::Bool(true))], img.clone()); + for (a, b) in solid.img.data.iter().zip(&flipped.img.data) { + assert!((a[3] > 0.5) != (b[3] > 0.5) || (a[3] - 0.5).abs() < 0.49); + } + // Black And White: escaped pixels are pure black or white (away from supersampled edges). + let bw = run("ec.generate.fractal", &[("fractalColor/palette", Value::Enum(2)), ("highQualitySettings/samplingFactor", num(1.0))], img.clone()); + assert!(bw.img.data.iter().all(|p| p[0] == p[1] && p[1] == p[2] && (p[0] == 0.0 || p[0] == 1.0))); + // Mandelbrot Over Julia depends on the Julia centre. + let o1 = run("ec.generate.fractal", &[("setChoice", Value::Enum(2)), ("julia/juliaX", num(0.3))], img.clone()); + let o2 = run("ec.generate.fractal", &[("setChoice", Value::Enum(2)), ("julia/juliaX", num(-0.2))], img.clone()); + assert_ne!(o1.img.data, o2.img.data); + let plain = run("ec.generate.fractal", &[("julia/juliaX", num(0.3))], img); + assert_ne!(o1.img.data, plain.img.data); +} + +#[test] +fn scribble_fills_mask_and_animates() { + let masks = square_mask(); + let env = EffectEnv { masks: &masks, ..Default::default() }; + let img = Image::filled(64, 64, [0.0, 0.0, 1.0, 1.0]); + let a = run_env("ec.generate.scribble", &[], img.clone(), 0.5, env); + let b = run_env("ec.generate.scribble", &[], img.clone(), 0.5, env); + assert_eq!(a.img.data, b.img.data); + let inside = alpha_sum(&a.img, 18, 18, 46, 46); + let outside = alpha_sum(&a.img, 0, 0, 10, 64); + assert!(inside > 100.0 && outside < 1.0, "{inside} {outside}"); + let c = run_env("ec.generate.scribble", &[], img.clone(), 1.5, env); + assert_ne!(a.img.data, c.img.data, "jumpy wiggle changes over time"); + let s = run_env("ec.generate.scribble", &[("wiggleType", Value::Enum(0))], img.clone(), 0.5, env); + let s2 = run_env("ec.generate.scribble", &[("wiggleType", Value::Enum(0))], img.clone(), 3.5, env); + assert_eq!(s.img.data, s2.img.data, "static wiggle is constant"); + let half = run_env("ec.generate.scribble", &[("end", num(50.0))], img, 0.5, env); + assert!(alpha_sum(&half.img, 0, 0, 64, 64) < alpha_sum(&a.img, 0, 0, 64, 64)); +} + +#[test] +fn scribble_edge_types() { + let masks = square_mask(); + let env = EffectEnv { masks: &masks, ..Default::default() }; + let img = Image::new(64, 64); + let o = run_env("ec.generate.scribble", &[("fillType", Value::Enum(3)), ("edgeOptions/edgeWidth", num(6.0))], img, 0.5, env); + // Outside edge: paint near the border outside, nothing in the centre. + assert!(alpha_sum(&o.img, 26, 26, 38, 38) < 0.5); + assert!(alpha_sum(&o.img, 8, 8, 16, 56) > 5.0); +} + +#[test] +fn stroke_draws_along_mask() { + let masks = square_mask(); + let env = EffectEnv { masks: &masks, ..Default::default() }; + let img = Image::new(64, 64); + let a = run_env("ec.generate.stroke", &[("brushSize", num(4.0)), ("paintStyle", Value::Enum(1))], img.clone(), 0.0, env); + assert!(a.img.get(32, 16)[3] > 0.9, "on the top edge"); + assert!(a.img.get(32, 32)[3] < 0.01, "centre empty"); + let b = run_env("ec.generate.stroke", &[("brushSize", num(4.0)), ("end", num(25.0))], img.clone(), 0.0, env); + assert!(b.img.get(32, 16)[3] > 0.9 && b.img.get(48, 32)[3] < 0.01, "first quarter only"); + let d = run_env("ec.generate.stroke", &[("brushSize", num(4.0)), ("spacing", num(300.0))], img, 0.0, env); + assert!(alpha_sum(&d.img, 0, 0, 64, 64) < alpha_sum(&a.img, 0, 0, 64, 64), "spaced dabs"); + // No masks: unchanged. + let src = Image::filled(8, 8, [0.2, 0.3, 0.4, 1.0]); + assert_eq!(run("ec.generate.stroke", &[], src.clone()).img.data, src.data); +} + +#[test] +fn vegas_traces_contours() { + let mut img = Image::new(64, 64); + for y in 16..48 { + for x in 16..48 { + img.set(x, y, [1.0, 1.0, 1.0, 1.0]); + } + } + let a = + run("ec.generate.vegas", &[("blendMode", Value::Enum(0)), ("length", num(1.0)), ("midpointOpacity", num(1.0)), ("endOpacity", num(1.0))], img.clone()); + let b = + run("ec.generate.vegas", &[("blendMode", Value::Enum(0)), ("length", num(1.0)), ("midpointOpacity", num(1.0)), ("endOpacity", num(1.0))], img.clone()); + assert_eq!(a.img.data, b.img.data); + assert!(alpha_sum(&a.img, 14, 14, 50, 50) > 50.0, "edges drawn"); + assert!(alpha_sum(&a.img, 26, 26, 38, 38) < 0.5, "interior untouched"); + let r = run("ec.generate.vegas", &[("blendMode", Value::Enum(0)), ("rotation", num(90.0))], img); + assert_ne!(r.img.data, a.img.data); +} + +#[test] +fn contours_of_square_are_closed() { + let mut pl = Plane::new(20, 20); + for y in 5..15 { + for x in 5..15 { + pl.data[y * 20 + x] = 1.0; + } + } + let c = contours(&pl, 0.5); + assert_eq!(c.len(), 1); + assert!(c[0].1); + let l = poly_length(&c[0].0, true); + assert!((l - 40.0).abs() < 4.0, "{l}"); +} + +#[test] +fn write_on_and_glue_gun_draw_at_brush() { + let img = Image::new(32, 32); + let a = run("ec.generate.writeon", &[], img.clone()); + assert!(a.img.get(16, 16)[3] > 0.9 && a.img.get(2, 2)[3] == 0.0); + assert_eq!(a.img.data, run("ec.generate.writeon", &[], img.clone()).img.data); + let src = Image::filled(32, 32, [0.5, 0.2, 0.1, 1.0]); + let g = run("ec.generate.ccgluegun", &[], src.clone()); + assert_eq!(g.img.data, run("ec.generate.ccgluegun", &[], src.clone()).img.data); + assert_ne!(g.img.get(16, 16), src.get(16, 16)); + assert_eq!(g.img.get(0, 0), src.get(0, 0)); +} + +/// #536: an animated Brush Position draws a stroke that stays (Stroke Length 0), expires +/// after Stroke Length, and lays its dabs Brush Spacing seconds apart. +#[test] +fn write_on_accumulates_a_stroke_along_the_animated_brush() { + // Brush Position from [8, 16] at 0 s to [56, 16] at 1 s; red, hard, 6 px. + struct Brush(Vec<(&'static str, Value)>); + impl Brush { + fn at(&self, t: f64) -> Vec<(&'static str, Value)> { + let mut v = self.0.clone(); + v.push(("brushPosition", Value::Vec2([8.0 + 48.0 * t.clamp(0.0, 1.0), 16.0]))); + v + } + } + impl EffectHost for Brush { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn params_at(&self, t: f64) -> Option { + let s = crate::find("ec.generate.writeon").unwrap(); + let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in self.at(t) { + p.values.insert(k.to_string(), v); + } + Some(p) + } + } + let render = |extra: &[(&'static str, Value)], t: f64| { + let mut set = vec![("color", col(1.0, 0.0, 0.0)), ("brushSize", num(6.0)), ("brushHardness", num(100.0)), ("brushSpacing", num(0.05))]; + set.extend(extra.iter().cloned()); + let host = Brush(set); + let env = EffectEnv { host: Some(&host), ..Default::default() }; + let at = host.at(t); + run_env("ec.generate.writeon", &at, Image::new(64, 32), t, env).img + }; + let on = |img: &Image, x: i64| img.get(x, 16)[3] > 0.9; + let half = render(&[], 0.5); + assert!(on(&half, 8) && on(&half, 20) && on(&half, 32) && !on(&half, 44) && !on(&half, 56)); + let end = render(&[], 1.0); + assert!([8, 20, 32, 44, 56].iter().all(|x| on(&end, *x))); + // Stroke Length 0.25 s: only the last quarter second's trail. + let trail = render(&[("strokeLength", num(0.25))], 1.0); + assert!(!on(&trail, 8) && !on(&trail, 32) && on(&trail, 48) && on(&trail, 56)); + // Brush Spacing 0.5 s: three separate marks. + let spaced = render(&[("brushSpacing", num(0.5))], 1.0); + assert!(on(&spaced, 8) && on(&spaced, 32) && on(&spaced, 56) && !on(&spaced, 20) && !on(&spaced, 44)); + // Paint Time Properties ▸ Opacity: each dab keeps the opacity it was laid with. + struct Fade; + impl EffectHost for Fade { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn params_at(&self, t: f64) -> Option { + let mut p = Brush(vec![("brushHardness", num(100.0)), ("brushSpacing", num(0.05)), ("paintTimeProperties", Value::Enum(1))]).params_at(t)?; + p.values.insert("brushOpacity".into(), num(if t < 0.5 { 100.0 } else { 20.0 })); + Some(p) + } + } + let p1 = Fade.params_at(1.0).unwrap(); + let set: Vec<(&str, Value)> = p1.values.iter().map(|(k, v)| (k.as_str(), v.clone())).collect(); + let faded = run_env("ec.generate.writeon", &set, Image::new(64, 32), 1.0, EffectEnv { host: Some(&Fade), ..Default::default() }).img; + assert!(faded.get(8, 16)[3] > 0.9 && (faded.get(56, 16)[3] - 0.2).abs() < 0.05, "{:?} {:?}", faded.get(8, 16), faded.get(56, 16)); +} + +#[test] +fn paint_bucket_fills_enclosed_region_only() { + let mut img = Image::filled(32, 32, [1.0, 1.0, 1.0, 1.0]); + for i in 8..24 { + for &(x, y) in &[(i, 8), (i, 23), (8, i), (23, i)] { + img.set(x, y, [0.0, 0.0, 0.0, 1.0]); + } + } + let out = run("ec.generate.paintbucket", &[], img.clone()); + let inside = out.img.get(16, 16); + assert!(inside[0] > 0.99 && inside[1] < 0.01, "{inside:?}"); + assert_eq!(out.img.get(2, 2), [1.0, 1.0, 1.0, 1.0], "outside untouched"); + assert_eq!(out.img.get(8, 16), [0.0, 0.0, 0.0, 1.0], "border untouched"); + assert_eq!(out.img.data, run("ec.generate.paintbucket", &[], img).img.data); +} + +#[test] +fn eyedropper_fill_uses_sample() { + let mut img = Image::filled(16, 16, [0.0, 0.0, 1.0, 1.0]); + img.set(8, 8, [1.0, 0.0, 0.0, 1.0]); + let out = run("ec.generate.eyedropperfill", &[], img.clone()); + assert!(out.img.data.iter().all(|p| *p == [1.0, 0.0, 0.0, 1.0])); + let blended = run("ec.generate.eyedropperfill", &[("blendWithOriginal", num(100.0))], img.clone()); + assert_eq!(blended.img.data, img.data); +} + +#[test] +fn threads_weave_is_deterministic_and_gappy() { + let src = Image::filled(64, 64, [0.4, 0.6, 0.2, 1.0]); + let a = run("ec.generate.ccthreads", &[("width", num(16.0)), ("height", num(16.0)), ("coverage", num(50.0))], src.clone()); + assert_eq!(a.img.data, run("ec.generate.ccthreads", &[("width", num(16.0)), ("height", num(16.0)), ("coverage", num(50.0))], src).img.data); + assert!(a.img.data.iter().any(|p| p[3] == 0.0), "gaps between threads"); + assert!(a.img.data.iter().any(|p| p[3] > 0.99), "threads"); +} + +struct Tone; +impl EffectHost for Tone { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, start: f64, frames: usize, rate: u32) -> Option> { + Some( + (0..frames) + .flat_map(|i| { + let t = start + i as f64 / rate as f64; + let v = (2.0 * PI * 440.0 * t).sin() as f32 * 0.8; + [v, v] + }) + .collect(), + ) + } +} + +#[test] +fn audio_effects_without_audio_pass_through() { + let src = Image::filled(16, 16, [0.3, 0.3, 0.3, 1.0]); + for id in ["ec.generate.audiospectrum", "ec.generate.audiowaveform"] { + assert_eq!(run(id, &[], src.clone()).img.data, src.data, "{id}"); + } +} + +#[test] +fn audio_effects_draw_with_host() { + let host = Tone; + let env = EffectEnv { host: Some(&host), comp_time: 1.0, ..Default::default() }; + let img = Image::new(96, 64); + for id in ["ec.generate.audiospectrum", "ec.generate.audiowaveform"] { + let a = run_env(id, &[("audioLayer", Value::Layer(Some(1)))], img.clone(), 1.0, env); + let b = run_env(id, &[("audioLayer", Value::Layer(Some(1)))], img.clone(), 1.0, env); + assert_eq!(a.img.data, b.img.data, "{id}"); + assert!(alpha_sum(&a.img, 0, 0, 96, 64) > 10.0, "{id} drew nothing"); + } +} + +#[test] +fn fft_finds_tone() { + let n = 256; + let mut re: Vec = (0..n).map(|i| (2.0 * PI * 8.0 * i as f64 / n as f64).cos()).collect(); + let mut im = vec![0.0; n]; + fft(&mut re, &mut im); + let mags: Vec = (0..n / 2).map(|k| (re[k] * re[k] + im[k] * im[k]).sqrt()).collect(); + let peak = mags.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1)).unwrap().0; + assert_eq!(peak, 8); +} + +#[test] +fn stroke_outline_caps_and_joins() { + let line = vec![vec![[10.0, 10.0], [30.0, 10.0]]]; + let cov = |cap: u32| raster_convex(40, 20, &stroke_outline(&line, 3.0, cap, 0, 4.0)); + // Round caps reach 3 px past the ends (on the axis), Butt stops there, Projecting is square. + let (round, butt, proj) = (cov(0), cov(1), cov(2)); + assert!(round.get(8, 10) > 0.9 && butt.get(8, 10) < 0.1 && proj.get(8, 10) > 0.9); + assert!(proj.get(7, 7) > 0.9 && round.get(7, 7) < 0.5, "square corner"); + // A right-angle turn: Miter fills the outer corner, Bevel cuts it. + let corner = vec![vec![[5.0, 30.0], [30.0, 30.0], [30.0, 5.0]]]; + let j = |join: u32| raster_convex(40, 40, &stroke_outline(&corner, 3.0, 1, join, 4.0)); + let (miter, bevel) = (j(1), j(2)); + assert!(miter.get(32, 32) > 0.9 && bevel.get(32, 32) < 0.1, "{} {}", miter.get(32, 32), bevel.get(32, 32)); + // A tight miter limit falls back to a bevel. + let limited = raster_convex(40, 40, &stroke_outline(&corner, 3.0, 1, 1, 1.0)); + assert!(limited.get(32, 32) < 0.1); +} + +#[test] +fn scribble_caps_and_start_end_apply_to() { + let masks = square_mask(); + let env = EffectEnv { masks: &masks, ..Default::default() }; + let img = Image::new(64, 64); + let base = [("wiggleType", Value::Enum(0)), ("strokeWidth", num(3.0))]; + let round = run_env("ec.generate.scribble", &base, img.clone(), 0.0, env); + let butt = run_env( + "ec.generate.scribble", + &[base.as_slice(), &[("edgeOptions/endCap", Value::Enum(1)), ("edgeOptions/join", Value::Enum(2))]].concat(), + img.clone(), + 0.0, + env, + ); + assert_ne!(round.img.data, butt.img.data); + // Each Stroke: every stroke is trimmed on its own, so the half-way image has ink all over. + let two = vec![ + crate::MaskShape { name: String::new(), points: vec![[4.0, 4.0], [28.0, 4.0], [28.0, 60.0], [4.0, 60.0]], closed: true, inverted: false }, + crate::MaskShape { name: String::new(), points: vec![[36.0, 4.0], [60.0, 4.0], [60.0, 60.0], [36.0, 60.0]], closed: true, inverted: false }, + ]; + let env2 = EffectEnv { masks: &two, ..Default::default() }; + let seq = [("scribble", Value::Enum(1)), ("end", num(50.0))]; + let all = run_env("ec.generate.scribble", &[base.as_slice(), &seq].concat(), img.clone(), 0.0, env2); + let each = run_env("ec.generate.scribble", &[base.as_slice(), &seq, &[("startEndApplyTo", Value::Enum(1))]].concat(), img, 0.0, env2); + assert_ne!(all.img.data, each.img.data); +} + +#[test] +fn vegas_long_segments_are_sampled_finely() { + // A short segment keeps the 12 samples; a long one gets more so it bends smoothly round rounded corners. + assert_eq!(super::paths::vegas_substeps(10.0, 4.0), 12); + let n = super::paths::vegas_substeps(400.0, 4.0); + assert!(n > 12 && n.is_multiple_of(2), "{n}"); + assert!(super::paths::vegas_substeps(13.0 * 4.0, 4.0).is_multiple_of(2)); + assert!(400.0 / n as f64 <= 4.0 + 1e-9); + assert_eq!(super::paths::vegas_substeps(1.0e9, 4.0), 512); +} + +#[test] +fn vegas_long_segment_follows_curved_mask() { + // One segment as long as a round mask (radius 90 px): with only 12 straight pieces the + // chords cut ~3 px inside the curve, leaving gaps on the mask outline. + let (c, r) = (100.0, 90.0); + let mut points = Vec::new(); + for i in 0..256 { + let a = i as f64 / 256.0 * std::f64::consts::TAU; + points.push([c + r * a.cos(), c + r * a.sin()]); + } + let masks = vec![MaskShape { name: "Mask 1".into(), points, closed: true, inverted: false }]; + let env = EffectEnv { masks: &masks, ..Default::default() }; + let set = [ + ("stroke", Value::Enum(1)), + ("segments", num(1.0)), + ("length", num(1.0)), + ("segmentDistribution", Value::Enum(1)), + ("blendMode", Value::Enum(0)), + ("hardness", num(1.0)), + ("midpointOpacity", num(1.0)), + ("endOpacity", num(1.0)), + ]; + let out = run_env("ec.generate.vegas", &set, Image::new(200, 200), 0.5, env); + for k in 0..720 { + let a = k as f64 / 720.0 * std::f64::consts::TAU; + let (x, y) = ((c + r * a.cos()).floor() as usize, (c + r * a.sin()).floor() as usize); + let alpha = out.img.data[y * 200 + x][3]; + assert!(alpha > 0.5, "gap on the curve at {k}/720 turn: alpha {alpha}"); + } +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index cc0aad515..c865d4f96 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -8,6 +8,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod analysis_cache; pub mod audio_fx; mod blur2; mod blur3; @@ -78,7 +79,7 @@ pub use color_fx::{ pub use color_fx::{COLORAMA_PRESETS, ColoramaPalette}; pub use color2::{Curve, auto_correct_settings, color_link_mode, color_link_sample, equalize_tables, luma_clip_points, shadow_highlight_amounts}; pub use color3::OffsetCurve; -pub use distort::transform_shutter; +pub use distort::{transform_grow, transform_matrix_px, transform_shutter}; pub use distort2::{MAGNIFY_MODES, bezier_grid, parse_mesh}; pub use distort3::{RESHAPE_POINTS, ReshapeSetup, SmearSetup, cc_smear_pad, reshape_setup, smear_setup, warp_inverse_map}; pub use distort4::liquify_mesh; @@ -86,7 +87,10 @@ use effectcraft_keyframe::Value; use effectcraft_project::build::Ids; use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property}; pub use effectcraft_raster::{AuxChannels, Image}; -pub use misc::{INVERT_ALPHA, INVERT_CHANNELS, glow_ab_t, glow_operation}; +pub use misc::{ + GLOW_GAIN, GLOW_KNEE, INVERT_ALPHA, INVERT_CHANNELS, glow_ab_t, glow_operation, glow_sigma, radial_wipe_sigma, radial_wipe_wedge, venetian_cover, + venetian_origin, +}; pub use sim::particle_state; pub use sim3::{PgBlit, PgPlan, playground_plan, playground_state}; pub use stylize2::strobe_on; @@ -104,7 +108,7 @@ pub use transition::place_layer; // effectcraft-gpu fx_noise. pub use keying2::{InnerOuterPlan, inner_outer_plan}; pub use noise::remove_grain_level; -pub use noise3::{FractalGpu, fractal_gpu}; +pub use noise3::{FractalGpu, NOISE_CELL, fractal_gpu}; pub use stylize3::{BumpLight, bump_layer_id}; pub use textfx::{TextLayer, text_layer}; pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time, time_frames, timewarp_crop, timewarp_plan}; @@ -113,7 +117,7 @@ pub use ocio::color_stabilizer_maps; // GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes). pub use channel3d::{CRYPTO_LAYERS, CryptoAux, aux_point, crypto_aux_image, crypto_aux_image_with, id_color, selection_hashes}; pub use ocio::{ColorOp, Straight, Tf, color_program}; -pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, drizzle_drops, mercury_blobs, sprite_plan}; +pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, bubble_sample_bounds, drizzle_drops, mercury_blobs, sprite_plan}; pub use sim2::{ CausticsSetup, FoamDisc, FoamEnv, FoamPlan, Piece, PiecePlan, PieceTex, PixelPlan, WavePlan, Waves, caustics_setup, foam_full_plan, piece_plan, pixel_plan, wave_world_plan, @@ -287,15 +291,66 @@ impl Buf { self.offset[0] += origin[0] * self.scale; self.offset[1] += origin[1] * self.scale; } - /// Grow by `pad` transparent pixels on every side. - pub fn pad(&mut self, pad: u32) { - if pad == 0 { + /// Grow by transparent pixels: `sides` = left, top, right, bottom. Content keeps its place. + pub fn pad_sides(&mut self, sides: [u32; 4]) { + let [l, t, r, b] = sides; + if l == 0 && t == 0 && r == 0 && b == 0 { return; } - self.img = self.img.padded(pad); - self.offset[0] += pad as f64; - self.offset[1] += pad as f64; + let (Some(w), Some(h)) = (self.img.width.checked_add(l).and_then(|v| v.checked_add(r)), self.img.height.checked_add(t).and_then(|v| v.checked_add(b))) + else { + return; + }; + let mut out = Image::new(w, h); + let (sw, ow) = (self.img.width as usize, w as usize); + for y in 0..self.img.height as usize { + let at = (y + t as usize) * ow + l as usize; + let (Some(src), Some(dst)) = (self.img.data.get(y * sw..(y + 1) * sw), out.data.get_mut(at..at + sw)) else { continue }; + dst.copy_from_slice(src); + } + self.img = out; + self.offset[0] += l as f64; + self.offset[1] += t as f64; } + /// Grow by `left` and `top` transparent pixels on those sides only. + pub fn grow_left_top(&mut self, left: u32, top: u32) { + self.pad_sides([left, top, 0, 0]); + } + /// Give the buffer the pixel-grid phase (modulo [`GRID_ALIGN`]) that [`Buf::cover`] of + /// `size` would, by growing left/top only ([`align_plan`]). A downscaled layer is resampled + /// through a pyramid whose 2x2 blocks follow the buffer's grid, so a buffer that starts at + /// its content's corner instead of the cover's resamples to different pixels (#1205). + pub fn align(&mut self, size: [f64; 2]) { + if let Some((l, t)) = align_plan(self.offset, self.scale, size, self.img.width, self.img.height) { + self.grow_left_top(l, t); + } + } + /// Grow by `pad` transparent pixels on every side, within [`pad_within_budget`] and as + /// clamped by [`Image::effective_pad`]. + pub fn pad(&mut self, pad: u32) { + let eff = self.img.effective_pad(pad_within_budget(self.img.width, self.img.height, pad)); + if eff == 0 { + return; + } + self.img = self.img.padded(eff); + self.offset[0] += eff as f64; + self.offset[1] += eff as f64; + } +} + +/// `pad` on every side of a `w` × `h` effect buffer, cut so the padded buffer stays within +/// 16 Mpx (256 MiB of f32 RGBA) or four times its own area, whichever is larger: a small layer +/// far from an effect's centre can't ask for gigabytes (#2261). Shared with the GPU path. +pub fn pad_within_budget(w: u32, h: u32, pad: u32) -> u32 { + const MIN_BUDGET: f64 = 16_777_216.0; + let (w, h) = (f64::from(w), f64::from(h)); + let budget = MIN_BUDGET.max(4.0 * w * h); + if (w + 2.0 * f64::from(pad)) * (h + 2.0 * f64::from(pad)) <= budget { + return pad; + } + // (w + 2p)(h + 2p) = budget, solved for p. + let p = (((w - h).powi(2) + 4.0 * budget).sqrt() - (w + h)) / 4.0; + (p.floor().max(0.0) as u32).min(pad) } /// Motion Tile's output frame for a buffer with layer origin `offset` (pixels) at `scale`: @@ -335,6 +390,33 @@ pub fn adjustment_crop(offset: [f64; 2], canvas_offset: [f64; 2], size: (u32, u3 Some((dx as i64, dy as i64)) } +/// Effects that grow their own buffer by what they need (blur and glow margins, Repeat Edge +/// Pixels through [`Buf::cover`], displacement through [`cover_pad`]): a stack of only these +/// skips the renderer's cover of the whole layer rectangle (#1205). +pub const SKIPS_COVER: &[&str] = &["ec.blur.gaussian", "ec.blur.fastbox", "ec.stylize.glow", "ec.distort.turbulentdisplace"]; + +/// The grid phase [`Buf::align`] keeps and the step [`cover_pad`] grows in: covers the levels +/// of the resampling pyramid used for downscaled layers. +pub const GRID_ALIGN: u32 = 64; + +/// Left/top growth giving a buffer [`cover_plan`]'s grid phase modulo [`GRID_ALIGN`]; `None` +/// when there is nothing to add. +pub fn align_plan(offset: [f64; 2], scale: f64, size: [f64; 2], width: u32, height: u32) -> Option<(u32, u32)> { + let (dx, dy, _, _) = cover_plan(offset, scale, size, width, height)?; + let (l, t) = (dx % GRID_ALIGN, dy % GRID_ALIGN); + (l != 0 || t != 0).then_some((l, t)) +} + +/// Padding for an effect that moves pixels up to `want` past the content (not an adjustment +/// layer): `want`, capped at what reaches the layer rectangle, in whole [`GRID_ALIGN`] steps so +/// the grid phase stays. 0 once the buffer covers the layer. +pub fn cover_pad(ctx: &EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f64, want: u32) -> u32 { + if ctx.adjustment { + return 0; + } + want.min(util::layer_cover_pad(ctx, size, offset, scale)).div_ceil(GRID_ALIGN) * GRID_ALIGN +} + /// Where [`Buf::cover`] would take a buffer: shift its pixels by `(dx, dy)` and resize it to /// `width` × `height` so it spans the `size`-layer-pixel region that starts at its offset — the /// whole layer rectangle, which a sized source's buffer already is. `None` when the buffer @@ -421,6 +503,18 @@ pub trait EffectHost: Sync { fn params_at(&self, _layer_time: f64) -> Option { None } + /// Parameter `id` (a [`flatten_params`] id such as `birthRate`) of effect `effect_index` in + /// the layer's stack (the running effect's [`EffectEnv::effect_index`]) at another **layer + /// time**: CC Particle World samples its Birth Rate at every step it simulates (#1373). + /// `None` when unavailable. + fn param_at(&self, _effect_index: usize, _id: &str, _layer_time: f64) -> Option { + None + } + /// Parameter `id` of effect `effect_index` has one value at every time (no keyframes, no + /// expression), so sampling it over time can be skipped. `false` when unknown. + fn param_is_static(&self, _effect_index: usize, _id: &str) -> bool { + false + } /// A particle simulation backend (GPU particles; see [`psim`]). `None` = simulate on the /// CPU. fn particles(&self) -> Option<&dyn psim::ParticleSim> { @@ -501,6 +595,15 @@ pub struct EffectSpec { pub float: bool, } +impl EffectSpec { + /// True when this effect grows its own buffer (blur/glow margins, Repeat Edge + /// [`Buf::cover`], displacement [`cover_pad`]) so a stack of only these can skip the + /// renderer's full-layer cover and keep the pyramid grid phase (#1205). + pub fn skips_cover(&self) -> bool { + SKIPS_COVER.contains(&self.id) + } +} + pub(crate) fn p(id: &'static str, name: &'static str, default: Value, ui: ParamUi) -> ParamSpec { ParamSpec { id, name, default, ui } } @@ -1276,6 +1379,13 @@ pub fn is_time_dependent(id: &str) -> bool { id.starts_with("ec.sim.") || TIME_DEPENDENT.contains(&id) } +/// Does effect `id` read its own parameters at earlier times (the stepped particle systems sample +/// their Birth Rate at every step, #1373)? Caches keyed on the current values can't see what an +/// expression returned then. +pub fn reads_param_history(id: &str) -> bool { + matches!(id, "ec.sim.ccparticleworld" | "ec.sim.ccparticlesystems2" | "ec.sim.ccmrmercury") +} + /// Run an effect on a buffer. pub fn apply(spec: &EffectSpec, ctx: &EffectCtx, buf: Buf) -> Buf { (spec.render)(ctx, buf) @@ -1300,6 +1410,23 @@ pub(crate) fn run_fx(id: &str, vals: &[(&str, Value)], img: Image, time: f64, en mod tests { use super::*; + /// #2261: a 20 × 20 layer with CC Radial Fast Blur's Center far away grows to at most 16 Mpx; + /// big layers may still grow to four times their area, and small pads are untouched. + #[test] + fn padded_buffers_stay_within_the_budget() { + let area = |w: u32, h: u32, p: u32| (u64::from(w) + 2 * u64::from(p)) * (u64::from(h) + 2 * u64::from(p)); + let far = radial_fast_pad((6500.0, 6500.0), 20, 20, 0.8); + assert_eq!(far, 4096, "the effect asks for its full pad"); + let mut b = Buf { img: Image::new(20, 20), offset: [0.0; 2], scale: 1.0 }; + b.pad(far); + assert!(area(20, 20, 0) < area(b.img.width, b.img.height, 0) && area(b.img.width, b.img.height, 0) <= 1 << 24, "{}x{}", b.img.width, b.img.height); + assert_eq!(b.offset, [f64::from((b.img.width - 20) / 2); 2], "the content keeps its place"); + let p = pad_within_budget(3840, 2160, 4096); + assert!(p > 1000 && area(3840, 2160, p) <= 4 * 3840 * 2160 && area(3840, 2160, p + 1) > 4 * 3840 * 2160); + assert_eq!(pad_within_budget(640, 360, 200), 200); + assert_eq!(pad_within_budget(20, 20, 0), 0); + } + #[test] fn run_fx_passthrough() { let img = Image::filled(4, 3, [0.5, 0.25, 0.125, 1.0]); @@ -1307,6 +1434,20 @@ mod tests { assert_eq!(out.img.data, img.data); } + #[test] + fn pad_offset_follows_the_clamped_pad() { + // Image::padded clamps a hostile pad to keep each side within MAX_PADDED_SIDE; the offset + // must move by the pad actually applied, or the content drifts from its layer position. + let mut img = Image::new(29_990, 2); + img.set(0, 0, [1.0, 0.0, 0.0, 1.0]); + let mut buf = Buf { img, offset: [3.0, 4.0], scale: 1.0 }; + buf.pad(u32::MAX); + // Width binds: (MAX_PADDED_SIDE - 29 990) / 2 = 5. + assert_eq!(buf.offset, [8.0, 9.0]); + let (x, y) = buf.to_px([-3.0, -4.0]); + assert_eq!(buf.img.get(x as i64, y as i64), [1.0, 0.0, 0.0, 1.0]); + } + #[test] #[ignore] fn dump_registry() { @@ -1491,4 +1632,24 @@ mod tests { assert_eq!(g.get("noiseSourceLayerSource").map(|p| p.value.clone()), Some(Value::Enum(0))); assert_eq!(g.props().filter(|p| p.match_id == "noiseSourceLayerSource").count(), 1); } + + #[test] + fn align_plan_is_cover_shift_mod_grid() { + // Content inset from the layer origin: pixel 0 maps to layer (50, 30). + let offset = [-50.0, -30.0]; + let scale = 1.0; + let size = [1920.0, 1080.0]; + let (dx, dy, _, _) = cover_plan(offset, scale, size, 80, 40).unwrap(); + assert_eq!((dx, dy), (50, 30)); + assert_eq!(align_plan(offset, scale, size, 80, 40), Some((50, 30))); + assert_eq!(align_plan([-64.0, -128.0], 1.0, size, 80, 40), None); + } + + #[test] + fn known_margin_effects_skip_cover_and_twirl_does_not() { + for id in SKIPS_COVER { + assert!(find(id).expect(id).skips_cover(), "{id}"); + } + assert!(!find("ec.distort.twirl").unwrap().skips_cover()); + } } diff --git a/crates/effects/src/matte.rs b/crates/effects/src/matte.rs index 4c42c9c39..d695a3b41 100644 --- a/crates/effects/src/matte.rs +++ b/crates/effects/src/matte.rs @@ -362,6 +362,22 @@ mod tests { assert!(a > 0.99 && c[0] > 0.5, "{c:?}"); } + /// #2102: negative Choke Matte keeps growing the matte past -20, down to -100: a 120 px hole + /// in an opaque frame closes a bit more with every step. + #[test] + fn simple_choker_grows_the_matte_at_large_negative_values() { + let mut img = Image::filled(200, 200, [1.0, 1.0, 1.0, 1.0]); + for y in 40..160 { + for x in 40..160 { + img.set(x, y, [0.0; 4]); + } + } + let filled = |c: f64| alpha_sum(&run("ec.matte.simplechoker", &[("chokeMatte", num(c))], img.clone())); + let sums: Vec = [-10.0, -20.0, -40.0, -60.0].into_iter().map(filled).collect(); + assert!(sums.windows(2).all(|w| w[1] > w[0] + 100.0), "{sums:?}"); + assert!((filled(-100.0) - 200.0 * 200.0).abs() < 1.0, "-100 closes the hole"); + } + #[test] fn matte_choker_defaults_shrink() { let img = square(); diff --git a/crates/effects/src/migrate.rs b/crates/effects/src/migrate.rs index b5976b761..ecea0fe19 100644 --- a/crates/effects/src/migrate.rs +++ b/crates/effects/src/migrate.rs @@ -344,6 +344,23 @@ pub fn upgrade_instance(spec: &EffectSpec, g: &mut PropGroup, ids: &mut Ids, lay *g != before } +/// The spec id of the parameter that an instance of `spec` saved at `id` (`a/b/param`) is now: +/// `id` itself when the spec still has it, the new id of a renamed parameter +/// ([`PARAM_ID_ALIASES`]), or the twirl-down path of a top-level parameter that moved into a +/// group, as [`upgrade_instance`] finds them. Returns `None` when no parameter matches. +pub fn current_param_id(spec: &EffectSpec, id: &str) -> Option<&'static str> { + if let Some(ps) = spec.params.iter().find(|ps| ps.id == id) { + return Some(ps.id); + } + if let Some((_, _, new)) = PARAM_ID_ALIASES.iter().find(|(e, old, _)| *e == spec.id && *old == id) { + return Some(new); + } + if id.contains('/') { + return None; + } + spec.params.iter().find(|ps| ps.id.rsplit('/').next() == Some(id)).map(|ps| ps.id) +} + /// Bring an effect instance saved before project schema 2 into effect space (#227). /// /// Layers without a source rectangle (shape, text) have comp-sized effect bounds centred on @@ -559,6 +576,21 @@ mod tests { assert_eq!(prop_at(&mut g, "stabilization/smoothness").unwrap().value, Value::Scalar(12.0)); } + /// A saved parameter id maps to the parameter `upgrade_instance` moves it to. + #[test] + fn current_param_id_follows_renames_and_twirl_down_moves() { + let stab = find(crate::warp_stab::ID).unwrap(); + assert_eq!(current_param_id(stab, "stabilization/smoothness"), Some("stabilization/smoothness")); + assert_eq!(current_param_id(stab, "smoothness"), Some("stabilization/smoothness")); + let ctc = find("ec.color.changetocolor").unwrap(); + assert_eq!(current_param_id(ctc, "tolerance"), Some("toleranceGroup/hue")); + // Radio Waves' old Velocity is the growth speed, not the new Velocity. + let waves = find("ec.generate.radiowaves").unwrap(); + assert_eq!(current_param_id(waves, "velocity"), Some("waveMotion/expansion")); + assert_eq!(current_param_id(stab, "noSuchParam"), None); + assert_eq!(current_param_id(stab, "other/smoothness"), None); + } + #[test] fn card_wipe_back_layer_popup_becomes_a_layer_parameter() { let spec = find("ec.transition.cardwipe").unwrap(); @@ -733,7 +765,13 @@ not a stroke", let data = effectcraft_track::roto::RotoData { base: Some(0), span: [0, 10], - strokes: vec![effectcraft_track::roto::Stroke { kind: effectcraft_track::roto::StrokeKind::Fg, frame: 0, radius: 5.0, points: vec![[2.0, 2.0]] }], + strokes: vec![effectcraft_track::roto::Stroke { + kind: effectcraft_track::roto::StrokeKind::Fg, + frame: 0, + radius: 5.0, + points: vec![[2.0, 2.0]], + object: 0, + }], }; roto.get_mut(crate::roto::STROKES).unwrap().value = Value::Str(data.to_json()); to_effect_space(spec, &mut roto, SIZE, D, &own); diff --git a/crates/effects/src/misc.rs b/crates/effects/src/misc.rs index 7468c6b84..15d5d0aac 100644 --- a/crates/effects/src/misc.rs +++ b/crates/effects/src/misc.rs @@ -34,6 +34,9 @@ fn gaussian(ctx: &EffectCtx, mut b: Buf) -> Buf { let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { b.pad((s * 3.0).ceil() as u32); + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(ctx.layer_size); } let (sx, sy) = (s * kx, s * ky); // Small sigmas: integer-radius box passes would step (and do nothing below ~0.6 px), so sample the kernel (#1939). @@ -60,6 +63,9 @@ fn box_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { if !repeat && !ctx.adjustment { let reach = if frac > 0.0 { r + 1 } else { r }; b.pad((reach * it) as u32 + 1); + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(ctx.layer_size); } let edge = repeat || ctx.adjustment; let lo = effectcraft_raster::box_blur(&b.img, r * kx as usize, r * ky as usize, it, edge); @@ -142,6 +148,28 @@ pub fn glow_ab_t(l: f32, looping: u32, loops: f32, phase: f32, mid: f32) -> f32 t.max(0.0).powf(0.5f32.ln() / m.ln()) } +/// Glow's blur: a Gaussian of σ = 0.384 × Glow Radius. Fitted to After Effects 2026 (cases +/// 807 and 808, Glow Radius 10 and 30 on a grey solid): the edge profile of the glow's +/// coverage is an erfc step of that σ within 1.7/255 (it was σ = radius / 2). +pub fn glow_sigma(radius: f64) -> f64 { + (radius * 0.384).max(0.5) +} + +/// Glow's bright pass for the original colours, per channel of the straight colour `v`: +/// `8 × (v − threshold + 0.0611)`, nothing below. Fitted to After Effects 2026 renders of grey +/// 0.65 / 0.8 / 0.9 / 1, orange and light-blue solids at Threshold 40 / 60 / 80 % and Radius 10 +/// / 30 (36 channel values, exact to the 8-bit capture): the glow is linear in each channel, +/// its gain is the same at every threshold and radius, and channels below the threshold add +/// nothing (an orange solid glows red only). +pub fn glow_bright_channel(v: f32, thr: f32) -> f32 { + (GLOW_GAIN * (v - thr + GLOW_KNEE)).max(0.0) +} + +/// [`glow_bright_channel`]'s gain and threshold offset. `kernels.wgsl` repeats both as literals; +/// the GPU test `glow_constants_match_the_cpu` checks that they are these. +pub const GLOW_GAIN: f32 = 8.0; +pub const GLOW_KNEE: f32 = 0.0611; + /// Glow Operation options (blend modes the glow is applied with). pub const GLOW_OPERATIONS: [&str; 17] = [ "Normal", @@ -205,23 +233,31 @@ fn glow(ctx: &EffectCtx, mut b: Buf) -> Buf { // Glow Based On: brightness from the colours, or from the alpha channel. let l = if alpha_based { a } else { luminance(px[0] / a, px[1] / a, px[2] / a) }; let k = ((l - thr) / (1.0 - thr).max(1e-3)).clamp(0.0, 1.0); + // Based on the colour channels, the glow's coverage is the layer's whole alpha, below + // the threshold too: After Effects darkens what lies under a dim layer's edge (glow_alpha). + let ga = if alpha_based { k * a } else { a }; if let Some(map) = &arbitrary { let t = glow_ab_t(l, looping, loops, phase, mid); let c = map.iter().map(|cv| cv.as_ref().map_or(t, |cv| cv.eval(t))).collect::>(); - *px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, k * a]; + *px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, ga]; } else if use_colors { let t = glow_ab_t(l, looping, loops, phase, mid); let c = [ca[0] + (cb[0] - ca[0]) * t, ca[1] + (cb[1] - ca[1]) * t, ca[2] + (cb[2] - ca[2]) * t]; - *px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, k * a]; + *px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, ga]; + } else if alpha_based { + *px = [px[0] * k, px[1] * k, px[2] * k, ga]; } else { - *px = px.map(|v| v * k); + // Original colours: each channel of the straight colour against the threshold. + let c = [px[0] / a, px[1] / a, px[2] / a].map(|v| glow_bright_channel(v, thr) * a); + *px = [c[0], c[1], c[2], ga]; } }); - let s = (radius / 2.0).max(0.5); + let s = glow_sigma(radius); let blurred = gaussian_blur(&bright, s * kx, s * ky, false); let mode = ctx.params.e("operation"); b.img.data.par_iter_mut().zip(blurred.data.par_iter()).for_each(|(o, g)| { - let g = g.map(|v| v * intensity); + // Intensity scales the glow's colour; with the colour channels its coverage stays. + let g = [g[0] * intensity, g[1] * intensity, g[2] * intensity, if alpha_based { g[3] * intensity } else { g[3] }]; match mode { // Behind: the glow shows only where the original is transparent. 1 => { @@ -525,22 +561,67 @@ fn linear_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { b } +/// Standard normal CDF Φ (Abramowitz & Stegun 7.1.26 erf, |error| < 1.5e-7). +fn norm_cdf(z: f64) -> f64 { + let x = z.abs() / std::f64::consts::SQRT_2; + let t = 1.0 / (1.0 + 0.327_591_1 * x); + let poly = t * (0.254_829_592 + t * (-0.284_496_736 + t * (1.421_413_741 + t * (-1.453_152_027 + t * 1.061_405_429)))); + let erf = 1.0 - poly * (-x * x).exp(); + if z >= 0.0 { 0.5 * (1.0 + erf) } else { 0.5 * (1.0 - erf) } +} + +/// Radial Wipe's wiped wedge relative to the Start Angle: (first ray, span), degrees clockwise +/// from up. Clockwise (0) wipes from the start angle on, Counterclockwise (1) back from it, +/// Both (2) half each way. +pub fn radial_wipe_wedge(wipe: u32, done: f64) -> (f64, f64) { + let span = done.clamp(0.0, 1.0) * 360.0; + match wipe { + 1 => (-span, span), + 2 => (-span / 2.0, span), + _ => (0.0, span), + } +} + +/// Radial Wipe's feather: a Gaussian of σ = 0.4 × Feather pixels across both edges of the wedge +/// (After Effects 2026, Feather 20: the edge profile is the same width in pixels at every +/// radius, σ = 8.0 px, mean error 0.0002 over the frame). +pub fn radial_wipe_sigma(feather: f64) -> f64 { + feather.max(0.0) * 0.4 +} + +/// Visible fraction of a pixel at `(ux, uy)` from the centre (y down) for a wiped wedge from +/// `ray0` (absolute degrees clockwise from up) over `span` degrees: the signed distance to the +/// wedge's nearer edge, through the feather's Gaussian. +pub fn radial_wipe_alpha(ux: f64, uy: f64, ray0: f64, span: f64, sigma: f64) -> f64 { + let a = (ux.atan2(-uy).to_degrees() - ray0).rem_euclid(360.0); + let wiped = a < span; + let ray_dist = |deg: f64| { + let (s, c) = deg.to_radians().sin_cos(); + // Ray direction (sin, −cos) in y-down pixels. + let along = ux * s - uy * c; + if along > 0.0 { (ux * c + uy * s).abs() } else { ux.hypot(uy) } + }; + let d = ray_dist(ray0).min(ray_dist(ray0 + span)); + let sd = if wiped { -d } else { d }; + if sigma < 1e-3 { + return if wiped { 0.0 } else { 1.0 }; + } + norm_cdf(sd / sigma) +} + fn radial_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { - let done = ctx.params.f("completion") / 100.0; + let done = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0); + if done <= 0.0 { + return b; + } let start = ctx.params.f("startAngle"); let c = b.to_px(ctx.params.v2("center")); - let dir = ctx.params.e("wipe"); - let feather = (ctx.params.f("feather")).max(0.01); + let (w0, span) = radial_wipe_wedge(ctx.params.e("wipe"), done); + let sigma = radial_wipe_sigma(ctx.params.f("feather")) * b.scale; + let full = done >= 1.0; b.img.rows_mut().for_each(|(y, row)| { for (x, px) in row.iter_mut().enumerate() { - let a = ((x as f64 + 0.5 - c.0).atan2(-(y as f64 + 0.5 - c.1)).to_degrees() - start).rem_euclid(360.0); - // Both: the wipe opens in both directions from the start angle at once. - let (a, edge) = match dir { - 1 => (360.0 - a, done * 360.0), - 2 => (a.min(360.0 - a), done * 180.0), - _ => (a, done * 360.0), - }; - let k = ((a - edge) / feather + 0.5).clamp(0.0, 1.0) as f32; + let k = if full { 0.0 } else { radial_wipe_alpha(x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1, start + w0, span, sigma) as f32 }; for ch in px.iter_mut() { *ch *= k; } @@ -549,16 +630,49 @@ fn radial_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { b } +/// Venetian Blinds: how much of a pixel at phase `d` (0..`width`) a band of `done × width` +/// covers, its two edges softened by a Gaussian of σ = Feather / 2 (σ in the units of `width`). +/// Fitted to After Effects 2026 (Completion 40, Direction 30, Width 50, Feather 5 on 960 × 540 +/// and 480 × 270 layers): band from phase 0 to done × width, both edges Gaussian, within 6/255. +pub fn venetian_cover(d: f64, width: f64, done: f64, sigma: f64) -> f64 { + let band = done * width; + if sigma < 1e-3 { + return if d < band { 1.0 } else { 0.0 }; + } + // Wider than a few blinds the stripes blur into an even veil. + if sigma > 8.0 * width { + return done.clamp(0.0, 1.0); + } + let n = ((4.0 * sigma / width).ceil() as i64 + 1).clamp(1, 40); + let mut t = 0.0; + for k in -n..=n { + let u = d + k as f64 * width; + t += norm_cdf(u / sigma) - norm_cdf((u - band) / sigma); + } + t.clamp(0.0, 1.0) +} + +/// Venetian Blinds' phase origin in layer pixels: the layer centre plus 0.6 px (fitted to the +/// AE renders above; the stripes are measured from the layer's centre, not its corner). +pub fn venetian_origin(layer_size: [f64; 2]) -> [f64; 2] { + [layer_size[0] / 2.0 + 0.6, layer_size[1] / 2.0 + 0.6] +} + fn venetian(ctx: &EffectCtx, mut b: Buf) -> Buf { - let done = ctx.params.f("completion") / 100.0; + let done = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0); + if done <= 0.0 { + return b; + } let ang = ctx.params.f("direction").to_radians(); let width = (ctx.params.f("width") * b.scale).max(1.0); - let feather = (ctx.params.f("feather") * b.scale).max(0.001); + let sigma = ctx.params.f("feather").max(0.0) * b.scale / 2.0; let (dx, dy) = (ang.cos(), ang.sin()); + let (ox, oy) = b.to_px(venetian_origin(ctx.layer_size)); b.img.rows_mut().for_each(|(y, row)| { + let ry = (y as f64 + 0.5 - oy) * dy; for (x, px) in row.iter_mut().enumerate() { - let d = ((x as f64 + 0.5) * dx + (y as f64 + 0.5) * dy).rem_euclid(width); - let k = ((d - done * width) / feather + 0.5).clamp(0.0, 1.0) as f32; + let d = ((x as f64 + 0.5 - ox) * dx + ry).rem_euclid(width); + let k = (1.0 - venetian_cover(d, width, done, sigma)) as f32; for c in px.iter_mut() { *c *= k; } @@ -807,6 +921,189 @@ mod tests { img } + /// After Effects 2026 (cases 524, 807, 808, 809): Glow on a 50 % grey solid, which is below + /// the 60 % threshold, still covers a blurred copy of the layer's alpha with black, so the + /// red layer underneath darkens outside the edge. The halo is the same at Intensity 1 and 3 + /// and with Composite Original On Top or Behind; its width follows Glow Radius. + #[test] + fn glow_halo_matches_after_effects() { + let grey = effectcraft_raster::Image::filled(200, 120, [0.5, 0.5, 0.5, 1.0]); + // AE's red under the halo at 0.5, 1.5, 3.5, 5.5 and 7.5 px outside the left edge + // (radius 10) and at 0.5, 5.5, 10.5, 20.5 px (radius 30); halo alpha = 1 − red. + let r10 = [(0.5, 140.0), (1.5, 165.0), (3.5, 208.0), (5.5, 234.0), (7.5, 248.0)]; + let r30 = [(0.5, 132.0), (5.5, 173.0), (10.5, 208.0), (20.5, 245.0)]; + for (radius, intensity, operation, profile) in [(10.0, 1.0, 1, &r10[..]), (10.0, 3.0, 1, &r10[..]), (10.0, 1.0, 0, &r10[..]), (30.0, 1.0, 1, &r30[..])] + { + let set = [ + ("based", Value::Enum(1)), + ("threshold", num(60.0)), + ("radius", num(radius)), + ("intensity", num(intensity)), + ("operation", Value::Enum(operation)), + ]; + let o = run_fx("ec.stylize.glow", &set, grey.clone(), 0.0, EffectEnv::default()); + for &(d, red) in profile { + // Layer x = −d (pixel centre), mid-height; over AE's red: R = 1 − α, colour 0. + let (bx, by) = o.to_px([-d, 60.0]); + let p = o.img.get(bx.floor() as i64, by.floor() as i64); + let ae = 1.0 - red / 255.0; + assert!((p[3] - ae).abs() < 3.0 / 255.0, "radius {radius} intensity {intensity} op {operation} at {d} px: alpha {} vs AE {ae}", p[3]); + assert!(p[0].abs() < 1e-6, "the halo is black: {p:?}"); + } + } + } + + /// After Effects 2026, Glow at Threshold 60 %, Radius 10, Intensity 0.25 on 200 × 200 + /// solids over red: the glow 1.5 px outside the left edge, premultiplied (the green / blue + /// channels of the render, where the red background adds nothing), and its alpha 0.353. + #[test] + fn glow_brightness_matches_after_effects() { + // (solid colour, channel, AE glow value 0..255) + let cases = [ + ([0.65, 0.65, 0.65], 1, 20.0), + ([0.8, 0.8, 0.8], 1, 47.0), + ([0.9, 0.9, 0.9], 1, 65.0), + ([1.0, 1.0, 1.0], 1, 83.0), + ([1.0, 0.5, 0.0], 1, 0.0), + ([0.2, 0.6, 1.0], 1, 11.0), + ([0.2, 0.6, 1.0], 2, 83.0), + ]; + for (c, ch, ae) in cases { + let img = effectcraft_raster::Image::filled(200, 200, [c[0], c[1], c[2], 1.0]); + let set = [("threshold", num(60.0)), ("radius", num(10.0)), ("intensity", num(0.25))]; + let o = run_fx("ec.stylize.glow", &set, img, 0.0, EffectEnv::default()); + let (bx, by) = o.to_px([-1.5, 100.0]); + let p = o.img.get(bx.floor() as i64, by.floor() as i64); + assert!((p[ch] * 255.0 - ae).abs() <= 2.5, "{c:?} channel {ch}: {} vs AE {ae}", p[ch] * 255.0); + assert!((p[3] - 0.353).abs() < 3.0 / 255.0, "{c:?}: alpha {}", p[3]); + } + // Intensity scales the colour linearly: white at Intensity 1 is 3.7 × its alpha. + let white = effectcraft_raster::Image::filled(200, 200, [1.0, 1.0, 1.0, 1.0]); + let o = run_fx("ec.stylize.glow", &[("threshold", num(60.0)), ("radius", num(10.0))], white, 0.0, EffectEnv::default()); + let (bx, by) = o.to_px([-1.5, 100.0]); + let p = o.img.get(bx.floor() as i64, by.floor() as i64); + assert!((p[1] / p[3] - 3.69).abs() < 0.05, "{p:?}"); + } + + /// After Effects 2026: Radial Blur keeps the layer's edges. Zoom 20 on a flat grey 960 × 540 + /// solid (case 713) leaves it flat and opaque to the edge (EffectCraft faded the edges toward + /// transparency, letting the red layer below show through); on a full-frame horizontal ramp + /// (case 829, centre [960, 540]) row 540 reads 2/255 at x = 2 and 253/255 at x = 1917. + #[test] + fn radial_blur_keeps_the_layer_edges_as_after_effects() { + let zoom = |c: [f64; 2]| [("amount", num(20.0)), ("type", Value::Enum(1)), ("center", Value::Vec2(c))]; + let grey = effectcraft_raster::Image::filled(960, 540, [0.5, 0.5, 0.5, 1.0]); + let o = run_fx("ec.blur.radial", &zoom([600.0, 300.0]), grey, 0.0, EffectEnv::default()); + for (x, y) in [(0, 270), (1, 270), (959, 270), (480, 0), (480, 539), (0, 0), (959, 539)] { + let p = o.img.get(x, y); + assert!((p[3] - 1.0).abs() < 1e-4 && (p[0] - 0.5).abs() < 1e-4, "({x}, {y}): {p:?}"); + } + let mut ramp = effectcraft_raster::Image::new(1920, 1080); + ramp.rows_mut().for_each(|(_, row)| { + for (x, px) in row.iter_mut().enumerate() { + let v = (x as f32 + 0.5) / 1920.0; + *px = [v, v, v, 1.0]; + } + }); + let o = run_fx("ec.blur.radial", &zoom([960.0, 540.0]), ramp, 0.0, EffectEnv::default()); + for (x, ae) in [(2, 2.0), (1917, 253.0)] { + let p = o.img.get(x, 540); + assert!((p[0] * 255.0 - ae).abs() <= 2.0 && (p[3] - 1.0).abs() < 1e-4, "x {x}: {p:?} vs AE {ae}"); + } + } + + /// After Effects 2026, Venetian Blinds Completion 40, Direction 30, Width 50, Feather 5 on + /// an opaque 480 × 270 layer: alpha (0..255) along row y = 135. The transparent band starts + /// at the layer centre's phase, both edges are Gaussian (σ = Feather / 2). + #[test] + fn venetian_blinds_match_after_effects() { + let img = effectcraft_raster::Image::filled(480, 270, [0.5, 0.5, 0.5, 1.0]); + let vals = [("completion", Value::Scalar(40.0)), ("direction", Value::Scalar(30.0)), ("width", Value::Scalar(50.0)), ("feather", Value::Scalar(5.0))]; + let o = run_fx("ec.transition.venetian", &vals, img, 0.0, EffectEnv::default()); + let ae = [ + (200, 5.0), + (203, 47.0), + (206, 145.0), + (209, 228.0), + (212, 253.0), + (224, 255.0), + (236, 238.0), + (239, 168.0), + (242, 66.0), + (245, 10.0), + (251, 0.0), + (257, 2.0), + (260, 31.0), + ]; + for (x, a) in ae { + let got = o.img.get(x, 135)[3] * 255.0; + assert!((got - a).abs() <= 6.0, "x {x}: alpha {got} vs AE {a}"); + } + // No completion leaves the layer; full completion clears it; no feather is a hard edge. + let full = [("completion", Value::Scalar(100.0)), ("feather", Value::Scalar(5.0))]; + let img = effectcraft_raster::Image::filled(40, 20, [0.5, 0.5, 0.5, 1.0]); + assert!(run_fx("ec.transition.venetian", &full, img.clone(), 0.0, EffectEnv::default()).img.data.iter().all(|p| p[3] < 1e-4)); + assert_eq!(run_fx("ec.transition.venetian", &[("completion", Value::Scalar(0.0))], img.clone(), 0.0, EffectEnv::default()).img, img); + let hard = run_fx( + "ec.transition.venetian", + &[("completion", Value::Scalar(50.0)), ("width", Value::Scalar(10.0)), ("feather", Value::Scalar(0.0))], + img, + 0.0, + EffectEnv::default(), + ); + assert!(hard.img.data.iter().all(|p| p[3] == 0.0 || p[3] == 1.0)); + // A feather far wider than the blinds is an even veil. + assert_eq!(venetian_cover(3.0, 10.0, 0.5, 1e9), 0.5); + } + + /// After Effects 2026, Radial Wipe Completion 40, Start Angle 30, Wipe Counterclockwise, + /// Feather 20, centre (800, 500): visible fraction at pixel offsets from the centre on both + /// edges (8-bit captures of dark steps, so ±0.05). + #[test] + fn radial_wipe_feather_matches_after_effects() { + let img = effectcraft_raster::Image::filled(400, 300, [1.0, 1.0, 1.0, 1.0]); + let vals = [ + ("completion", Value::Scalar(40.0)), + ("startAngle", Value::Scalar(30.0)), + ("center", Value::Vec2([200.0, 150.0])), + ("wipe", Value::Enum(1)), + ("feather", Value::Scalar(20.0)), + ]; + let o = run_fx("ec.transition.radialwipe", &vals, img.clone(), 0.0, EffectEnv::default()); + let ae = [ + ((-50, 34), 0.922), + ((-52, 30), 0.797), + ((-54, 26), 0.609), + ((-55, 24), 0.5), + ((-56, 22), 0.406), + ((-57, 19), 0.266), + ((-58, 15), 0.125), + ((-104, 60), 0.951), + ((-108, 53), 0.732), + ((-111, 45), 0.341), + ((-114, 37), 0.062), + ((22, -56), 0.156), + ((26, -54), 0.312), + ((30, -52), 0.531), + ((34, -50), 0.729), + ((37, -47), 0.875), + ((53, -108), 0.177), + ((67, -99), 0.875), + ]; + for ((dx, dy), a) in ae { + let got = o.img.get(200 + dx, 150 + dy)[3]; + assert!((got - a).abs() < 0.05, "offset ({dx}, {dy}): {got} vs AE {a}"); + } + // 0 % leaves the layer, 100 % clears it, Feather 0 is a hard edge. + let at = |c: f64, f: f64| { + let v = [("completion", Value::Scalar(c)), ("feather", Value::Scalar(f))]; + run_fx("ec.transition.radialwipe", &v, img.clone(), 0.0, EffectEnv::default()).img + }; + assert_eq!(at(0.0, 20.0), img); + assert!(at(100.0, 20.0).data.iter().all(|p| p[3] == 0.0)); + assert!(at(50.0, 0.0).data.iter().all(|p| p[3] == 0.0 || p[3] == 1.0)); + } + #[test] fn glow_operation_and_arbitrary_map() { // Pixels addressed in layer space (Glow pads its buffer). diff --git a/crates/effects/src/noise.rs b/crates/effects/src/noise.rs index d39fd6528..9d21e77a7 100644 --- a/crates/effects/src/noise.rs +++ b/crates/effects/src/noise.rs @@ -264,25 +264,30 @@ pub(crate) fn sample_boxes(img: &Image, count: usize, size: usize) -> Vec<(usize } let low = Plane::luma(img); let step = (size / 2).max(1); - let mut cands: Vec<(f32, usize, usize)> = Vec::new(); - for y in (0..=h - size).step_by(step) { - for x in (0..=w - size).step_by(step) { - // Variance of the picture inside the box (the flattest boxes hold the least detail). - let (mut s, mut sq, mut n, mut opaque) = (0.0f32, 0.0f32, 0.0f32, true); - for yy in y..y + size { - for xx in x..x + size { - let v = low.data[yy * w + xx]; - s += v; - sq += v * v; - n += 1.0; - opaque &= img.data[yy * w + xx][3] > 0.5; + // Candidate rows in parallel; the sort below is a total order (variance, y, x), so the + // result does not depend on the collection order. + let rows: Vec = (0..=h - size).step_by(step).collect(); + let mut cands: Vec<(f32, usize, usize)> = rows + .par_iter() + .flat_map_iter(|&y| { + let data = low.data.as_slice(); + let alpha = img.data.as_slice(); + (0..=w - size).step_by(step).filter_map(move |x| { + // Variance of the picture inside the box (the flattest boxes hold the least detail). + let (mut s, mut sq, mut n, mut opaque) = (0.0f32, 0.0f32, 0.0f32, true); + for yy in y..y + size { + for xx in x..x + size { + let v = data[yy * w + xx]; + s += v; + sq += v * v; + n += 1.0; + opaque &= alpha[yy * w + xx][3] > 0.5; + } } - } - if opaque { - cands.push((sq / n - (s / n).powi(2), x, y)); - } - } - } + opaque.then(|| (sq / n - (s / n).powi(2), x, y)) + }) + }) + .collect(); cands.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.2.cmp(&b.2)).then(a.1.cmp(&b.1))); cands.into_iter().take(count).map(|(_, x, y)| (x, y, size)).collect() } @@ -479,16 +484,7 @@ pub(crate) fn sampling_boxes(ctx: &EffectCtx, img: &Image, scale: f64) -> Vec<(u /// The grain level (0.5..2) measured in the sample boxes of `img`. fn grain_level(img: &Image, boxes: &[(usize, usize, usize)]) -> f32 { - let mut m = vec![false; img.data.len()]; - let w = img.width as usize; - for &(x, y, s) in boxes { - for yy in y..y + s { - for xx in x..x + s { - m[yy * w + xx] = true; - } - } - } - let st = crate::noise2::grain_stats_masked(img, Some(&m)); + let st = crate::noise2::grain_stats_boxes(img, boxes); let s = (st.std[0] + st.std[1] + st.std[2]) / 3.0; if boxes.is_empty() { 1.0 } else { (s / 0.02).clamp(0.5, 2.0) } } diff --git a/crates/effects/src/noise2.rs b/crates/effects/src/noise2.rs index 4c22f6d8f..48d840416 100644 --- a/crates/effects/src/noise2.rs +++ b/crates/effects/src/noise2.rs @@ -75,6 +75,102 @@ pub(crate) fn grain_stats_masked(img: &Image, mask: Option<&[bool]>) -> GrainSta GrainStats { std, size: (1.0 / (1.0 - rho as f32)).clamp(0.5, 20.0) * 0.6 } } +/// Grain statistics over sample `boxes` (in image pixels), measured from small crops around each +/// box instead of the whole image. Empty `boxes` measures every opaque pixel, like +/// [`grain_stats_masked`] without a mask. Overlapping boxes are deduplicated, so the sums match +/// the masked version. +pub(crate) fn grain_stats_boxes(img: &Image, boxes: &[(usize, usize, usize)]) -> GrainStats { + if boxes.is_empty() { + return grain_stats_masked(img, None); + } + let (w, h) = (img.width as usize, img.height as usize); + if w < 3 || h < 3 { + return GrainStats::default(); + } + // The Gaussian's three box passes at sigma 1.5 have radius 1 each, so 3 px of halo make the + // residual exact inside a box; one more column covers the right neighbour used by the lag-1 + // correlation. + const HALO: usize = 4; + let mut mask = vec![false; w * h]; + for &(x, y, s) in boxes { + for yy in y..y.saturating_add(s).min(h) { + for xx in x..x.saturating_add(s).min(w) { + mask[yy * w + xx] = true; + } + } + } + let mut counted = vec![false; w * h]; + let mut n = [0.0f64; 3]; + let mut sum = [0.0f64; 3]; + let mut sq = [0.0f64; 3]; + let mut rho_num = 0.0f64; + let mut rho_den = 0.0f64; + let mut crop: Vec<([f32; 3], f32)> = Vec::new(); + let mut planes: Vec = Vec::new(); + for &(bx, by, bs) in boxes { + let x0 = bx.saturating_sub(HALO); + let y0 = by.saturating_sub(HALO); + let x1 = bx.saturating_add(bs).saturating_add(HALO).min(w); + let y1 = by.saturating_add(bs).saturating_add(HALO).min(h); + if x1 <= x0 || y1 <= y0 { + continue; + } + let (cw, ch) = (x1 - x0, y1 - y0); + crop.clear(); + crop.extend((y0..y1).flat_map(|y| (x0..x1).map(move |x| unpremul(img.data[y * w + x])))); + planes.clear(); + for k in 0..3 { + let pl = Plane { w: cw, h: ch, data: crop.iter().map(|s| s.0[k]).collect() }; + planes.push(gauss_plane(&pl, 1.5, 1.5)); + } + for ly in 0..bs { + let y = by + ly; + if y >= h { + break; + } + for lx in 0..bs { + let x = bx + lx; + if x >= w { + break; + } + let g = y * w + x; + if !mask[g] || counted[g] { + continue; + } + counted[g] = true; + let l = (y - y0) * cw + (x - x0); + let (_, a) = crop[l]; + if a <= 0.5 { + continue; + } + for k in 0..3 { + let hp = crop[l].0[k] - planes[k].data[l]; + sum[k] += hp as f64; + sq[k] += (hp as f64) * (hp as f64); + n[k] += 1.0; + } + if x + 1 < w && mask[g + 1] && crop[l + 1].1 > 0.5 { + for k in 0..3 { + let hp = crop[l].0[k] - planes[k].data[l]; + let hn = crop[l + 1].0[k] - planes[k].data[l + 1]; + rho_num += (hp * hn) as f64; + rho_den += (hp * hp) as f64; + } + } + } + } + } + let mut std = [0.0f32; 3]; + for k in 0..3 { + if n[k] > 1.0 { + let mean = sum[k] / n[k]; + std[k] = ((sq[k] / n[k] - mean * mean).max(0.0)).sqrt() as f32; + } + } + let rho = if rho_den > 1e-12 { (rho_num / rho_den).clamp(0.0, 0.95) } else { 0.0 }; + GrainStats { std, size: (1.0 / (1.0 - rho as f32)).clamp(0.5, 20.0) * 0.6 } +} + /// Match Grain's Viewing Mode options. const MATCH_GRAIN_VIEWS: [&str; 5] = ["Preview", "Noise Samples", "Compensation Samples", "Blending Matte", "Final Output"]; @@ -86,34 +182,24 @@ fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { let view = ctx.params.e("viewingMode"); let source = layer_or_self(ctx, &b, "noiseSourceLayer", false, false); let src_boxes = sampling_boxes(ctx, &source, b.scale); - let own_boxes = sampling_boxes(ctx, &b.img, b.scale); let box_col = ctx.params.get("sampling/sampleBoxColor").map(|v| v.as_color()).unwrap_or([1.0, 1.0, 0.0, 1.0]); if view == 1 || view == 2 { // Noise Samples (on the source) / Compensation Samples (on this layer). - let (mut img, boxes) = if view == 1 { (source, src_boxes) } else { (b.img.clone(), own_boxes) }; + let (mut img, boxes) = if view == 1 { (source, src_boxes) } else { (b.img.clone(), sampling_boxes(ctx, &b.img, b.scale)) }; draw_boxes(&mut img, &boxes, box_col); b.img = img; return b; } - let mask_of = |img: &Image, boxes: &[(usize, usize, usize)]| -> Option> { - if boxes.is_empty() { - return None; - } - let w = img.width as usize; - let mut m = vec![false; img.data.len()]; - for &(x, y, s) in boxes { - for yy in y..y + s { - for xx in x..x + s { - m[yy * w + xx] = true; - } - } - } - Some(m) - }; - let src_stats = grain_stats_masked(&source, mask_of(&source, &src_boxes).as_deref()); + let src_stats = grain_stats_boxes(&source, &src_boxes); let comp = (ctx.params.f("compensateForExistingNoise") / 100.0) as f32; - let own = if comp > 0.0 { grain_stats_masked(&b.img, mask_of(&b.img, &own_boxes).as_deref()) } else { GrainStats::default() }; - let orig = b.img.clone(); + // The own-layer boxes only matter for the compensation term; skip them when it is off. + let own = if comp > 0.0 { + let own_boxes = sampling_boxes(ctx, &b.img, b.scale); + grain_stats_boxes(&b.img, &own_boxes) + } else { + GrainStats::default() + }; + let orig = (view == 0).then(|| b.img.clone()); let look = GrainLook::from_params(ctx); let amp: [f32; 3] = [0, 1, 2].map(|k| { let s = src_stats.std[k]; @@ -147,8 +233,8 @@ fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { } } }); - if view == 0 { - crate::noise::preview_compose(ctx, &mut b, &orig); + if let Some(orig) = &orig { + crate::noise::preview_compose(ctx, &mut b, orig); } b } @@ -306,6 +392,63 @@ mod tests { img } + #[test] + fn boxed_grain_stats_match_the_masked_ones() { + // Noisy image with alpha holes; the boxes cover overlapping, edge-adjacent and + // bottom-right corner cases (clamped crops). + let mut img = noisy(64, 48, 0.2, 9); + for y in 0..img.height { + for x in 0..img.width { + if (x / 7 + y / 5) % 6 == 0 { + let mut p = img.get(x as i64, y as i64); + p[3] = 0.0; + img.set(x, y, p); + } + } + } + let boxes = [(3usize, 4usize, 9usize), (8, 6, 9), (40, 20, 12), (39, 21, 12), (55, 39, 9)]; + let w = img.width as usize; + let mut mask = vec![false; img.data.len()]; + for &(x, y, s) in &boxes { + for yy in y..y + s { + for xx in x..x + s { + mask[yy * w + xx] = true; + } + } + } + let masked = grain_stats_masked(&img, Some(&mask)); + let boxed = grain_stats_boxes(&img, &boxes); + for k in 0..3 { + assert!((masked.std[k] - boxed.std[k]).abs() < 1e-6, "{k}: {masked:?} vs {boxed:?}"); + } + assert!((masked.size - boxed.size).abs() <= 1e-7, "size: {masked:?} vs {boxed:?}"); + // A denser grid where every box overlaps its neighbours (mask dedup across boxes). + let img2 = noisy(96, 64, 0.15, 21); + let mut boxes2 = Vec::new(); + for gy in 0..5 { + for gx in 0..6 { + boxes2.push((gx * 11 + (gy % 3) * 4, gy * 10 + gx % 2, 12)); + } + } + let w2 = img2.width as usize; + let mut mask2 = vec![false; img2.data.len()]; + for &(x, y, s) in &boxes2 { + for yy in y..y + s { + for xx in x..x + s { + mask2[yy * w2 + xx] = true; + } + } + } + let masked2 = grain_stats_masked(&img2, Some(&mask2)); + let boxed2 = grain_stats_boxes(&img2, &boxes2); + for k in 0..3 { + assert!((masked2.std[k] - boxed2.std[k]).abs() < 1e-6, "{k}: {masked2:?} vs {boxed2:?}"); + } + assert!((masked2.size - boxed2.size).abs() <= 1e-7, "size: {masked2:?} vs {boxed2:?}"); + // No boxes measures the whole image, like no mask. + assert_eq!(grain_stats_boxes(&img, &[]), grain_stats(&img)); + } + #[test] fn match_grain_transfers_grain_strength() { let flat = Image::filled(48, 48, [0.5, 0.5, 0.5, 1.0]); diff --git a/crates/effects/src/noise3.rs b/crates/effects/src/noise3.rs index baa5fb50a..49f200553 100644 --- a/crates/effects/src/noise3.rs +++ b/crates/effects/src/noise3.rs @@ -60,6 +60,11 @@ pub const BLEND_NORMAL: u32 = 1; /// Index of "Soft Linear" in [`NOISE_TYPES`] (the default, and the GPU path's interpolation). pub const NOISE_SOFT_LINEAR: u32 = 2; +/// Lattice cell of Fractal / Turbulent Noise in pixels per percent of Scale: After Effects 2026 +/// draws Block noise at Scale 100 in 66.67 px cells (and Soft Linear noise has the matching +/// correlation length), not 100 px. +pub const NOISE_CELL: f64 = 2.0 / 3.0; + fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { EffectSpec { id, name, category: "Noise & Grain", params, render, gpu: false, float: true } } @@ -236,7 +241,7 @@ impl Fractal { overflow: pr.e("overflow"), invert: pr.b("invert"), rot: rot.sin_cos(), - size: [(sw * b.scale).max(1.0) as f32, (sh * b.scale).max(1.0) as f32], + size: [(sw * NOISE_CELL * b.scale).max(1.0) as f32, (sh * NOISE_CELL * b.scale).max(1.0) as f32], origin: [ox as f32, oy as f32], center: [cx as f32, cy as f32], perspective: pr.b("transform/perspectiveOffset"), @@ -333,7 +338,7 @@ impl Fractal { } /// Fractal / Turbulent Noise resolved for the GPU kernel (effectcraft-gpu `fx_noise`): the -/// per-pixel constants and, per octave, the terms [`Fractal::raw`] computes before touching the +/// per-pixel constants and, per octave, the terms `Fractal::raw` computes before touching the /// pixel, in the same f32 order. pub struct FractalGpu { pub kind: u32, @@ -535,14 +540,34 @@ mod tests { #[test] fn noise_types_differ_and_block_is_piecewise_constant() { - let a = run("ec.noise.fractal", &[("noiseType", Value::Enum(0)), ("complexity", num(1.0)), ("transform/scale", num(10.0))]); - // Block noise: neighbours inside one 10 px cell share a value. + let a = run("ec.noise.fractal", &[("noiseType", Value::Enum(0)), ("complexity", num(1.0)), ("transform/scale", num(15.0))]); + // Block noise: neighbours inside one 10 px cell (Scale 15) share a value. assert_eq!(a.get(4, 4), a.get(5, 5)); let others: Vec = (1..4).map(|t| run("ec.noise.fractal", &[("noiseType", Value::Enum(t)), ("transform/scale", num(10.0))])).collect(); assert_ne!(others[0], others[1]); assert_ne!(others[1], others[2]); } + /// After Effects 2026 (R9): Block noise at Scale 100, Complexity 1 changes value every + /// 66.67 px along a row. + #[test] + fn block_noise_cells_are_two_thirds_of_scale_as_in_after_effects() { + let img = run_fx( + "ec.noise.fractal", + &[("noiseType", Value::Enum(0)), ("complexity", num(1.0)), ("blendingMode", Value::Enum(BLEND_NONE))], + Image::filled(400, 8, [0.0, 0.0, 0.0, 1.0]), + 0.0, + EffectEnv::default(), + ) + .img; + let changes: Vec = (1..400).filter(|&x| img.get(x, 4) != img.get(x - 1, 4)).collect(); + assert!(changes.len() >= 4, "{changes:?}"); + for w in changes.windows(2) { + let d = (w[1] - w[0]) as f64; + assert!((d - 200.0 / 3.0).abs() <= 1.0, "cell width {d}: {changes:?}"); + } + } + #[test] fn scale_width_height_apply_without_uniform_scaling() { let base = &[("complexity", num(1.0)), ("transform/scale", num(8.0))]; diff --git a/crates/effects/src/object_matte.rs b/crates/effects/src/object_matte.rs index d98ec73ad..8a9750282 100644 --- a/crates/effects/src/object_matte.rs +++ b/crates/effects/src/object_matte.rs @@ -144,7 +144,7 @@ pub fn prompt_strokes(params: &Params, layer_size: [f64; 2], frame: i64, with_mo if !inside(pt) { return None; } - let mut out = vec![Stroke { kind: StrokeKind::Fg, frame, radius: POINT_RADIUS, points: vec![pt] }]; + let mut out = vec![Stroke { kind: StrokeKind::Fg, frame, radius: POINT_RADIUS, points: vec![pt], object: 0 }]; let rect = |x0: f64, y0: f64, x1: f64, y1: f64| vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]; let boxed = b(params, "useBox").then(|| Some((v2(params, "boxTopLeft")?, v2(params, "boxBottomRight")?))).flatten(); match boxed { @@ -158,9 +158,9 @@ pub fn prompt_strokes(params: &Params, layer_size: [f64; 2], frame: i64, with_mo } out.push(if with_model { // The model takes the box itself as its box prompt. - Stroke { kind: StrokeKind::Box, frame, radius: 0.0, points: vec![[x0, y0], [x1, y1]] } + Stroke { kind: StrokeKind::Box, frame, radius: 0.0, points: vec![[x0, y0], [x1, y1]], object: 0 } } else { - Stroke { kind: StrokeKind::Bg, frame, radius: EDGE_RADIUS, points: rect(x0, y0, x1, y1) } + Stroke { kind: StrokeKind::Bg, frame, radius: EDGE_RADIUS, points: rect(x0, y0, x1, y1), object: 0 } }); } None if !with_model => { @@ -168,7 +168,7 @@ pub fn prompt_strokes(params: &Params, layer_size: [f64; 2], frame: i64, with_mo if w <= 4.0 * i || h <= 4.0 * i { return None; } - out.push(Stroke { kind: StrokeKind::Bg, frame, radius: EDGE_RADIUS, points: rect(i, i, w - i, h - i) }); + out.push(Stroke { kind: StrokeKind::Bg, frame, radius: EDGE_RADIUS, points: rect(i, i, w - i, h - i), object: 0 }); } None => {} } @@ -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 } @@ -222,7 +227,7 @@ pub fn job_frames(params: &Params, limits: [i64; 2]) -> Vec { pub fn chain(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, scale: f64, model: Option>) -> Option { let data = roto_data(params, layer_size, f, model.is_some())?; let keys = data.chain_keys(seed(params, layer_size, fps, model.as_deref())); - Some(Chain { data: Arc::new(data), keys, opts: seg_opts(scale), refine_radius: 0.0, model }) + Some(Chain { data: Arc::new(data), keys, opts: seg_opts(scale), refine_radius: 0.0, model, saved: None, show_object: 0 }) } /// Frame `f`'s segmentation at full resolution, computing what is missing (the background @@ -255,9 +260,14 @@ pub fn frame_keys(params: &Params, layer_size: [f64; 2], fps: f64, frames: &[i64 /// What frozen mattes depend on: what the frames are (the seed: Input Key, size, rate, mode, /// model), the prompt, the tracked span and the matte settings. pub fn frozen_key(params: &Params, layer_size: [f64; 2], fps: f64, model: Option<&dyn MaskModel>) -> String { - let mut h = seed(params, layer_size, fps, model); + frozen_key_of(params, layer_size, fps, model.map_or(effectcraft_segment::CLASSICAL, |m| m.info().id)) +} + +/// [`frozen_key`] for the model with id `model` ([`effectcraft_segment::CLASSICAL`]: no model). +fn frozen_key_of(params: &Params, layer_size: [f64; 2], fps: f64, model: &str) -> String { + let mut h = seed_of(params, layer_size, fps, model); let base = reference_frame(params); - if let Some(st) = prompt_strokes(params, layer_size, base, model.is_some()) { + if let Some(st) = prompt_strokes(params, layer_size, base, model != effectcraft_segment::CLASSICAL) { fnv(&mut h, serde_json::to_string(&st).unwrap_or_default().as_bytes()); } for v in [base, reach(params)[0], reach(params)[1]] { @@ -273,7 +283,9 @@ pub fn is_frozen(params: &Params) -> bool { } /// Frame `f`'s frozen matte on a `w`×`h` grid, when the instance is frozen, the mattes still -/// match ([`frozen_key`]) and `f` is one of them. +/// match ([`frozen_key`]) and `f` is one of them. The mattes match under the session's `model` +/// or under the model that froze them (which this session may not have loaded, e.g. a fresh +/// headless render): frozen mattes need no segmentation. pub fn frozen_alpha(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, w: usize, h: usize, model: Option<&dyn MaskModel>) -> Option> { let j = s(params, FROZEN); if j.is_empty() { @@ -281,7 +293,10 @@ pub fn frozen_alpha(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, w: } let fc = frozen_cache(j)?; if fc.key != frozen_key(params, layer_size, fps, model) { - return None; + let ids = std::iter::once(effectcraft_segment::CLASSICAL).chain(effectcraft_segment::MODELS.iter().map(|m| m.id)); + if !ids.into_iter().any(|id| fc.key == frozen_key_of(params, layer_size, fps, id)) { + return None; + } } let fr = fc.frames.get(&f)?; let [fw, fh] = fc.size; @@ -358,20 +373,24 @@ pub enum FrameMatte { Outside, } -/// The matte of layer frame `f` on the layer grid of `img` (the effect's input on that frame at -/// `scale`), computing what is missing. `frame(g)`: the input on frame g on the same grid. -#[allow(clippy::too_many_arguments)] +/// The layer grid and timing used to compute one object matte. +pub struct MatteFrame<'a> { + pub layer_size: [f64; 2], + pub fps: f64, + pub frame: i64, + pub scale: f64, + pub img: &'a Image, +} + +/// Compute the object's alpha for one layer frame, loading other frames on the same grid as needed. pub fn frame_matte( params: &Params, - layer_size: [f64; 2], - fps: f64, - f: i64, - scale: f64, - img: &Image, + input: &MatteFrame<'_>, model: Option>, frame: &mut dyn FnMut(i64) -> Option>, cancel: &dyn Fn() -> bool, ) -> FrameMatte { + let MatteFrame { layer_size, fps, frame: f, scale, img } = *input; let Some(chain) = chain(params, layer_size, fps, f, scale, model) else { return FrameMatte::NoPrompt }; if !chain.data.in_span(f) { return FrameMatte::Outside; @@ -408,7 +427,7 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf { if let Some(a) = frozen_alpha(ctx.params, layer_size, fps, f, w, h, model.as_deref()) { return compose(&buf, Some(&a), w, h, b(ctx.params, "invert"), param(ctx.params, "view").map(Value::as_enum).unwrap_or(0) == 1); } - let alpha = match frame_matte(ctx.params, layer_size, fps, f, sc, &img, model, &mut frame, &|| false) { + let alpha = match frame_matte(ctx.params, &MatteFrame { layer_size, fps, frame: f, scale: sc, img: &img }, model, &mut frame, &|| false) { FrameMatte::Alpha(a) if a.len() == w * h => Some(a), FrameMatte::NoPrompt => return buf, _ => None, @@ -608,7 +627,11 @@ mod tests { assert_ne!(seed(&p, [100.0, 80.0], 25.0, Some(stub.as_ref())), seed(&p, [100.0, 80.0], 25.0, None)); let img = frame_at(0); let mut frame = |g: i64| Some(Arc::new(frame_at(g))); - let FrameMatte::Alpha(a) = frame_matte(&p, [100.0, 80.0], 25.0, 0, 1.0, &img, Some(stub.clone()), &mut frame, &|| false) else { panic!("no matte") }; + let FrameMatte::Alpha(a) = + frame_matte(&p, &MatteFrame { layer_size: [100.0, 80.0], fps: 25.0, frame: 0, scale: 1.0, img: &img }, Some(stub.clone()), &mut frame, &|| false) + else { + panic!("no matte") + }; assert!(a[40 * 100 + 40] > 0.5 && a[5 * 100 + 5] < 0.5); assert_eq!(stub.calls.load(std::sync::atomic::Ordering::Relaxed), 1); assert_eq!(*stub.bbox.lock().unwrap(), None); @@ -634,7 +657,11 @@ mod tests { let stub = Arc::new(Stub::default()); let img = frame_at(0); let mut frame = |g: i64| Some(Arc::new(frame_at(g))); - let FrameMatte::Alpha(a) = frame_matte(&p, [100.0, 80.0], 25.0, 0, 1.0, &img, Some(stub.clone()), &mut frame, &|| false) else { panic!("no matte") }; + let FrameMatte::Alpha(a) = + frame_matte(&p, &MatteFrame { layer_size: [100.0, 80.0], fps: 25.0, frame: 0, scale: 1.0, img: &img }, Some(stub.clone()), &mut frame, &|| false) + else { + panic!("no matte") + }; assert_eq!(*stub.bbox.lock().unwrap(), Some([74.0, 54.0, 100.0, 80.0])); assert!(a[70 * 100 + 90] > 0.5 && a[40 * 100 + 40] < 0.5); // At half resolution the box is scaled with the frame (another Input Key, so the @@ -643,7 +670,8 @@ mod tests { p.values.insert(INPUT_KEY.into(), Value::Str("half".into())); let half = effectcraft_raster::resample(&img, 50, 40); let mut frame = |g: i64| Some(Arc::new(effectcraft_raster::resample(&frame_at(g), 50, 40))); - let _ = frame_matte(&p, [100.0, 80.0], 25.0, 0, 0.5, &half, Some(stub.clone()), &mut frame, &|| false); + let _ = + frame_matte(&p, &MatteFrame { layer_size: [100.0, 80.0], fps: 25.0, frame: 0, scale: 0.5, img: &half }, Some(stub.clone()), &mut frame, &|| false); assert_eq!(*stub.bbox.lock().unwrap(), Some([37.0, 27.0, 50.0, 40.0])); } @@ -692,4 +720,28 @@ mod tests { p.values.insert(FROZEN.into(), Value::Str(fc2.to_json())); assert_eq!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 100, 80, None), None); } + + /// Mattes frozen with a model stay usable in a session where that model is not loaded (a + /// fresh headless render): they are used as stored, not re-segmented with another model. + #[test] + fn frozen_mattes_survive_a_session_without_their_model() { + let mut p = params(&[ + ("objectPoint", Value::Vec2([40.0, 40.0])), + ("useBox", Value::Bool(true)), + ("boxTopLeft", Value::Vec2([10.0, 10.0])), + ("boxBottomRight", Value::Vec2([90.0, 70.0])), + ("objectTracking/framesAfter", Value::Scalar(3.0)), + ]); + p.values.insert(INPUT_KEY.into(), Value::Str("frozen-model-test".into())); + let stub = Stub::default(); + let alpha: Vec = (0..100 * 80).map(|i| ((i % 100) < 50) as u8 as f32).collect(); + let mut fc = FrozenCache { key: frozen_key(&p, [100.0, 80.0], 25.0, Some(&stub)), size: [100, 80], ..Default::default() }; + fc.frames.insert(2, roto::freeze_frame(&alpha, &[])); + p.values.insert(FROZEN.into(), Value::Str(fc.to_json())); + assert!(is_frozen(&p)); + assert!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 100, 80, Some(&stub)).is_some()); + assert_eq!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 50, 40, None).map(|a| (a[20 * 50 + 5], a[20 * 50 + 45])), Some((1.0, 0.0))); + let out = run(&p, 2); + assert!(out.data[40 * 100 + 10][3] > 0.99 && out.data[40 * 100 + 90][3] < 0.01, "the frozen matte is used"); + } } diff --git a/crates/effects/src/ocio.rs b/crates/effects/src/ocio.rs index e84720a32..2daa9857d 100644 --- a/crates/effects/src/ocio.rs +++ b/crates/effects/src/ocio.rs @@ -37,1802 +37,31 @@ use crate::util::unpremul; use crate::utility::{Lut, parse_cube}; use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider}; -type M3 = [[f64; 3]; 3]; +mod cdl; +mod effects; +mod lut; +mod program; +mod registry; +mod space; -pub(crate) const D65: [f64; 2] = [0.3127, 0.3290]; -const ACES_WHITE: [f64; 2] = [0.32168, 0.33767]; -const AP0: [[f64; 2]; 3] = [[0.7347, 0.2653], [0.0, 1.0], [0.0001, -0.0770]]; -const AP1: [[f64; 2]; 3] = [[0.713, 0.293], [0.165, 0.830], [0.128, 0.044]]; -pub(crate) const REC709: [[f64; 2]; 3] = [[0.640, 0.330], [0.300, 0.600], [0.150, 0.060]]; -pub(crate) const REC2020: [[f64; 2]; 3] = [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]]; -pub(crate) const P3: [[f64; 2]; 3] = [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]]; -const IDENTITY: M3 = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; - -/// Transfer functions (encoded ↔ linear). -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum Tf { - Linear, - Srgb, - Gamma(f64), - AcesCct, - /// ACEScc (Academy S-2014-003), the pure-log grading encoding. - AcesCc, - /// SMPTE ST 2084 with 1.0 = 100 cd/m². - Pq, -} - -/// ACEScc: the lowest code value (linear 0 and below) and the end of its toe (linear 2⁻¹⁵). -const ACESCC_MIN: f64 = (-16.0 + 9.72) / 17.52; -const ACESCC_TOE: f64 = (-15.0 + 9.72) / 17.52; - -impl Tf { - pub fn decode(self, v: f64) -> f64 { - match self { - Tf::Linear => v, - Tf::Srgb => { - let a = v.abs(); - let l = if a <= 0.04045 { a / 12.92 } else { ((a + 0.055) / 1.055).powf(2.4) }; - l.copysign(v) - } - Tf::Gamma(g) => v.abs().powf(g).copysign(v), - Tf::AcesCct => { - if v <= 0.155_251_141_552_511 { - (v - 0.072_905_534_195_835_5) / 10.540_237_741_654_5 - } else if v < (65504f64.log2() + 9.72) / 17.52 { - 2f64.powf(v * 17.52 - 9.72) - } else { - 65504.0 - } - } - Tf::AcesCc => { - if v < ACESCC_TOE { - (2f64.powf(v * 17.52 - 9.72) - 2f64.powi(-16)) * 2.0 - } else if v < (65504f64.log2() + 9.72) / 17.52 { - 2f64.powf(v * 17.52 - 9.72) - } else { - 65504.0 - } - } - Tf::Pq => { - let (m1, m2, c1, c2, c3) = pq_consts(); - let e = v.max(0.0).powf(1.0 / m2); - let l = ((e - c1).max(0.0) / (c2 - c3 * e)).powf(1.0 / m1); - l * 100.0 - } - } - } - pub fn encode(self, v: f64) -> f64 { - match self { - Tf::Linear => v, - Tf::Srgb => { - let a = v.abs(); - let e = if a <= 0.003_130_8 { a * 12.92 } else { 1.055 * a.powf(1.0 / 2.4) - 0.055 }; - e.copysign(v) - } - Tf::Gamma(g) => v.abs().powf(1.0 / g).copysign(v), - Tf::AcesCct => { - if v <= 0.007_812_5 { - 10.540_237_741_654_5 * v + 0.072_905_534_195_835_5 - } else { - (v.log2() + 9.72) / 17.52 - } - } - Tf::AcesCc => { - if v <= 0.0 { - ACESCC_MIN - } else if v < 2f64.powi(-15) { - ((2f64.powi(-16) + v * 0.5).log2() + 9.72) / 17.52 - } else { - (v.log2() + 9.72) / 17.52 - } - } - Tf::Pq => { - let (m1, m2, c1, c2, c3) = pq_consts(); - let y = (v / 100.0).max(0.0).powf(m1); - ((c1 + c2 * y) / (1.0 + c3 * y)).powf(m2) - } - } - } -} - -fn pq_consts() -> (f64, f64, f64, f64, f64) { - (2610.0 / 16384.0, 2523.0 / 4096.0 * 128.0, 3424.0 / 4096.0, 2413.0 / 4096.0 * 32.0, 2392.0 / 4096.0 * 32.0) -} - -/// A colour space of the built-in config. -#[derive(Clone, Copy, Debug)] -pub struct Space { - pub name: &'static str, - /// Primaries and white (None = CIE XYZ itself). - pub prims: Option<([[f64; 2]; 3], [f64; 2])>, - pub tf: Tf, - /// Passes data through untouched. - pub raw: bool, -} - -pub(crate) const fn sp(name: &'static str, prims: [[f64; 2]; 3], white: [f64; 2], tf: Tf) -> Space { - Space { name, prims: Some((prims, white)), tf, raw: false } -} - -/// The built-in configuration's colour spaces, in popup order. -pub(crate) const ACES2065_1: Space = sp("ACES2065-1", AP0, ACES_WHITE, Tf::Linear); -pub(crate) const ACES_CG: Space = sp("ACEScg", AP1, ACES_WHITE, Tf::Linear); -const ACES_CCT: Space = sp("ACEScct", AP1, ACES_WHITE, Tf::AcesCct); - -pub const SPACES: &[Space] = &[ - ACES2065_1, - ACES_CG, - ACES_CCT, - sp("Linear Rec.709 (sRGB)", REC709, D65, Tf::Linear), - sp("sRGB", REC709, D65, Tf::Srgb), - sp("Gamma 2.4 Rec.709", REC709, D65, Tf::Gamma(2.4)), - sp("Linear Rec.2020", REC2020, D65, Tf::Linear), - sp("Gamma 2.4 Rec.2020", REC2020, D65, Tf::Gamma(2.4)), - sp("Linear P3-D65", P3, D65, Tf::Linear), - sp("Display P3", P3, D65, Tf::Srgb), - Space { name: "CIE-XYZ-D65", prims: None, tf: Tf::Linear, raw: false }, - Space { name: "Raw", prims: None, tf: Tf::Linear, raw: true }, -]; - -pub fn space_names() -> Vec<&'static str> { - SPACES.iter().map(|s| s.name).collect() -} +pub use cdl::Cdl; +pub(crate) use effects::CUSTOM_CONFIG_FX; +#[cfg(test)] +pub use effects::parse_looks; +pub use effects::{color_stabilizer_maps, warning}; +#[cfg(test)] +use effects::{tonemap, tonemap_inv}; +pub use lut::{FileXform, load_file}; +#[cfg(test)] +pub use lut::{parse_file, parse_spi1d, parse_spi3d, parse_spimtx}; +pub use program::{ColorOp, Straight, color_program}; +pub use registry::specs; +#[cfg(test)] +use space::M3; +pub(crate) use space::{ACES_CG, ACES2065_1, D65, P3, REC709, REC2020, sp}; +#[cfg(test)] +pub use space::{SPACES, Xform, bradford, space_by_name}; +pub use space::{Space, Tf, matrix}; #[cfg(test)] -pub fn space_by_name(n: &str) -> Option<&'static Space> { - SPACES.iter().find(|s| s.name.eq_ignore_ascii_case(n)) -} - -/// Bradford chromatic adaptation from white `src` to white `dst` (xy). -pub fn bradford(src: [f64; 2], dst: [f64; 2]) -> M3 { - if src == dst { - return IDENTITY; - } - const MB: M3 = [[0.8951, 0.2664, -0.1614], [-0.7502, 1.7135, 0.0367], [0.0389, -0.0685, 1.0296]]; - let xyz = |c: [f64; 2]| [c[0] / c[1], 1.0, (1.0 - c[0] - c[1]) / c[1]]; - let s = mul_vec(&MB, xyz(src)); - let d = mul_vec(&MB, xyz(dst)); - let diag = [[d[0] / s[0], 0.0, 0.0], [0.0, d[1] / s[1], 0.0], [0.0, 0.0, d[2] / s[2]]]; - mul(&invert(&MB), &mul(&diag, &MB)) -} - -impl Space { - /// Linear RGB → XYZ D65 (Bradford-adapted when `adapt`, else the space's own white). - pub fn to_ref(self, adapt: bool) -> M3 { - match self.prims { - None => IDENTITY, - Some((p, w)) => { - let m = rgb_to_xyz(p, w); - if adapt { mul(&bradford(w, D65), &m) } else { m } - } - } - } -} - -/// Linear-RGB matrix from space `a` to space `b`. -pub fn matrix(a: &Space, b: &Space) -> M3 { - mul(&invert(&b.to_ref(true)), &a.to_ref(true)) -} - -/// A compiled colour-space conversion. -#[derive(Clone, Copy, Debug)] -pub struct Xform { - dec: Tf, - m: [[f32; 3]; 3], - ident: bool, - enc: Tf, -} - -impl Xform { - pub fn new(a: &Space, b: &Space) -> Xform { - if a.raw || b.raw { - return Xform { dec: Tf::Linear, m: IDENTITY.map(|r| r.map(|v| v as f32)), ident: true, enc: Tf::Linear }; - } - let m = matrix(a, b); - let ident = (0..3).all(|i| (0..3).all(|j| (m[i][j] - IDENTITY[i][j]).abs() < 1e-12)); - Xform { dec: a.tf, m: m.map(|r| r.map(|v| v as f32)), ident, enc: b.tf } - } - #[inline] - pub fn apply(&self, c: [f32; 3]) -> [f32; 3] { - let l = c.map(|v| self.dec.decode(v as f64) as f32); - let l = if self.ident { l } else { [0, 1, 2].map(|i| self.m[i][0] * l[0] + self.m[i][1] * l[1] + self.m[i][2] * l[2]) }; - l.map(|v| self.enc.encode(v as f64) as f32) - } -} - -// ---------------------------------------------------------------- ASC CDL - -/// An ASC Colour Decision List correction. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Cdl { - pub slope: [f64; 3], - pub offset: [f64; 3], - pub power: [f64; 3], - pub sat: f64, -} - -impl Default for Cdl { - fn default() -> Self { - Cdl { slope: [1.0; 3], offset: [0.0; 3], power: [1.0; 3], sat: 1.0 } - } -} - -const LUMA: [f64; 3] = [0.2126, 0.7152, 0.0722]; - -impl Cdl { - /// Forward CDL. `clamp` = the v1.2 style (results clamped to 0..1 after SOP and saturation). - pub fn apply(&self, c: [f64; 3], clamp: bool) -> [f64; 3] { - let mut o = [0.0; 3]; - for i in 0..3 { - let v = c[i] * self.slope[i] + self.offset[i]; - o[i] = if clamp { - v.clamp(0.0, 1.0).powf(self.power[i]) - } else if v >= 0.0 { - v.powf(self.power[i]) - } else { - v - }; - } - let l = o[0] * LUMA[0] + o[1] * LUMA[1] + o[2] * LUMA[2]; - let o = o.map(|v| l + self.sat * (v - l)); - if clamp { o.map(|v| v.clamp(0.0, 1.0)) } else { o } - } - /// Exact inverse of [`Cdl::apply`] (inside the clamped range). - pub fn invert(&self, c: [f64; 3], clamp: bool) -> [f64; 3] { - let c = if clamp { c.map(|v| v.clamp(0.0, 1.0)) } else { c }; - let l = c[0] * LUMA[0] + c[1] * LUMA[1] + c[2] * LUMA[2]; - let s = if self.sat.abs() < 1e-9 { 1e-9 } else { self.sat }; - let o = c.map(|v| l + (v - l) / s); - let mut r = [0.0; 3]; - for i in 0..3 { - let v = if clamp { o[i].clamp(0.0, 1.0) } else { o[i] }; - let pw = self.power[i].max(1e-9); - let u = if v >= 0.0 { v.powf(1.0 / pw) } else { v }; - let sl = if self.slope[i].abs() < 1e-9 { 1e-9 } else { self.slope[i] }; - r[i] = (u - self.offset[i]) / sl; - } - if clamp { r.map(|v| v.clamp(0.0, 1.0)) } else { r } - } -} - -fn tag<'a>(xml: &'a str, name: &str) -> Option<&'a str> { - let open = xml.find(&format!("<{name}"))?; - let gt = open + xml[open..].find('>')?; - if xml[..gt].ends_with('/') { - return Some(""); - } - let close = xml[gt..].find(&format!(""))? + gt; - Some(&xml[gt + 1..close]) -} - -fn nums3(s: &str) -> Option<[f64; 3]> { - let v: Vec = s.split_whitespace().filter_map(|t| t.parse().ok()).collect(); - (v.len() >= 3).then(|| [v[0], v[1], v[2]]) -} - -fn parse_cc(block: &str) -> Cdl { - let mut c = Cdl::default(); - if let Some(s) = tag(block, "Slope").and_then(nums3) { - c.slope = s; - } - if let Some(s) = tag(block, "Offset").and_then(nums3) { - c.offset = s; - } - if let Some(s) = tag(block, "Power").and_then(nums3) { - c.power = s; - } - if let Some(s) = tag(block, "Saturation").and_then(|t| t.trim().parse().ok()) { - c.sat = s; - } - c -} - -/// Parse ASC CDL XML (`.cc`, `.ccc`, `.cdl`): every `` with its `id`. -pub fn parse_cdl_xml(xml: &str) -> Vec<(String, Cdl)> { - let mut out = vec![]; - let mut rest = xml; - while let Some(i) = rest.find("').unwrap_or(after.len()); - let head = &after[..head_end]; - let id = head - .find("id=") - .and_then(|k| { - let q = head[k + 3..].chars().next()?; - let s = &head[k + 4..]; - s.find(q).map(|e| s[..e].to_string()) - }) - .unwrap_or_default(); - let end = after.find("").map(|e| e + "".len()).unwrap_or(after.len()); - out.push((id, parse_cc(&after[..end]))); - rest = &after[end..]; - } - out -} - -// ---------------------------------------------------------------- LUT files - -/// A loaded transform file. -#[derive(Clone, Debug)] -pub enum FileXform { - /// A lattice/1D LUT with an optional per-channel piecewise-linear shaper (cineSpace pre-LUT). - Lut { lut: Lut, shaper: Option<[Vec<(f32, f32)>; 3]> }, - /// CDL corrections by id (in file order). - Cdl(Vec<(String, Cdl)>), - /// A matrix with offset (Sony Imageworks `.spimtx`), as a MatrixTransform's. - Matrix { m: [f64; 16], offset: [f64; 4] }, -} - -/// Parse a Sony Imageworks `.spimtx` matrix: three rows of four numbers, three matrix columns -/// and an offset in 16-bit code values (÷ 65535). -pub fn parse_spimtx(text: &str) -> Option { - let v: Vec = text.split_whitespace().map(|t| t.parse().ok()).collect::>()?; - if v.len() != 12 || v.iter().any(|x| !x.is_finite()) { - return None; - } - let mut m = [0.0; 16]; - let mut offset = [0.0; 4]; - for (r, row) in v.as_chunks::<4>().0.iter().enumerate() { - m[r * 4..r * 4 + 3].copy_from_slice(&row[..3]); - offset[r] = row[3] / 65535.0; - } - m[15] = 1.0; - Some(FileXform::Matrix { m, offset }) -} - -/// Parse a Sony Imageworks `.spi1d` 1D LUT: `From `, `Length `, `Components -/// <1 | 3>`, then `{` the values `}`. -pub fn parse_spi1d(text: &str) -> Option { - let (mut from, mut len, mut comps) = ([0.0f32, 1.0], 0usize, 1usize); - let mut values: Vec = vec![]; - let mut body = false; - for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) { - if body { - if line.starts_with('}') { - break; - } - for t in line.split_whitespace() { - values.push(t.parse().ok()?); - } - continue; - } - let mut t = line.split_whitespace(); - match t.next()? { - "{" => body = true, - "From" => from = [t.next()?.parse().ok()?, t.next()?.parse().ok()?], - "Length" => len = t.next()?.parse().ok()?, - "Components" => comps = t.next()?.parse().ok()?, - _ => {} - } - } - // A 1D table is interpolated by index, so cap it like the lattices (2²⁴ entries). - if !(2..=1 << 24).contains(&len) - || !matches!(comps, 1 | 3) - || values.len() != len.checked_mul(comps)? - || from[1] <= from[0] - || from.iter().any(|v| !v.is_finite()) - { - return None; - } - let d1 = values.chunks_exact(comps).map(|c| if comps == 3 { [c[0], c[1], c[2]] } else { [c[0]; 3] }).collect(); - Some(Lut { n1: len, d1, n3: 0, d3: vec![], dmin: [from[0]; 3], dmax: [from[1]; 3] }) -} - -/// Parse a Sony Imageworks `.spi3d` lattice: `SPILUT 1.0`, `3 3`, the sizes, then one -/// `r g b R G B` line per lattice point (indices, then the colour). -pub fn parse_spi3d(text: &str) -> Option { - let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty()); - if !lines.next()?.starts_with("SPILUT") { - return None; - } - lines.next()?; - let size: Vec = lines.next()?.split_whitespace().map(|t| t.parse().ok()).collect::>()?; - let n = *size.first()?; - if size.len() != 3 || size.iter().any(|s| *s != n) || !(2..=129).contains(&n) { - return None; - } - let mut d3 = vec![[0.0f32; 3]; n * n * n]; - let mut seen = 0usize; - for line in lines { - let v: Vec<&str> = line.split_whitespace().collect(); - let [r, g, b, cr, cg, cb] = v.as_slice() else { return None }; - let (r, g, b): (usize, usize, usize) = (r.parse().ok()?, g.parse().ok()?, b.parse().ok()?); - if r >= n || g >= n || b >= n { - return None; - } - // Our lattice is red fastest. - *d3.get_mut(r + g * n + b * n * n)? = [cr.parse().ok()?, cg.parse().ok()?, cb.parse().ok()?]; - seen += 1; - } - (seen == n * n * n).then_some(Lut { n1: 0, d1: vec![], n3: n, d3, dmin: [0.0; 3], dmax: [1.0; 3] }) -} - -/// Parse an Autodesk `.3dl` lattice (integers, blue fastest; an optional input-mesh line). -pub fn parse_3dl(text: &str) -> Option { - let mut mesh: Option> = None; - let mut rows: Vec<[f64; 3]> = vec![]; - let mut out_bits: Option = None; - for line in text.lines() { - let l = line.trim(); - if l.is_empty() || l.starts_with('#') || l.starts_with('<') { - continue; - } - if l.starts_with(|c: char| c.is_ascii_alphabetic()) { - // "Mesh " (Lustre header); other keywords are ignored. - let t: Vec<&str> = l.split_whitespace().collect(); - if t[0].eq_ignore_ascii_case("mesh") && t.len() >= 3 { - out_bits = t[2].parse().ok(); - } - continue; - } - let v: Vec = l.split_whitespace().filter_map(|t| t.parse().ok()).collect(); - if v.len() > 3 && mesh.is_none() && rows.is_empty() { - mesh = Some(v); - } else if v.len() == 3 { - rows.push([v[0], v[1], v[2]]); - } else { - return None; - } - } - let is_cube = |k: usize| { - let n = (k as f64).cbrt().round() as usize; - n * n * n == k - }; - // A 3-entry input mesh (3³ lattice) looks like a data row: tell them apart by the count. - if mesh.is_none() && !is_cube(rows.len()) && rows.len() == 28 { - mesh = Some(rows.remove(0).to_vec()); - } - let n = match &mesh { - Some(m) => m.len(), - None => (rows.len() as f64).cbrt().round() as usize, - }; - if n < 2 || rows.len() != n * n * n { - return None; - } - let maxv = rows.iter().flat_map(|r| r.iter()).fold(0.0f64, |m, &v| m.max(v)); - let out_max = match out_bits { - Some(b) if b > 0 && b < 32 => (1u64 << b) as f64 - 1.0, - _ => [255.0, 1023.0, 4095.0, 65535.0].into_iter().find(|&m| maxv <= m).unwrap_or(maxv.max(1.0)), - }; - // .3dl rows: red slowest, blue fastest. Our lattice is red fastest. - let mut d3 = vec![[0.0f32; 3]; n * n * n]; - for (k, r) in rows.iter().enumerate() { - let (ri, gi, bi) = (k / (n * n), (k / n) % n, k % n); - d3[ri + gi * n + bi * n * n] = r.map(|v| (v / out_max) as f32); - } - Some(Lut { n1: 0, d1: vec![], n3: n, d3, dmin: [0.0; 3], dmax: [1.0; 3] }) -} - -/// Parse a cineSpace `.csp` LUT (1D or 3D, with its three pre-LUT shapers). -pub fn parse_csp(text: &str) -> Option<(Lut, [Vec<(f32, f32)>; 3])> { - let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty()); - if !lines.next()?.starts_with("CSPLUTV100") { - return None; - } - let kind = lines.next()?; - let mut lines = lines.peekable(); - if lines.peek().is_some_and(|l| l.starts_with("BEGIN METADATA")) { - for l in lines.by_ref() { - if l.starts_with("END METADATA") { - break; - } - } - } - let nums = |l: &str| -> Option> { l.split_whitespace().map(|t| t.parse().ok()).collect() }; - let mut shaper: [Vec<(f32, f32)>; 3] = Default::default(); - for ch in shaper.iter_mut() { - let n: usize = lines.next()?.parse().ok()?; - let ins = nums(lines.next()?)?; - let outs = nums(lines.next()?)?; - if ins.len() != n || outs.len() != n || n < 2 { - return None; - } - *ch = ins.into_iter().zip(outs).collect(); - } - if kind.starts_with("3D") { - let sz = nums(lines.next()?)?; - if sz.len() != 3 || sz[0] != sz[1] || sz[1] != sz[2] { - return None; - } - let n = sz[0] as usize; - let mut d3 = Vec::with_capacity(n * n * n); - for _ in 0..n * n * n { - let v = nums(lines.next()?)?; - if v.len() != 3 { - return None; - } - d3.push([v[0], v[1], v[2]]); - } - (n >= 2).then_some((Lut { n1: 0, d1: vec![], n3: n, d3, dmin: [0.0; 3], dmax: [1.0; 3] }, shaper)) - } else { - let n: usize = lines.next()?.parse().ok()?; - let mut d1 = Vec::with_capacity(n); - for _ in 0..n { - let v = nums(lines.next()?)?; - if v.len() != 3 { - return None; - } - d1.push([v[0], v[1], v[2]]); - } - (n >= 2).then_some((Lut { n1: n, d1, n3: 0, d3: vec![], dmin: [0.0; 3], dmax: [1.0; 3] }, shaper)) - } -} - -fn shape(curve: &[(f32, f32)], x: f32) -> f32 { - if curve.len() < 2 { - return x; - } - let i = curve.partition_point(|p| p.0 < x).clamp(1, curve.len() - 1); - let (a, b) = (curve[i - 1], curve[i]); - let t = ((x - a.0) / (b.0 - a.0).max(1e-12)).clamp(0.0, 1.0); - a.1 + (b.1 - a.1) * t -} - -fn shape_inv(curve: &[(f32, f32)], y: f32) -> f32 { - let flipped: Vec<(f32, f32)> = curve.iter().map(|&(a, b)| (b, a)).collect(); - shape(&flipped, y) -} - -/// Parse transform text by its format (detected from `ext` or the content). -pub fn parse_file(text: &str, ext: &str) -> Option { - let ext = ext.to_ascii_lowercase(); - if ext == "cc" || ext == "ccc" || ext == "cdl" || text.contains(">>>; - -/// Load (and cache) a transform from a file path or inline content. -pub fn load_file(src: &str) -> Option> { - let src_t = src.trim(); - if src_t.is_empty() { - return None; - } - static CACHE: OnceLock = OnceLock::new(); - let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); - if let Ok(m) = cache.lock() - && let Some(v) = m.get(src) - { - return v.clone(); - } - let inline = src.contains('\n'); - let parsed = if inline { - parse_file(src, "") - } else { - let ext = std::path::Path::new(src_t).extension().and_then(std::ffi::OsStr::to_str).unwrap_or(""); - std::fs::read_to_string(src_t).ok().and_then(|t| parse_file(&t, ext)) - } - .map(Arc::new); - if let Ok(mut m) = cache.lock() { - m.insert(src.to_string(), parsed.clone()); - } - parsed -} - -impl FileXform { - pub fn apply(&self, c: [f32; 3], interp: u32, inverse: bool, ccc_id: &str) -> [f32; 3] { - match self { - FileXform::Cdl(list) => { - let cdl = list.iter().find(|(id, _)| !ccc_id.is_empty() && id == ccc_id).or(list.first()).map(|(_, c)| *c).unwrap_or_default(); - let c64 = c.map(|v| v as f64); - let o = if inverse { cdl.invert(c64, true) } else { cdl.apply(c64, true) }; - o.map(|v| v as f32) - } - FileXform::Matrix { m, offset } => crate::ocio_config::matrix_apply(m, offset, c.map(|v| v as f64), inverse).map(|v| v as f32), - FileXform::Lut { lut, shaper } => { - let fwd = |c: [f32; 3]| { - let c = match shaper { - Some(s) => [shape(&s[0], c[0]), shape(&s[1], c[1]), shape(&s[2], c[2])], - None => c, - }; - lut.apply_interp(c, interp) - }; - if !inverse { - return fwd(c); - } - if lut.n3 == 0 { - // 1D: invert each channel exactly through the (monotone) tables. - let mut x = c; - if lut.n1 > 1 { - x = [0, 1, 2].map(|i| { - let curve: Vec<(f32, f32)> = - (0..lut.n1).map(|k| (lut.d1[k][i], lut.dmin[i] + (lut.dmax[i] - lut.dmin[i]) * k as f32 / (lut.n1 - 1) as f32)).collect(); - shape(&curve, c[i]) - }); - } - if let Some(s) = shaper { - x = [shape_inv(&s[0], x[0]), shape_inv(&s[1], x[1]), shape_inv(&s[2], x[2])]; - } - return x; - } - // 3D: damped fixed-point (Newton with an identity Jacobian) from the target. - let mut x = c; - for _ in 0..40 { - let y = fwd(x); - let e = [c[0] - y[0], c[1] - y[1], c[2] - y[2]]; - if e.iter().all(|v| v.abs() < 1e-6) { - break; - } - x = [x[0] + e[0], x[1] + e[1], x[2] + e[2]]; - } - x - } - } - } -} - -// ---------------------------------------------------------------- displays, views and looks - -/// Displays of the built-in config: (name, display primaries/white, encoding). -pub const DISPLAYS: &[(&str, [[f64; 2]; 3], Tf)] = - &[("sRGB", REC709, Tf::Srgb), ("Rec.1886 Rec.709", REC709, Tf::Gamma(2.4)), ("Display P3", P3, Tf::Srgb), ("Rec.2100-PQ", REC2020, Tf::Pq)]; - -pub const VIEWS: &[&str] = &["Standard", "Tone Mapped", "Raw"]; - -/// Configuration popup: the built-in config, or a custom `.ocio` file (Config File: a path or -/// the config's text; spaces chosen by name, see [`crate::ocio_config`]). -pub const CONFIGS: &[&str] = &["Built-in (EffectCraft minimal)", "Custom (.ocio file)"]; - -/// Built-in looks: CDLs applied in ACEScct. -pub fn looks() -> Vec<(&'static str, Cdl)> { - let grey = 0.413_588_402_492_442_1; // ACEScct of 0.18 - let c = |slope: [f64; 3], offset: [f64; 3], sat: f64| Cdl { slope, offset, power: [1.0; 3], sat }; - vec![ - ("Warm", c([1.04, 1.0, 0.94], [0.0; 3], 1.0)), - ("Cool", c([0.95, 1.0, 1.06], [0.0; 3], 1.0)), - ("High Contrast", c([1.25; 3], [grey * (1.0 - 1.25); 3], 1.0)), - ("Low Contrast", c([0.8; 3], [grey * (1.0 - 0.8); 3], 1.0)), - ("Desaturated", c([1.0; 3], [0.0; 3], 0.6)), - ("Vivid", c([1.0; 3], [0.0; 3], 1.3)), - ] -} - -/// Extended Reinhard roll-off (white point 16): maps 0..∞ into 0..1, ≈ identity near 0. -fn tonemap(v: f64) -> f64 { - let w2 = 256.0; - if v <= 0.0 { v } else { v * (1.0 + v / w2) / (1.0 + v) } -} - -fn tonemap_inv(y: f64) -> f64 { - if y <= 0.0 { - return y; - } - // Solve v² / w² + v (1 − y) − y = 0. - let (a, b, c) = (1.0 / 256.0, 1.0 - y, -y); - (-b + (b * b - 4.0 * a * c).sqrt()) / (2.0 * a) -} - -/// Display transform of an input-space colour (`inverse`: display code values → input space). -pub fn display_xform(input: &Space, display: usize, view: usize, inverse: bool) -> impl Fn([f32; 3]) -> [f32; 3] + Sync { - let (_, prims, tf) = DISPLAYS[display.min(DISPLAYS.len() - 1)]; - let disp = Space { name: "display", prims: Some((prims, D65)), tf: Tf::Linear, raw: false }; - let raw = view == 2 || input.raw; - let m_in = Xform::new(&Space { tf: Tf::Linear, ..*input }, &disp); - let m_out = Xform::new(&disp, &Space { tf: Tf::Linear, ..*input }); - let in_tf = input.tf; - move |c: [f32; 3]| { - if raw { - return c; - } - if !inverse { - let lin = c.map(|v| in_tf.decode(v as f64) as f32); - let d = m_in.apply(lin); - d.map(|v| { - let v = v as f64; - let v = if view == 1 { tonemap(v) } else { v }; - tf.encode(v) as f32 - }) - } else { - let d = c.map(|v| { - let l = tf.decode(v as f64); - (if view == 1 { tonemap_inv(l) } else { l }) as f32 - }); - m_out.apply(d).map(|v| in_tf.encode(v as f64) as f32) - } - } -} - -// ---------------------------------------------------------------- effects - -fn spec(id: &'static str, name: &'static str, cat: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { - EffectSpec { id, name, category: cat, params, render, gpu: false, float: true } -} - -fn space_param(id: &'static str, name: &'static str, default: u32) -> crate::ParamSpec { - p(id, name, Value::Enum(default), popup(&space_names())) -} - -fn direction() -> crate::ParamSpec { - p("direction", "Direction", Value::Enum(0), popup(&["Forward", "Inverse"])) -} - -fn map_px(b: &mut Buf, f: impl Fn([f32; 3]) -> [f32; 3] + Sync) { - b.img.data.par_iter_mut().for_each(|px| { - let (c, a) = unpremul(*px); - if a <= 0.0 { - return; - } - let o = f(c); - *px = [o[0] * a, o[1] * a, o[2] * a, a]; - }); -} - -fn space_at(i: u32) -> &'static Space { - &SPACES[(i as usize).min(SPACES.len() - 1)] -} - -/// The effects that take Configuration ▸ Custom (a `.ocio` file). -pub(crate) const CUSTOM_CONFIG_FX: [&str; 2] = ["ec.color.ociocolorspace", "ec.color.ociodisplay"]; - -/// Effect `id`'s transform under Configuration ▸ Custom: the config's conversion from the source -/// space to the destination space (Color Space Transform) or through a display's view (Display -/// Transform), see [`crate::ocio_config`]. `Ok(None)` with the built-in configuration; an error -/// (the pixels pass through) when the config can't be read or has no such colour space. -fn custom_xf(id: &str, pr: &crate::Params) -> Result, String> { - use crate::ocio_config::Xf; - if !CUSTOM_CONFIG_FX.contains(&id) || pr.e("config") != 1 { - return Ok(None); - } - let cfg = crate::ocio_config::load_config(pr.s("configFile")).map_err(|e| format!("Can't read the OCIO config: {e}"))?; - // A space by its typed name, else by the built-in popup's name. - let space = |name: &str, popup: u32| { - let name = if name.trim().is_empty() { space_at(popup).name } else { name.trim() }; - cfg.space(name).ok_or_else(|| format!("The OCIO config has no color space `{name}`")) - }; - let a = space(pr.s("sourceName"), pr.e("source"))?; - let inverse = pr.e("direction") == 1; - if id == "ec.color.ociodisplay" { - let (dn, vn) = display_view_names(pr); - let xf = cfg.display_path(a, dn, vn); - return Ok(Some(if inverse { Xf::Inverse(Box::new(xf)) } else { xf })); - } - let z = space(pr.s("destinationName"), pr.e("destination"))?; - // Inverse: the conversion the other way (OCIO's inverse ColorSpaceTransform). - Ok(Some(if inverse { cfg.path(z, a) } else { cfg.path(a, z) })) -} - -/// The display and view a Display Transform names: the typed names, else the popups'. -fn display_view_names(pr: &crate::Params) -> (&str, &str) { - let display = DISPLAYS.get(pr.e("display") as usize).map_or("", |d| d.0); - let view = VIEWS.get(pr.e("view") as usize).copied().unwrap_or(""); - let typed = |k: &str, popup| if pr.s(k).trim().is_empty() { popup } else { pr.s(k).trim() }; - (typed("displayName", display), typed("viewName", view)) -} - -/// Effect Controls' warning for effect `id`: a custom OCIO configuration that can't be read, has -/// no such colour space, display or view, or uses transforms EffectCraft can't apply (colours -/// pass through those steps, #410). `None` when the effect renders as asked. -pub fn warning(id: &str, pr: &crate::Params) -> Option { - let xf = match custom_xf(id, pr) { - Ok(x) => x?, - Err(e) => return Some(format!("{e}: the colors pass through.")), - }; - let mut notes = vec![]; - if id == "ec.color.ociodisplay" - && let Ok(cfg) = crate::ocio_config::load_config(pr.s("configFile")) - { - let (dn, vn) = display_view_names(pr); - if let Some((d, v)) = cfg.view(dn, vn) - && (!d.eq_ignore_ascii_case(dn) || !v.name.eq_ignore_ascii_case(vn)) - { - notes.push(format!("The OCIO config has no display `{dn}` with a view `{vn}`: showing {d} / {}.", v.name)); - } - } - let missing = xf.unsupported_list(); - if !missing.is_empty() { - notes.push(format!("Not supported, the colors pass through: {}.", missing.join(", "))); - } - (!notes.is_empty()).then(|| notes.join(" ")) -} - -/// Configuration ▸ Custom: render through the config's transform. `false` with the built-in -/// configuration (the caller renders it). -fn custom_render(id: &str, ctx: &EffectCtx, b: &mut Buf) -> bool { - match custom_xf(id, ctx.params) { - Ok(None) => false, - Ok(Some(xf)) => { - map_px(b, |c| xf.apply(c.map(|v| v as f64), false).map(|v| v as f32)); - true - } - // A config that can't be read, or an unknown space: the pixels pass through. - Err(_) => true, - } -} - -fn ocio_cst(ctx: &EffectCtx, mut b: Buf) -> Buf { - if custom_render("ec.color.ociocolorspace", ctx, &mut b) { - return b; - } - let (a, z) = (space_at(ctx.params.e("source")), space_at(ctx.params.e("destination"))); - let (a, z) = if ctx.params.e("direction") == 1 { (z, a) } else { (a, z) }; - if a.name == z.name { - return b; - } - let x = Xform::new(a, z); - map_px(&mut b, |c| x.apply(c)); - b -} - -fn cdl_from_params(pr: &crate::Params) -> Cdl { - Cdl { - slope: [pr.f("slopeRed"), pr.f("slopeGreen"), pr.f("slopeBlue")], - offset: [pr.f("offsetRed"), pr.f("offsetGreen"), pr.f("offsetBlue")], - power: [pr.f("powerRed").max(0.0), pr.f("powerGreen").max(0.0), pr.f("powerBlue").max(0.0)], - sat: pr.f("saturation").max(0.0), - } -} - -fn ocio_cdl(ctx: &EffectCtx, mut b: Buf) -> Buf { - let cdl = cdl_from_params(ctx.params); - if cdl == Cdl::default() { - return b; - } - let clamp = ctx.params.e("style") == 0; - let inv = ctx.params.e("direction") == 1; - map_px(&mut b, |c| { - let c = c.map(|v| v as f64); - let o = if inv { cdl.invert(c, clamp) } else { cdl.apply(c, clamp) }; - o.map(|v| v as f32) - }); - b -} - -fn ocio_display(ctx: &EffectCtx, mut b: Buf) -> Buf { - if custom_render("ec.color.ociodisplay", ctx, &mut b) { - return b; - } - let input = space_at(ctx.params.e("source")); - let f = display_xform(input, ctx.params.e("display") as usize, ctx.params.e("view") as usize, ctx.params.e("direction") == 1); - map_px(&mut b, f); - b -} - -fn ocio_file(ctx: &EffectCtx, mut b: Buf) -> Buf { - let Some(x) = load_file(ctx.params.s("file")) else { return b }; - let interp = match ctx.params.e("interpolation") { - 0 => 0, - 1 => 1, - _ => 2, - }; - let inv = ctx.params.e("direction") == 1; - let id = ctx.params.s("cccId").to_string(); - map_px(&mut b, |c| x.apply(c, interp, inv, &id)); - b -} - -/// Parse a looks string: comma/space separated names, `-Name` = inverse. -pub fn parse_looks(s: &str) -> Vec<(Cdl, bool)> { - let all = looks(); - s.split([',', ':']) - .map(str::trim) - .filter(|t| !t.is_empty()) - .filter_map(|t| { - let (inv, n) = match t.strip_prefix('-') { - Some(r) => (true, r.trim()), - None => (false, t.trim_start_matches('+').trim()), - }; - all.iter().find(|(name, _)| name.eq_ignore_ascii_case(n)).map(|(_, c)| (*c, inv)) - }) - .collect() -} - -fn ocio_look(ctx: &EffectCtx, mut b: Buf) -> Buf { - let mut list = parse_looks(ctx.params.s("looks")); - let pick = ctx.params.e("look"); - if pick > 0 - && let Some((_, c)) = looks().get(pick as usize - 1) - { - list.insert(0, (*c, false)); - } - if list.is_empty() { - return b; - } - let (src, dst) = (space_at(ctx.params.e("source")), space_at(ctx.params.e("destination"))); - let inv = ctx.params.e("direction") == 1; - let cct = &ACES_CCT; - let (a, z) = if inv { (dst, src) } else { (src, dst) }; - let to_p = Xform::new(a, cct); - let from_p = Xform::new(cct, z); - if inv { - list.reverse(); - } - map_px(&mut b, |c| { - let mut v = to_p.apply(c).map(|x| x as f64); - for (cdl, li) in &list { - v = if *li != inv { cdl.invert(v, false) } else { cdl.apply(v, false) }; - } - from_p.apply(v.map(|x| x as f32)) - }); - b -} - -// ---- Color Profile Converter (Utility) - -pub const PROFILES: &[&str] = &["Project Working Space", "sRGB IEC61966-2.1", "HDTV (Rec. 709)", "Rec. 2020", "Display P3", "ACEScg", "ACES2065-1"]; - -fn profile(ctx: &EffectCtx, i: u32) -> (Space, bool) { - let ws = |cs: ColorSpace| match cs { - ColorSpace::Srgb => sp("sRGB", REC709, D65, Tf::Srgb), - ColorSpace::Rec709 => sp("Rec.709", REC709, D65, Tf::Gamma(2.4)), - ColorSpace::Rec2020 => sp("Rec.2020", REC2020, D65, Tf::Gamma(2.4)), - ColorSpace::DisplayP3 => sp("Display P3", P3, D65, Tf::Srgb), - ColorSpace::AcesCg => ACES_CG, - ColorSpace::Aces2065 => ACES2065_1, - // HDR encodings are output spaces, never a working space. - ColorSpace::Rec2100Pq | ColorSpace::Rec2100Hlg => sp("Rec.2020", REC2020, D65, Tf::Gamma(2.4)), - }; - match i { - 0 => (ws(ctx.env.working_space.unwrap_or(ColorSpace::Srgb)), ctx.env.working_linear), - 1 => (ws(ColorSpace::Srgb), false), - 2 => (ws(ColorSpace::Rec709), false), - 3 => (ws(ColorSpace::Rec2020), false), - 4 => (ws(ColorSpace::DisplayP3), false), - 5 => (ACES_CG, true), - _ => (ACES2065_1, true), - } -} - -/// Gamut compression for the perceptual intent: pull out-of-gamut (negative) colours toward -/// their luminance just enough to bring every channel to ≥ 0, rolling off smoothly. -fn compress_gamut(c: [f64; 3], luma: [f64; 3]) -> [f64; 3] { - let l = c[0] * luma[0] + c[1] * luma[1] + c[2] * luma[2]; - if l <= 0.0 { - return c.map(|v| v.max(0.0)); - } - let mn = c.iter().cloned().fold(f64::INFINITY, f64::min); - if mn >= 0.0 { - return c; - } - // Fraction of the way toward grey that reaches zero on the most negative channel. - let t = (l / (l - mn)).clamp(0.0, 1.0); - c.map(|v| l + (v - l) * t) -} - -fn profile_converter(ctx: &EffectCtx, mut b: Buf) -> Buf { - let (inp, in_lin_default) = profile(ctx, ctx.params.e("inputProfile")); - let (out, out_lin_default) = profile(ctx, ctx.params.e("outputProfile")); - let in_lin = ctx.params.b("linearizeInputProfile") || in_lin_default && ctx.params.e("inputProfile") == 0; - let out_lin = ctx.params.b("linearizeOutputProfile") || out_lin_default && ctx.params.e("outputProfile") == 0; - let intent = ctx.params.e("intent"); - let dec = if in_lin { Tf::Linear } else { inp.tf }; - let enc = if out_lin { Tf::Linear } else { out.tf }; - // Absolute colorimetric keeps the source white (no adaptation); the others adapt. - let m = if intent == 3 { mul(&invert(&out.to_ref(false)), &inp.to_ref(false)) } else { matrix(&inp, &out) }; - let identity = dec == enc && (0..3).all(|i| (0..3).all(|j| (m[i][j] - IDENTITY[i][j]).abs() < 1e-9)); - if identity { - return b; - } - let out_luma = out.to_ref(true)[1]; - map_px(&mut b, |c| { - let l = c.map(|v| dec.decode(v as f64)); - let mut o = mul_vec(&m, l); - match intent { - 0 => o = compress_gamut(o, out_luma), - 2 => o = o.map(|v| v.max(0.0)), - _ => {} - } - o.map(|v| enc.encode(v) as f32) - }); - b -} - -// ---- Colour programs (the GPU compositor's colour-management kernels) - -/// One per-pixel step of a colour transform, in the order the CPU effect applies them (see -/// [`color_program`]). -#[derive(Clone, Debug)] -pub enum ColorOp { - /// Transfer function, encoded → linear. - Decode(Tf), - /// Transfer function, linear → encoded. - Encode(Tf), - /// Linear-RGB matrix. - Matrix([[f32; 3]; 3]), - /// ASC CDL (forward or exact inverse; `clamp` = the v1.2 style). - Cdl { cdl: Cdl, clamp: bool, inverse: bool }, - /// The Tone Mapped view's roll-off (or its inverse). - Tonemap { inverse: bool }, - /// Color Profile Converter's perceptual gamut compression toward luminance `luma`. - Gamut([f64; 3]), - /// Negative channels to 0 (the saturation intent). - Max0, - /// A LUT (0 nearest, 1 trilinear, 2 tetrahedral) with an optional per-channel shaper, forward - /// or inverse (as [`FileXform::apply`]). - Lut { lut: Arc, shaper: Option; 3]>>, interp: u32, inverse: bool }, - /// `m × (c + pre) + post` (a custom config's MatrixTransform and RangeTransform). - Affine { m: [[f32; 3]; 3], pre: [f32; 3], post: [f32; 3] }, - /// `max(c, 0) ^ p` per channel (ExponentTransform). - Pow([f32; 3]), - /// LogTransform / LogAffineTransform, forward or inverse (as `ocio_config::Xf::apply`). - LogAffine { base: f32, log_slope: [f32; 3], log_offset: [f32; 3], lin_slope: [f32; 3], lin_offset: [f32; 3], inverse: bool }, - /// ExponentWithLinearTransform, forward (decode) or inverse (as - /// [`crate::ocio_config::moncurve`]). - MonCurve { gamma: [f32; 3], offset: [f32; 3], inverse: bool, mirror: bool }, - /// Clamp each channel to `lo..=hi` (a clamping RangeTransform; ±`f32::MAX` = unbounded). - Clamp { lo: [f32; 3], hi: [f32; 3] }, -} - -/// The ops of a matrix-and-offset transform (MatrixTransform, `.spimtx`), forward or inverse -/// (as [`crate::ocio_config::matrix_apply`]). -fn matrix_ops(m: &[f64; 16], offset: &[f64; 4], inverse: bool, ops: &mut Vec) { - if !inverse { - let m = [0, 1, 2].map(|r| [m[r * 4] as f32, m[r * 4 + 1] as f32, m[r * 4 + 2] as f32]); - ops.push(ColorOp::Affine { m, pre: [0.0; 3], post: [offset[0] as f32, offset[1] as f32, offset[2] as f32] }); - } else if let Some(inv) = crate::ocio_config::mat3_inverse(m) { - let m = inv.map(|r| r.map(|v| v as f32)); - ops.push(ColorOp::Affine { m, pre: [-offset[0] as f32, -offset[1] as f32, -offset[2] as f32], post: [0.0; 3] }); - } -} - -/// The ops of a LUT / CDL / matrix file transform (as [`FileXform::apply`]). -fn file_ops(x: &FileXform, interp: u32, inverse: bool, ccc_id: &str, ops: &mut Vec) { - match x { - FileXform::Cdl(list) => { - let cdl = list.iter().find(|(i, _)| !ccc_id.is_empty() && i == ccc_id).or(list.first()).map(|(_, c)| *c).unwrap_or_default(); - ops.push(ColorOp::Cdl { cdl, clamp: true, inverse }); - } - FileXform::Lut { lut, shaper } => { - ops.push(ColorOp::Lut { lut: Arc::new(lut.clone()), shaper: shaper.clone().map(Arc::new), interp, inverse }); - } - FileXform::Matrix { m, offset } => matrix_ops(m, offset, inverse, ops), - } -} - -/// The ops of a custom config's transform `x` (as `Xf::apply`). -fn config_xf_ops(x: &crate::ocio_config::Xf, inverse: bool, ops: &mut Vec) { - use crate::ocio_config::Xf; - let f3 = |v: &[f64; 3]| v.map(|x| x as f32); - match x { - Xf::Inverse(x) => config_xf_ops(x, !inverse, ops), - Xf::Matrix { m, offset } => matrix_ops(m, offset, inverse, ops), - Xf::File { xf: Some(x), interp, ccc, .. } => file_ops(x, *interp, inverse, ccc, ops), - Xf::ExponentLinear { gamma, offset, mirror } => ops.push(ColorOp::MonCurve { gamma: f3(gamma), offset: f3(offset), inverse, mirror: *mirror }), - // A BuiltinTransform's curve: forward decodes. - Xf::Curve(tf) => ops.push(if inverse { ColorOp::Encode(*tf) } else { ColorOp::Decode(*tf) }), - Xf::Named(_) => {} - Xf::File { xf: None, .. } | Xf::Unsupported(_) => {} - Xf::Exponent(e) => ops.push(ColorOp::Pow([0, 1, 2].map(|k| (if inverse { 1.0 / e[k].max(1e-9) } else { e[k] }) as f32))), - Xf::Log { base } => { - ops.push(ColorOp::LogAffine { base: *base as f32, log_slope: [1.0; 3], log_offset: [0.0; 3], lin_slope: [1.0; 3], lin_offset: [0.0; 3], inverse }) - } - Xf::LogAffine { base, log_slope, log_offset, lin_slope, lin_offset } => ops.push(ColorOp::LogAffine { - base: *base as f32, - log_slope: f3(log_slope), - log_offset: f3(log_offset), - lin_slope: f3(lin_slope), - lin_offset: f3(lin_offset), - inverse, - }), - Xf::Cdl(cdl) => ops.push(ColorOp::Cdl { cdl: *cdl, clamp: true, inverse }), - Xf::Range(r) => { - let (k, off, lo, hi) = r.parts(inverse); - let k = k as f32; - ops.push(ColorOp::Affine { m: [[k, 0.0, 0.0], [0.0, k, 0.0], [0.0, 0.0, k]], pre: [0.0; 3], post: [off as f32; 3] }); - if lo.is_finite() || hi.is_finite() { - let bound = |v: f64| (v as f32).clamp(-f32::MAX, f32::MAX); - ops.push(ColorOp::Clamp { lo: [bound(lo); 3], hi: [bound(hi); 3] }); - } - } - Xf::Group(list) => { - if inverse { - list.iter().rev().for_each(|x| config_xf_ops(x, true, ops)); - } else { - list.iter().for_each(|x| config_xf_ops(x, false, ops)); - } - } - } -} - -/// How a colour program meets premultiplied pixels. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Straight { - /// `unpremul` (colour 0 below alpha 1e-6); fully transparent pixels untouched (OCIO effects). - Unpremul, - /// Colour ÷ alpha; fully transparent pixels untouched (`Image::map_straight`, Apply Color LUT). - Divide, -} - -fn xform_ops(x: &Xform, ops: &mut Vec) { - if x.dec != Tf::Linear { - ops.push(ColorOp::Decode(x.dec)); - } - if !x.ident { - ops.push(ColorOp::Matrix(x.m)); - } - if x.enc != Tf::Linear { - ops.push(ColorOp::Encode(x.enc)); - } -} - -/// Effect `id`'s per-pixel transform as a list of [`ColorOp`]s that reproduces the CPU effect -/// (empty = the layer passes through; custom OCIO configs compile their transforms too). `None` -/// for another effect. -pub fn color_program(id: &str, ctx: &EffectCtx) -> Option<(Vec, Straight)> { - let pr = ctx.params; - let mut ops = vec![]; - // Configuration ▸ Custom compiles the config's transforms (a config that can't be read, or - // names unknown spaces, passes the pixels through). - match custom_xf(id, pr) { - Ok(None) => {} - Ok(Some(xf)) => { - config_xf_ops(&xf, false, &mut ops); - return Some((ops, Straight::Unpremul)); - } - Err(_) => return Some((ops, Straight::Unpremul)), - } - match id { - "ec.utility.applylut" => { - if let Some(lut) = crate::utility::load_lut(pr.s("lut")) { - ops.push(ColorOp::Lut { lut, shaper: None, interp: 2, inverse: false }); - } - return Some((ops, Straight::Divide)); - } - "ec.color.ociocolorspace" => { - let (a, z) = (space_at(pr.e("source")), space_at(pr.e("destination"))); - let (a, z) = if pr.e("direction") == 1 { (z, a) } else { (a, z) }; - if a.name != z.name { - xform_ops(&Xform::new(a, z), &mut ops); - } - } - "ec.color.ociocdl" => { - let cdl = cdl_from_params(pr); - if cdl != Cdl::default() { - ops.push(ColorOp::Cdl { cdl, clamp: pr.e("style") == 0, inverse: pr.e("direction") == 1 }); - } - } - "ec.color.ociodisplay" => { - let input = space_at(pr.e("source")); - let (display, view, inverse) = (pr.e("display") as usize, pr.e("view") as usize, pr.e("direction") == 1); - let (_, prims, tf) = DISPLAYS[display.min(DISPLAYS.len() - 1)]; - if view != 2 && !input.raw { - let disp = Space { name: "display", prims: Some((prims, D65)), tf: Tf::Linear, raw: false }; - let lin = Space { tf: Tf::Linear, ..*input }; - if !inverse { - ops.push(ColorOp::Decode(input.tf)); - xform_ops(&Xform::new(&lin, &disp), &mut ops); - if view == 1 { - ops.push(ColorOp::Tonemap { inverse: false }); - } - ops.push(ColorOp::Encode(tf)); - } else { - ops.push(ColorOp::Decode(tf)); - if view == 1 { - ops.push(ColorOp::Tonemap { inverse: true }); - } - xform_ops(&Xform::new(&disp, &lin), &mut ops); - ops.push(ColorOp::Encode(input.tf)); - } - } - } - "ec.color.ociofile" => { - if let Some(x) = load_file(pr.s("file")) { - let interp = match pr.e("interpolation") { - 0 => 0, - 1 => 1, - _ => 2, - }; - file_ops(&x, interp, pr.e("direction") == 1, pr.s("cccId"), &mut ops); - } - } - "ec.color.ociolook" => { - let mut list = parse_looks(pr.s("looks")); - let pick = pr.e("look"); - if pick > 0 - && let Some((_, c)) = looks().get(pick as usize - 1) - { - list.insert(0, (*c, false)); - } - if !list.is_empty() { - let (src, dst) = (space_at(pr.e("source")), space_at(pr.e("destination"))); - let inv = pr.e("direction") == 1; - let cct = &ACES_CCT; - let (a, z) = if inv { (dst, src) } else { (src, dst) }; - if inv { - list.reverse(); - } - xform_ops(&Xform::new(a, cct), &mut ops); - for (cdl, li) in &list { - ops.push(ColorOp::Cdl { cdl: *cdl, clamp: false, inverse: *li != inv }); - } - xform_ops(&Xform::new(cct, z), &mut ops); - } - } - "ec.utility.colorprofileconverter" => { - let (inp, in_lin_default) = profile(ctx, pr.e("inputProfile")); - let (out, out_lin_default) = profile(ctx, pr.e("outputProfile")); - let in_lin = pr.b("linearizeInputProfile") || in_lin_default && pr.e("inputProfile") == 0; - let out_lin = pr.b("linearizeOutputProfile") || out_lin_default && pr.e("outputProfile") == 0; - let intent = pr.e("intent"); - let dec = if in_lin { Tf::Linear } else { inp.tf }; - let enc = if out_lin { Tf::Linear } else { out.tf }; - let m = if intent == 3 { mul(&invert(&out.to_ref(false)), &inp.to_ref(false)) } else { matrix(&inp, &out) }; - let identity = dec == enc && (0..3).all(|i| (0..3).all(|j| (m[i][j] - IDENTITY[i][j]).abs() < 1e-9)); - if !identity { - ops.push(ColorOp::Decode(dec)); - ops.push(ColorOp::Matrix(m.map(|r| r.map(|v| v as f32)))); - match intent { - 0 => ops.push(ColorOp::Gamut(out.to_ref(true)[1])), - 2 => ops.push(ColorOp::Max0), - _ => {} - } - ops.push(ColorOp::Encode(enc)); - } - } - _ => return None, - } - Some((ops, Straight::Unpremul)) -} - -// ---- Color Stabilizer - -/// Mean straight colour of a square (half-size `r` layer pixels) around layer point `pt`. -fn sample_mean(b: &Buf, pt: [f64; 2], r: f64) -> [f64; 3] { - let (cx, cy) = b.to_px(pt); - let rr = (r * b.scale).max(0.5); - let (x0, x1) = ((cx - rr).floor().max(0.0) as i64, ((cx + rr).ceil() as i64).min(b.img.width as i64)); - let (y0, y1) = ((cy - rr).floor().max(0.0) as i64, ((cy + rr).ceil() as i64).min(b.img.height as i64)); - let mut acc = [0.0f64; 4]; - for y in y0..y1 { - for x in x0..x1 { - let p = b.img.get(x, y); - for k in 0..4 { - acc[k] += p[k] as f64; - } - } - } - if acc[3] <= 1e-9 { - return [0.0; 3]; - } - [acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3]] -} - -/// Monotone piecewise-quadratic curve through (0,0), three samples and (1,1) (linear between -/// them is enough for a stabiliser and stays monotone). -fn curve3(xs: [f64; 3], ys: [f64; 3], v: f64) -> f64 { - let mut pts = vec![(0.0, 0.0)]; - let mut pairs: Vec<(f64, f64)> = xs.iter().cloned().zip(ys.iter().cloned()).collect(); - pairs.sort_by(|a, b| a.0.total_cmp(&b.0)); - for (x, y) in pairs { - if pts.last().is_some_and(|l| x > l.0 + 1e-4) && x < 1.0 - 1e-4 { - pts.push((x, y)); - } - } - pts.push((1.0, 1.0)); - let i = pts.partition_point(|p| p.0 < v).clamp(1, pts.len() - 1); - let (a, c) = (pts[i - 1], pts[i]); - a.1 + (c.1 - a.1) * (v - a.0) / (c.0 - a.0).max(1e-9) -} - -/// Color Stabilizer's correction of buffer `b` as one piecewise-linear map per channel (points -/// with increasing x; the end segments extend), the same maps [`color_stabilizer`] applies. -/// `None` without a host or reference frame (the layer passes through). (The GPU effect.) -pub fn color_stabilizer_maps(ctx: &EffectCtx, b: &Buf) -> Option<[Vec<(f64, f64)>; 3]> { - let reference = ctx.env.host?.self_at(ctx.params.f("referenceFrame"), ctx.env.effect_index)?; - let r = ctx.params.f("sampleSize").max(0.5); - let pts = [ctx.params.v2("blackPoint"), ctx.params.v2("midPoint"), ctx.params.v2("whitePoint")]; - let cur = pts.map(|pt| sample_mean(b, pt, r)); - let refv = pts.map(|pt| sample_mean(&reference, pt, r)); - let mode = ctx.params.e("stabilize"); - Some([0, 1, 2].map(|k| { - let offset = |c0: f64, r0: f64| vec![(0.0, r0 - c0), (1.0, 1.0 + r0 - c0)]; - match mode { - 0 => offset(cur[0][k], refv[0][k]), - 1 => { - let (c0, c1, r0, r1) = (cur[0][k], cur[2][k], refv[0][k], refv[2][k]); - if (c1 - c0).abs() < 1e-6 { - offset(c0, r0) - } else if c0 < c1 { - vec![(c0, r0), (c1, r1)] - } else { - vec![(c1, r1), (c0, r0)] - } - } - _ => { - let mut v = vec![(0.0, 0.0)]; - let mut pairs: Vec<(f64, f64)> = (0..3).map(|i| (cur[i][k], refv[i][k])).collect(); - pairs.sort_by(|a, b| a.0.total_cmp(&b.0)); - for (x, y) in pairs { - if v.last().is_some_and(|l| x > l.0 + 1e-4) && x < 1.0 - 1e-4 { - v.push((x, y)); - } - } - v.push((1.0, 1.0)); - v - } - } - })) -} - -fn color_stabilizer(ctx: &EffectCtx, mut b: Buf) -> Buf { - let Some(host) = ctx.env.host else { return b }; - let Some(reference) = host.self_at(ctx.params.f("referenceFrame"), ctx.env.effect_index) else { return b }; - let r = ctx.params.f("sampleSize").max(0.5); - let pts = [ctx.params.v2("blackPoint"), ctx.params.v2("midPoint"), ctx.params.v2("whitePoint")]; - let cur = pts.map(|pt| sample_mean(&b, pt, r)); - let refv = pts.map(|pt| sample_mean(&reference, pt, r)); - let mode = ctx.params.e("stabilize"); - map_px(&mut b, |c| { - [0, 1, 2].map(|k| { - let v = c[k] as f64; - let o = match mode { - // Brightness: one sample (black point) — offset each channel. - 0 => v + refv[0][k] - cur[0][k], - // Levels: black and white samples — linear map per channel. - 1 => { - let (c0, c1, r0, r1) = (cur[0][k], cur[2][k], refv[0][k], refv[2][k]); - if (c1 - c0).abs() < 1e-6 { v + r0 - c0 } else { r0 + (v - c0) * (r1 - r0) / (c1 - c0) } - } - // Curves: three samples — a curve through them per channel. - _ => curve3([cur[0][k], cur[1][k], cur[2][k]], [refv[0][k], refv[1][k], refv[2][k]], v), - }; - o as f32 - }) - }); - b -} - -fn sl(lo: f64, hi: f64, smin: f64, smax: f64) -> ParamUi { - slider(lo, hi, smin, smax, 3) -} - -pub fn specs() -> Vec { - let cc = "Color Correction"; - let srgb = SPACES.iter().position(|s| s.name == "sRGB").unwrap_or(0) as u32; - let lin709 = SPACES.iter().position(|s| s.name == "Linear Rec.709 (sRGB)").unwrap_or(0) as u32; - let acescg = SPACES.iter().position(|s| s.name == "ACEScg").unwrap_or(0) as u32; - let mut look_opts = vec!["None"]; - look_opts.extend(looks().iter().map(|(n, _)| *n)); - vec![ - spec( - "ec.color.ociocdl", - "OCIO CDL Transform", - cc, - vec![ - p("slopeRed", "Slope Red", num(1.0), sl(0.0, 100.0, 0.0, 4.0)), - p("slopeGreen", "Slope Green", num(1.0), sl(0.0, 100.0, 0.0, 4.0)), - p("slopeBlue", "Slope Blue", num(1.0), sl(0.0, 100.0, 0.0, 4.0)), - p("offsetRed", "Offset Red", num(0.0), sl(-100.0, 100.0, -1.0, 1.0)), - p("offsetGreen", "Offset Green", num(0.0), sl(-100.0, 100.0, -1.0, 1.0)), - p("offsetBlue", "Offset Blue", num(0.0), sl(-100.0, 100.0, -1.0, 1.0)), - p("powerRed", "Power Red", num(1.0), sl(0.0, 100.0, 0.0, 4.0)), - p("powerGreen", "Power Green", num(1.0), sl(0.0, 100.0, 0.0, 4.0)), - p("powerBlue", "Power Blue", num(1.0), sl(0.0, 100.0, 0.0, 4.0)), - p("saturation", "Saturation", num(1.0), sl(0.0, 100.0, 0.0, 4.0)), - p("style", "Style", Value::Enum(0), popup(&["ASC CDL v1.2", "No Clamp"])), - direction(), - ], - ocio_cdl, - ), - spec( - "ec.color.ociocolorspace", - "OCIO Color Space Transform", - cc, - vec![ - p("config", "Configuration", Value::Enum(0), popup(CONFIGS)), - p("configFile", "Config File (.ocio)", Value::Str(String::new()), crate::file_ui(&["ocio"])), - space_param("source", "Source", lin709), - p("sourceName", "Source (config name)", Value::Str(String::new()), ParamUi::Text), - space_param("destination", "Destination", srgb), - p("destinationName", "Destination (config name)", Value::Str(String::new()), ParamUi::Text), - direction(), - ], - ocio_cst, - ), - spec( - "ec.color.ociodisplay", - "OCIO Display Transform", - cc, - vec![ - p("config", "Configuration", Value::Enum(0), popup(CONFIGS)), - p("configFile", "Config File (.ocio)", Value::Str(String::new()), crate::file_ui(&["ocio"])), - space_param("source", "Input Color Space", lin709), - p("sourceName", "Source (config name)", Value::Str(String::new()), ParamUi::Text), - p("display", "Display Device", Value::Enum(0), popup(&DISPLAYS.iter().map(|d| d.0).collect::>())), - p("displayName", "Display (config name)", Value::Str(String::new()), ParamUi::Text), - p("view", "View Transform", Value::Enum(0), popup(VIEWS)), - p("viewName", "View (config name)", Value::Str(String::new()), ParamUi::Text), - direction(), - ], - ocio_display, - ), - // `file`: a path to a .cube/.3dl/.csp/.cc/.ccc/.cdl file, or the file's text itself. - spec( - "ec.color.ociofile", - "OCIO File Transform", - cc, - vec![ - p("file", "File", Value::Str(String::new()), crate::file_ui(&["cube", "3dl", "csp", "cc", "ccc", "cdl", "spi1d", "spi3d", "spimtx"])), - p("cccId", "CCC ID", Value::Str(String::new()), ParamUi::Text), - p("interpolation", "Interpolation", Value::Enum(3), popup(&["Nearest", "Linear", "Tetrahedral", "Best"])), - direction(), - ], - ocio_file, - ), - spec( - "ec.color.ociolook", - "OCIO Look Transform", - cc, - vec![ - p("config", "Configuration", Value::Enum(0), popup(&["Built-in (EffectCraft minimal)"])), - space_param("source", "Source", acescg), - space_param("destination", "Destination", acescg), - p("look", "Look", Value::Enum(0), popup(&look_opts)), - p("looks", "Looks", Value::Str(String::new()), ParamUi::Text), - direction(), - ], - ocio_look, - ), - spec( - "ec.color.colorstabilizer", - "Color Stabilizer", - cc, - vec![ - // The pivot frame (After Effects' Set Frame button stores it). - p("referenceFrame", "Reference Frame (s)", num(0.0), slider(-100000.0, 100000.0, 0.0, 60.0, 2)), - p("stabilize", "Stabilize", Value::Enum(0), popup(&["Brightness", "Levels", "Curves"])), - p("blackPoint", "Black Point", Value::Vec2([0.25, 0.75]), ParamUi::Point), - p("midPoint", "Mid Point", Value::Vec2([0.5, 0.5]), ParamUi::Point), - p("whitePoint", "White Point", Value::Vec2([0.75, 0.25]), ParamUi::Point), - p("sampleSize", "Sample Size", num(5.0), slider(1.0, 100.0, 1.0, 50.0, 0)), - ], - color_stabilizer, - ), - spec( - "ec.utility.colorprofileconverter", - "Color Profile Converter", - "Utility", - vec![ - p("inputProfile", "Input Profile", Value::Enum(0), popup(PROFILES)), - p("linearizeInputProfile", "Linearize Input Profile", Value::Bool(false), ParamUi::Checkbox), - p("outputProfile", "Output Profile", Value::Enum(1), popup(PROFILES)), - p("linearizeOutputProfile", "Linearize Output Profile", Value::Bool(false), ParamUi::Checkbox), - p("intent", "Intent", Value::Enum(1), popup(&["Perceptual", "Relative Colorimetric", "Saturation", "Absolute Colorimetric"])), - // Our built-in profiles share a zero black point and none is scene-referred, so - // these two are kept for compatibility and do not change the result. - p("useBlackPointCompensation", "Use Black Point Compensation", Value::Bool(true), ParamUi::Checkbox), - p("sceneRefCompensation", "Scene-ref. Profile Compensation", Value::Enum(2), popup(&["On", "Off", "Use Project Setting"])), - ], - profile_converter, - ), - ] -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{EffectEnv, EffectHost, Image, LayerPixels, run_fx}; - - fn close(a: M3, b: M3, tol: f64) { - for i in 0..3 { - for j in 0..3 { - assert!((a[i][j] - b[i][j]).abs() < tol, "{a:?} vs {b:?}"); - } - } - } - - #[test] - fn aces_matrices_match_published_values() { - let ap0 = space_by_name("ACES2065-1").unwrap(); - let ap1 = space_by_name("ACEScg").unwrap(); - // Academy S-2014-004 AP0 → AP1. - close( - matrix(ap0, ap1), - [ - [1.451_439_316_1, -0.236_510_746_9, -0.214_928_569_3], - [-0.076_553_773_4, 1.176_229_699_8, -0.099_675_926_4], - [0.008_316_148_4, -0.006_032_449_8, 0.997_716_301_4], - ], - 2e-4, - ); - // ACEScg → linear Rec.709 with Bradford adaptation (published ACES config value). - let lin = space_by_name("Linear Rec.709 (sRGB)").unwrap(); - close(matrix(ap1, lin), [[1.705_05, -0.621_79, -0.083_26], [-0.130_26, 1.140_80, -0.010_55], [-0.024_00, -0.128_97, 1.152_97]], 2e-3); - // White maps to white. - let w = mul_vec(&matrix(ap1, lin), [1.0, 1.0, 1.0]); - assert!(w.iter().all(|v| (v - 1.0).abs() < 1e-3), "{w:?}"); - } - - #[test] - fn transfer_functions_round_trip() { - for tf in [Tf::Srgb, Tf::Gamma(2.4), Tf::AcesCct, Tf::Pq] { - for v in [0.0, 0.001, 0.18, 0.5, 1.0, 4.0] { - let r = tf.decode(tf.encode(v)); - assert!((r - v).abs() < 1e-6 * v.max(1.0), "{tf:?} {v} {r}"); - } - } - // ACEScct of 18 % grey (S-2016-001). - assert!((Tf::AcesCct.encode(0.18) - 0.413_588).abs() < 1e-5); - // PQ: 100 nits (1.0) encodes to ≈ 0.508. - assert!((Tf::Pq.encode(1.0) - 0.508).abs() < 1e-3); - } - - #[test] - fn color_space_transform_effect_round_trips() { - let img = Image::filled(4, 4, [0.2, 0.5, 0.7, 1.0]); - let srgb = SPACES.iter().position(|s| s.name == "sRGB").unwrap() as u32; - let cg = SPACES.iter().position(|s| s.name == "ACEScg").unwrap() as u32; - let a = run_fx("ec.color.ociocolorspace", &[("source", Value::Enum(srgb)), ("destination", Value::Enum(cg))], img.clone(), 0.0, EffectEnv::default()); - assert_ne!(a.img.data, img.data); - let b = run_fx("ec.color.ociocolorspace", &[("source", Value::Enum(cg)), ("destination", Value::Enum(srgb))], a.img.clone(), 0.0, EffectEnv::default()); - let c = run_fx( - "ec.color.ociocolorspace", - &[("source", Value::Enum(srgb)), ("destination", Value::Enum(cg)), ("direction", Value::Enum(1))], - a.img, - 0.0, - EffectEnv::default(), - ); - for (x, y) in b.img.data.iter().zip(&img.data).chain(c.img.data.iter().zip(&img.data)) { - assert!((0..4).all(|k| (x[k] - y[k]).abs() < 1e-4), "{x:?} {y:?}"); - } - } - - #[test] - fn cdl_math() { - let cdl = Cdl { slope: [1.2, 1.0, 0.8], offset: [0.05, 0.0, -0.02], power: [1.1, 1.0, 0.9], sat: 0.8 }; - let c = [0.3, 0.5, 0.7]; - // Hand computation of the v1.2 formula. - let sop = [(0.3f64 * 1.2 + 0.05).powf(1.1), 0.5, (0.7f64 * 0.8 - 0.02).powf(0.9)]; - let l = sop[0] * 0.2126 + sop[1] * 0.7152 + sop[2] * 0.0722; - let want = sop.map(|v| l + 0.8 * (v - l)); - let got = cdl.apply(c, true); - for k in 0..3 { - assert!((got[k] - want[k]).abs() < 1e-12); - } - let back = cdl.invert(got, true); - for k in 0..3 { - assert!((back[k] - c[k]).abs() < 1e-9); - } - // v1.2 clamps, No Clamp keeps negatives. - let neg = Cdl { offset: [-0.5; 3], ..Cdl::default() }; - assert_eq!(neg.apply([0.2; 3], true), [0.0; 3]); - assert!((neg.apply([0.2; 3], false)[0] + 0.3).abs() < 1e-12); - let img = Image::filled(2, 2, [0.3, 0.5, 0.7, 1.0]); - let out = run_fx("ec.color.ociocdl", &[("slopeRed", num(1.2)), ("offsetRed", num(0.05)), ("powerRed", num(1.1))], img, 0.0, EffectEnv::default()); - let r = (0.3f64 * 1.2 + 0.05).powf(1.1); - assert!((out.img.get(0, 0)[0] as f64 - r).abs() < 1e-5); - } - - fn identity_lattice(n: usize, f: impl Fn([f32; 3]) -> [f32; 3]) -> Vec<[f32; 3]> { - let mut v = vec![]; - for b in 0..n { - for g in 0..n { - for r in 0..n { - let s = (n - 1) as f32; - v.push(f([r as f32 / s, g as f32 / s, b as f32 / s])); - } - } - } - v - } - - /// Sony Imageworks `.spi1d`, `.spi3d` and `.spimtx` (Blender's configs use them, #410), and - /// malformed ones rejected without panicking. - #[test] - fn spi_files_parse() { - let spi1d = "Version 1\nFrom 0.0 2.0\nLength 3\nComponents 1\n{\n0.0\n0.25\n1.0\n}\n"; - let x = parse_file(spi1d, "spi1d").unwrap(); - let o = x.apply([1.0, 0.5, 2.0], 1, false, ""); - assert!((o[0] - 0.25).abs() < 1e-6 && (o[1] - 0.125).abs() < 1e-6 && (o[2] - 1.0).abs() < 1e-6, "{o:?}"); - let back = x.apply(o, 1, true, ""); - assert!((back[0] - 1.0).abs() < 1e-5 && (back[2] - 2.0).abs() < 1e-5, "{back:?}"); - let three = parse_spi1d("Version 1\nFrom 0 1\nLength 2\nComponents 3\n{\n0 0 0\n1 0.5 2\n}\n").unwrap(); - assert_eq!(three.d1, vec![[0.0; 3], [1.0, 0.5, 2.0]]); - let mut spi3d = String::from("SPILUT 1.0\n3 3\n2 2 2\n"); - for (r, g, b) in (0..8).map(|i| (i & 1, (i >> 1) & 1, i >> 2)) { - spi3d += &format!("{r} {g} {b} {} {} {}\n", r as f32 * 0.5, g, b); - } - let x = parse_file(&spi3d, "spi3d").unwrap(); - let o = x.apply([1.0, 0.5, 0.25], 1, false, ""); - assert!((o[0] - 0.5).abs() < 1e-6 && (o[1] - 0.5).abs() < 1e-6 && (o[2] - 0.25).abs() < 1e-6, "{o:?}"); - let m = parse_file("2 0 0 6553.5\n0 1 0 0\n0 0 1 0\n", "spimtx").unwrap(); - let o = m.apply([0.25, 0.5, 1.0], 1, false, ""); - assert!((o[0] - 0.6).abs() < 1e-6 && o[1] == 0.5 && o[2] == 1.0, "{o:?}"); - assert!((m.apply(o, 1, true, "")[0] - 0.25).abs() < 1e-6); - for bad in [ - "Version 1\nFrom 0 1\nLength 99999999999\nComponents 1\n{\n0\n}\n", - "Version 1\nFrom 1 0\nLength 2\nComponents 1\n{\n0\n1\n}\n", - "Version 1\nFrom 0 1\nLength 3\nComponents 1\n{\n0\n1\n}\n", - "Version 1\nFrom 0 1\nLength 2\nComponents 2\n{\n0 0\n1 1\n}\n", - ] { - assert!(parse_spi1d(bad).is_none(), "{bad}"); - } - assert!(parse_spi3d("SPILUT 1.0\n3 3\n4000 4000 4000\n").is_none()); - assert!(parse_spi3d("SPILUT 1.0\n3 3\n2 2 2\n9 0 0 1 1 1\n").is_none()); - assert!(parse_spi3d("SPILUT 1.0\n3 3\n2 2 2\n0 0 0 1 1 1\n").is_none(), "incomplete lattice"); - assert!(parse_spimtx("1 0 0\n0 1 0\n").is_none()); - assert!(parse_spimtx("NaN 0 0 0 0 1 0 0 0 0 1 0").is_none()); - } - - #[test] - fn lut_files_parse_and_interpolate() { - // .cube with a non-linear lattice: trilinear between lattice points. - let f = |c: [f32; 3]| [c[0] * c[0], c[1], 1.0 - c[2]]; - let mut cube = String::from("LUT_3D_SIZE 3\n"); - for r in identity_lattice(3, f) { - cube += &format!("{} {} {}\n", r[0], r[1], r[2]); - } - let Some(FileXform::Lut { lut, .. }) = parse_file(&cube, "cube") else { panic!() }; - // Trilinear between 0.5 (0.25) and 1.0 (1.0) at 0.75 → 0.625. - let o = lut.apply_interp([0.75, 0.3, 0.2], 1); - assert!((o[0] - 0.625).abs() < 1e-6 && (o[1] - 0.3).abs() < 1e-6 && (o[2] - 0.8).abs() < 1e-6, "{o:?}"); - // Nearest snaps. - assert_eq!(lut.apply_interp([0.74, 0.0, 0.0], 0)[0], 0.25); - // .3dl (blue fastest, 10-bit integers, mesh line). - let mut tdl = String::from("0 511 1023\n"); - for r in 0..3 { - for g in 0..3 { - for b in 0..3 { - let v = [r, g, b].map(|i| i as f64 * 511.5); - tdl += &format!("{} {} {}\n", v[0].round(), v[1].round(), (1023.0 - v[2]).round()); - } - } - } - let Some(FileXform::Lut { lut, .. }) = parse_file(&tdl, "3dl") else { panic!() }; - let o = lut.apply_interp([0.25, 0.5, 0.25], 1); - assert!((o[0] - 0.25).abs() < 2e-3 && (o[1] - 0.5).abs() < 2e-3 && (o[2] - 0.75).abs() < 2e-3, "{o:?}"); - // .csp with a squaring pre-LUT on red and an identity 2³ lattice. - let mut csp = String::from("CSPLUTV100\n3D\n\n3\n0 0.5 1\n0 0.25 1\n2\n0 1\n0 1\n2\n0 1\n0 1\n\n2 2 2\n"); - for r in identity_lattice(2, |c| c) { - csp += &format!("{} {} {}\n", r[0], r[1], r[2]); - } - let x = parse_file(&csp, "csp").unwrap(); - let o = x.apply([0.5, 0.4, 0.6], 2, false, ""); - assert!((o[0] - 0.25).abs() < 1e-6 && (o[1] - 0.4).abs() < 1e-6, "{o:?}"); - let inv = x.apply(o, 2, true, ""); - assert!((inv[0] - 0.5).abs() < 1e-4, "{inv:?}"); - // CDL collection: pick by id. - let ccc = r#"2 2 20 0 01 1 1 - 0.5 0.5 0.51"#; - let x = parse_file(ccc, "ccc").unwrap(); - assert!((x.apply([0.4; 3], 2, false, "b")[0] - 0.2).abs() < 1e-6); - assert!((x.apply([0.4; 3], 2, false, "a")[0] - 0.8).abs() < 1e-6); - assert!((x.apply([0.4; 3], 2, false, "")[0] - 0.8).abs() < 1e-6); - // Effect with inline content; inverse of a 3D lattice. - let img = Image::filled(2, 2, [0.6, 0.3, 0.2, 1.0]); - let out = run_fx("ec.color.ociofile", &[("file", Value::Str(cube.clone()))], img.clone(), 0.0, EffectEnv::default()); - let back = run_fx("ec.color.ociofile", &[("file", Value::Str(cube)), ("direction", Value::Enum(1))], out.img, 0.0, EffectEnv::default()); - assert!((back.img.get(0, 0)[0] - 0.6).abs() < 1e-3, "{:?}", back.img.get(0, 0)); - assert!(parse_file("garbage", "3dl").is_none()); - } - - #[test] - fn display_and_look_transforms() { - // Linear 0.18 through the sRGB display / Standard view = sRGB encoding of 0.18. - let img = Image::filled(2, 2, [0.18, 0.18, 0.18, 1.0]); - let out = run_fx("ec.color.ociodisplay", &[], img.clone(), 0.0, EffectEnv::default()); - assert!((out.img.get(0, 0)[0] as f64 - Tf::Srgb.encode(0.18)).abs() < 1e-5); - let back = run_fx("ec.color.ociodisplay", &[("direction", Value::Enum(1))], out.img, 0.0, EffectEnv::default()); - assert!((back.img.get(0, 0)[0] - 0.18).abs() < 1e-5); - // Tone mapped view keeps highlights below 1. - let hot = Image::filled(1, 1, [8.0, 8.0, 8.0, 1.0]); - let tm = run_fx("ec.color.ociodisplay", &[("view", Value::Enum(1))], hot, 0.0, EffectEnv::default()); - assert!(tm.img.get(0, 0)[0] < 1.0); - assert!((tonemap_inv(tonemap(3.0)) - 3.0).abs() < 1e-9); - // Looks: Warm raises red over blue; its inverse undoes it. - let grey = Image::filled(1, 1, [0.18, 0.18, 0.18, 1.0]); - let w = run_fx("ec.color.ociolook", &[("looks", Value::Str("Warm".into()))], grey.clone(), 0.0, EffectEnv::default()); - let px = w.img.get(0, 0); - assert!(px[0] > px[2]); - let u = run_fx("ec.color.ociolook", &[("looks", Value::Str("-Warm".into()))], w.img, 0.0, EffectEnv::default()); - assert!((u.img.get(0, 0)[0] - 0.18).abs() < 1e-4 && (u.img.get(0, 0)[2] - 0.18).abs() < 1e-4); - assert_eq!(parse_looks("Warm, -Cool, nope").len(), 2); - } - - #[test] - fn profile_converter_converts_and_round_trips() { - let img = Image::filled(2, 2, [0.8, 0.4, 0.1, 1.0]); - // sRGB → Rec. 2020 (relative), then back. - let a = run_fx( - "ec.utility.colorprofileconverter", - &[("inputProfile", Value::Enum(1)), ("outputProfile", Value::Enum(3))], - img.clone(), - 0.0, - EffectEnv::default(), - ); - assert_ne!(a.img.data, img.data); - let b = run_fx( - "ec.utility.colorprofileconverter", - &[("inputProfile", Value::Enum(3)), ("outputProfile", Value::Enum(1))], - a.img, - 0.0, - EffectEnv::default(), - ); - assert!((0..3).all(|k| (b.img.get(0, 0)[k] - img.get(0, 0)[k]).abs() < 1e-4)); - // Same profile both sides: identity. Working space defaults to sRGB. - let c = run_fx( - "ec.utility.colorprofileconverter", - &[("inputProfile", Value::Enum(0)), ("outputProfile", Value::Enum(1))], - img.clone(), - 0.0, - EffectEnv::default(), - ); - assert_eq!(c.img.data, img.data); - // Perceptual pulls out-of-gamut colours inside: saturated Rec.2020 green into sRGB. - let g = Image::filled(1, 1, [0.0, 1.0, 0.0, 1.0]); - let rel = run_fx( - "ec.utility.colorprofileconverter", - &[("inputProfile", Value::Enum(3)), ("outputProfile", Value::Enum(1))], - g.clone(), - 0.0, - EffectEnv::default(), - ); - assert!(rel.img.get(0, 0)[0] < 0.0); - let per = run_fx( - "ec.utility.colorprofileconverter", - &[("inputProfile", Value::Enum(3)), ("outputProfile", Value::Enum(1)), ("intent", Value::Enum(0))], - g, - 0.0, - EffectEnv::default(), - ); - assert!(per.img.get(0, 0).iter().all(|v| *v >= -1e-6)); - } - - struct RefHost(Image); - impl EffectHost for RefHost { - fn layer(&self, _: u64, _: bool) -> Option { - None - } - fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { - None - } - fn self_at(&self, _: f64, _: usize) -> Option { - Some(Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 }) - } - } - - #[test] - fn color_stabilizer_matches_the_reference_frame() { - let mk = |k: f32, o: f32| { - let mut img = Image::new(40, 40); - for y in 0..40 { - for x in 0..40 { - let v = (x as f32 / 40.0) * k + o; - img.set(x, y, [v, v * 0.9, v * 0.8, 1.0]); - } - } - img - }; - let reference = mk(1.0, 0.0); - let flicker = mk(0.8, 0.1); - let host = RefHost(reference.clone()); - let env = EffectEnv { host: Some(&host), ..Default::default() }; - let pts = [("blackPoint", Value::Vec2([5.0, 20.0])), ("midPoint", Value::Vec2([20.0, 20.0])), ("whitePoint", Value::Vec2([35.0, 20.0]))]; - let mut vals: Vec<(&str, Value)> = pts.to_vec(); - vals.push(("stabilize", Value::Enum(1))); - vals.push(("sampleSize", num(1.0))); - let out = run_fx("ec.color.colorstabilizer", &vals, flicker.clone(), 0.0, env); - for x in [5i64, 20, 35] { - let (o, r) = (out.img.get(x, 20), reference.get(x, 20)); - assert!((o[0] - r[0]).abs() < 0.01, "x {x}: {o:?} vs {r:?}"); - } - // Deterministic, and a no-op without a host. - let again = run_fx("ec.color.colorstabilizer", &vals, flicker.clone(), 0.0, env); - assert_eq!(out.img.data, again.img.data); - let none = run_fx("ec.color.colorstabilizer", &vals, flicker.clone(), 0.0, EffectEnv::default()); - assert_eq!(none.img.data, flicker.data); - // Brightness mode corrects an offset at the black point. - let lifted = mk(1.0, 0.1); - let mut vals2: Vec<(&str, Value)> = pts.to_vec(); - vals2.push(("sampleSize", num(1.0))); - let b = run_fx("ec.color.colorstabilizer", &vals2, lifted, 0.0, env); - assert!((b.img.get(5, 20)[0] - reference.get(5, 20)[0]).abs() < 0.01); - } -} +mod tests; diff --git a/crates/effects/src/ocio/cdl.rs b/crates/effects/src/ocio/cdl.rs new file mode 100644 index 000000000..dba8c68fe --- /dev/null +++ b/crates/effects/src/ocio/cdl.rs @@ -0,0 +1,115 @@ +use super::{ParamUi, slider}; + +// ---------------------------------------------------------------- ASC CDL + +/// An ASC Colour Decision List correction. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Cdl { + pub slope: [f64; 3], + pub offset: [f64; 3], + pub power: [f64; 3], + pub sat: f64, +} + +impl Default for Cdl { + fn default() -> Self { + Cdl { slope: [1.0; 3], offset: [0.0; 3], power: [1.0; 3], sat: 1.0 } + } +} + +const LUMA: [f64; 3] = [0.2126, 0.7152, 0.0722]; + +impl Cdl { + /// Forward CDL. `clamp` = the v1.2 style (results clamped to 0..1 after SOP and saturation). + pub fn apply(&self, c: [f64; 3], clamp: bool) -> [f64; 3] { + let mut o = [0.0; 3]; + for i in 0..3 { + let v = c[i] * self.slope[i] + self.offset[i]; + o[i] = if clamp { + v.clamp(0.0, 1.0).powf(self.power[i]) + } else if v >= 0.0 { + v.powf(self.power[i]) + } else { + v + }; + } + let l = o[0] * LUMA[0] + o[1] * LUMA[1] + o[2] * LUMA[2]; + let o = o.map(|v| l + self.sat * (v - l)); + if clamp { o.map(|v| v.clamp(0.0, 1.0)) } else { o } + } + /// Exact inverse of [`Cdl::apply`] (inside the clamped range). + pub fn invert(&self, c: [f64; 3], clamp: bool) -> [f64; 3] { + let c = if clamp { c.map(|v| v.clamp(0.0, 1.0)) } else { c }; + let l = c[0] * LUMA[0] + c[1] * LUMA[1] + c[2] * LUMA[2]; + let s = if self.sat.abs() < 1e-9 { 1e-9 } else { self.sat }; + let o = c.map(|v| l + (v - l) / s); + let mut r = [0.0; 3]; + for i in 0..3 { + let v = if clamp { o[i].clamp(0.0, 1.0) } else { o[i] }; + let pw = self.power[i].max(1e-9); + let u = if v >= 0.0 { v.powf(1.0 / pw) } else { v }; + let sl = if self.slope[i].abs() < 1e-9 { 1e-9 } else { self.slope[i] }; + r[i] = (u - self.offset[i]) / sl; + } + if clamp { r.map(|v| v.clamp(0.0, 1.0)) } else { r } + } +} + +fn tag<'a>(xml: &'a str, name: &str) -> Option<&'a str> { + let open = xml.find(&format!("<{name}"))?; + let gt = open + xml[open..].find('>')?; + if xml[..gt].ends_with('/') { + return Some(""); + } + let close = xml[gt..].find(&format!(""))? + gt; + Some(&xml[gt + 1..close]) +} + +fn nums3(s: &str) -> Option<[f64; 3]> { + let v: Vec = s.split_whitespace().filter_map(|t| t.parse().ok()).collect(); + (v.len() >= 3).then(|| [v[0], v[1], v[2]]) +} + +fn parse_cc(block: &str) -> Cdl { + let mut c = Cdl::default(); + if let Some(s) = tag(block, "Slope").and_then(nums3) { + c.slope = s; + } + if let Some(s) = tag(block, "Offset").and_then(nums3) { + c.offset = s; + } + if let Some(s) = tag(block, "Power").and_then(nums3) { + c.power = s; + } + if let Some(s) = tag(block, "Saturation").and_then(|t| t.trim().parse().ok()) { + c.sat = s; + } + c +} + +/// Parse ASC CDL XML (`.cc`, `.ccc`, `.cdl`): every `` with its `id`. +pub fn parse_cdl_xml(xml: &str) -> Vec<(String, Cdl)> { + let mut out = vec![]; + let mut rest = xml; + while let Some(i) = rest.find("').unwrap_or(after.len()); + let head = &after[..head_end]; + let id = head + .find("id=") + .and_then(|k| { + let q = head[k + 3..].chars().next()?; + let s = &head[k + 4..]; + s.find(q).map(|e| s[..e].to_string()) + }) + .unwrap_or_default(); + let end = after.find("").map(|e| e + "".len()).unwrap_or(after.len()); + out.push((id, parse_cc(&after[..end]))); + rest = &after[end..]; + } + out +} + +pub(super) fn sl(lo: f64, hi: f64, smin: f64, smax: f64) -> ParamUi { + slider(lo, hi, smin, smax, 3) +} diff --git a/crates/effects/src/ocio/effects.rs b/crates/effects/src/ocio/effects.rs new file mode 100644 index 000000000..5df24096d --- /dev/null +++ b/crates/effects/src/ocio/effects.rs @@ -0,0 +1,467 @@ +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::{Buf, ColorSpace, EffectCtx, EffectSpec, IntoParallelRefMutIterator, ParallelIterator, Value, invert, mul, mul_vec, p, popup, unpremul}; + +// ---------------------------------------------------------------- displays, views and looks + +/// Displays of the built-in config: (name, display primaries/white, encoding). +pub const DISPLAYS: &[(&str, [[f64; 2]; 3], Tf)] = + &[("sRGB", REC709, Tf::Srgb), ("Rec.1886 Rec.709", REC709, Tf::Gamma(2.4)), ("Display P3", P3, Tf::Srgb), ("Rec.2100-PQ", REC2020, Tf::Pq)]; + +pub const VIEWS: &[&str] = &["Standard", "Tone Mapped", "Raw"]; + +/// Configuration popup: the built-in config, or a custom `.ocio` file (Config File: a path or +/// the config's text; spaces chosen by name, see [`crate::ocio_config`]). +pub const CONFIGS: &[&str] = &["Built-in (EffectCraft minimal)", "Custom (.ocio file)"]; + +/// Built-in looks: CDLs applied in ACEScct. +pub fn looks() -> Vec<(&'static str, Cdl)> { + let grey = 0.413_588_402_492_442_1; // ACEScct of 0.18 + let c = |slope: [f64; 3], offset: [f64; 3], sat: f64| Cdl { slope, offset, power: [1.0; 3], sat }; + vec![ + ("Warm", c([1.04, 1.0, 0.94], [0.0; 3], 1.0)), + ("Cool", c([0.95, 1.0, 1.06], [0.0; 3], 1.0)), + ("High Contrast", c([1.25; 3], [grey * (1.0 - 1.25); 3], 1.0)), + ("Low Contrast", c([0.8; 3], [grey * (1.0 - 0.8); 3], 1.0)), + ("Desaturated", c([1.0; 3], [0.0; 3], 0.6)), + ("Vivid", c([1.0; 3], [0.0; 3], 1.3)), + ] +} + +/// Extended Reinhard roll-off (white point 16): maps 0..∞ into 0..1, ≈ identity near 0. +pub(super) fn tonemap(v: f64) -> f64 { + let w2 = 256.0; + if v <= 0.0 { v } else { v * (1.0 + v / w2) / (1.0 + v) } +} + +pub(super) fn tonemap_inv(y: f64) -> f64 { + if y <= 0.0 { + return y; + } + // Solve v² / w² + v (1 − y) − y = 0. + let (a, b, c) = (1.0 / 256.0, 1.0 - y, -y); + (-b + (b * b - 4.0 * a * c).sqrt()) / (2.0 * a) +} + +/// Display transform of an input-space colour (`inverse`: display code values → input space). +pub fn display_xform(input: &Space, display: usize, view: usize, inverse: bool) -> impl Fn([f32; 3]) -> [f32; 3] + Sync { + let (_, prims, tf) = DISPLAYS[display.min(DISPLAYS.len() - 1)]; + let disp = Space { name: "display", prims: Some((prims, D65)), tf: Tf::Linear, raw: false }; + let raw = view == 2 || input.raw; + let m_in = Xform::new(&Space { tf: Tf::Linear, ..*input }, &disp); + let m_out = Xform::new(&disp, &Space { tf: Tf::Linear, ..*input }); + let in_tf = input.tf; + move |c: [f32; 3]| { + if raw { + return c; + } + if !inverse { + let lin = c.map(|v| in_tf.decode(v as f64) as f32); + let d = m_in.apply(lin); + d.map(|v| { + let v = v as f64; + let v = if view == 1 { tonemap(v) } else { v }; + tf.encode(v) as f32 + }) + } else { + let d = c.map(|v| { + let l = tf.decode(v as f64); + (if view == 1 { tonemap_inv(l) } else { l }) as f32 + }); + m_out.apply(d).map(|v| in_tf.encode(v as f64) as f32) + } + } +} + +// ---------------------------------------------------------------- effects + +pub(super) fn spec(id: &'static str, name: &'static str, cat: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: cat, params, render, gpu: false, float: true } +} + +pub(super) fn space_param(id: &'static str, name: &'static str, default: u32) -> crate::ParamSpec { + p(id, name, Value::Enum(default), popup(&space_names())) +} + +pub(super) fn direction() -> crate::ParamSpec { + p("direction", "Direction", Value::Enum(0), popup(&["Forward", "Inverse"])) +} + +fn map_px(b: &mut Buf, f: impl Fn([f32; 3]) -> [f32; 3] + Sync) { + b.img.data.par_iter_mut().for_each(|px| { + let (c, a) = unpremul(*px); + if a <= 0.0 { + return; + } + let o = f(c); + *px = [o[0] * a, o[1] * a, o[2] * a, a]; + }); +} + +pub(super) fn space_at(i: u32) -> &'static Space { + &SPACES[(i as usize).min(SPACES.len() - 1)] +} + +/// The effects that take Configuration ▸ Custom (a `.ocio` file). +pub(crate) const CUSTOM_CONFIG_FX: [&str; 2] = ["ec.color.ociocolorspace", "ec.color.ociodisplay"]; + +/// Effect `id`'s transform under Configuration ▸ Custom: the config's conversion from the source +/// space to the destination space (Color Space Transform) or through a display's view (Display +/// Transform), see [`crate::ocio_config`]. `Ok(None)` with the built-in configuration; an error +/// (the pixels pass through) when the config can't be read or has no such colour space. +pub(super) fn custom_xf(id: &str, pr: &crate::Params) -> Result, String> { + use crate::ocio_config::Xf; + if !CUSTOM_CONFIG_FX.contains(&id) || pr.e("config") != 1 { + return Ok(None); + } + let cfg = crate::ocio_config::load_config(pr.s("configFile")).map_err(|e| format!("Can't read the OCIO config: {e}"))?; + // A space by its typed name, else by the built-in popup's name. + let space = |name: &str, popup: u32| { + let name = if name.trim().is_empty() { space_at(popup).name } else { name.trim() }; + cfg.space(name).ok_or_else(|| format!("The OCIO config has no color space `{name}`")) + }; + let a = space(pr.s("sourceName"), pr.e("source"))?; + let inverse = pr.e("direction") == 1; + if id == "ec.color.ociodisplay" { + let (dn, vn) = display_view_names(pr); + let xf = cfg.display_path(a, dn, vn); + return Ok(Some(if inverse { Xf::Inverse(Box::new(xf)) } else { xf })); + } + let z = space(pr.s("destinationName"), pr.e("destination"))?; + // Inverse: the conversion the other way (OCIO's inverse ColorSpaceTransform). + Ok(Some(if inverse { cfg.path(z, a) } else { cfg.path(a, z) })) +} + +/// The display and view a Display Transform names: the typed names, else the popups'. +fn display_view_names(pr: &crate::Params) -> (&str, &str) { + let display = DISPLAYS.get(pr.e("display") as usize).map_or("", |d| d.0); + let view = VIEWS.get(pr.e("view") as usize).copied().unwrap_or(""); + let typed = |k: &str, popup| if pr.s(k).trim().is_empty() { popup } else { pr.s(k).trim() }; + (typed("displayName", display), typed("viewName", view)) +} + +/// Effect Controls' warning for effect `id`: a custom OCIO configuration that can't be read, has +/// no such colour space, display or view, or uses transforms EffectCraft can't apply (colours +/// pass through those steps, #410). `None` when the effect renders as asked. +pub fn warning(id: &str, pr: &crate::Params) -> Option { + let xf = match custom_xf(id, pr) { + Ok(x) => x?, + Err(e) => return Some(format!("{e}: the colors pass through.")), + }; + let mut notes = vec![]; + if id == "ec.color.ociodisplay" + && let Ok(cfg) = crate::ocio_config::load_config(pr.s("configFile")) + { + let (dn, vn) = display_view_names(pr); + if let Some((d, v)) = cfg.view(dn, vn) + && (!d.eq_ignore_ascii_case(dn) || !v.name.eq_ignore_ascii_case(vn)) + { + notes.push(format!("The OCIO config has no display `{dn}` with a view `{vn}`: showing {d} / {}.", v.name)); + } + } + let missing = xf.unsupported_list(); + if !missing.is_empty() { + notes.push(format!("Not supported, the colors pass through: {}.", missing.join(", "))); + } + (!notes.is_empty()).then(|| notes.join(" ")) +} + +/// Configuration ▸ Custom: render through the config's transform. `false` with the built-in +/// configuration (the caller renders it). +fn custom_render(id: &str, ctx: &EffectCtx, b: &mut Buf) -> bool { + match custom_xf(id, ctx.params) { + Ok(None) => false, + Ok(Some(xf)) => { + map_px(b, |c| xf.apply(c.map(|v| v as f64), false).map(|v| v as f32)); + true + } + // A config that can't be read, or an unknown space: the pixels pass through. + Err(_) => true, + } +} + +pub(super) fn ocio_cst(ctx: &EffectCtx, mut b: Buf) -> Buf { + if custom_render("ec.color.ociocolorspace", ctx, &mut b) { + return b; + } + let (a, z) = (space_at(ctx.params.e("source")), space_at(ctx.params.e("destination"))); + let (a, z) = if ctx.params.e("direction") == 1 { (z, a) } else { (a, z) }; + if a.name == z.name { + return b; + } + let x = Xform::new(a, z); + map_px(&mut b, |c| x.apply(c)); + b +} + +pub(super) fn cdl_from_params(pr: &crate::Params) -> Cdl { + Cdl { + slope: [pr.f("slopeRed"), pr.f("slopeGreen"), pr.f("slopeBlue")], + offset: [pr.f("offsetRed"), pr.f("offsetGreen"), pr.f("offsetBlue")], + power: [pr.f("powerRed").max(0.0), pr.f("powerGreen").max(0.0), pr.f("powerBlue").max(0.0)], + sat: pr.f("saturation").max(0.0), + } +} + +pub(super) fn ocio_cdl(ctx: &EffectCtx, mut b: Buf) -> Buf { + let cdl = cdl_from_params(ctx.params); + if cdl == Cdl::default() { + return b; + } + let clamp = ctx.params.e("style") == 0; + let inv = ctx.params.e("direction") == 1; + map_px(&mut b, |c| { + let c = c.map(|v| v as f64); + let o = if inv { cdl.invert(c, clamp) } else { cdl.apply(c, clamp) }; + o.map(|v| v as f32) + }); + b +} + +pub(super) fn ocio_display(ctx: &EffectCtx, mut b: Buf) -> Buf { + if custom_render("ec.color.ociodisplay", ctx, &mut b) { + return b; + } + let input = space_at(ctx.params.e("source")); + let f = display_xform(input, ctx.params.e("display") as usize, ctx.params.e("view") as usize, ctx.params.e("direction") == 1); + map_px(&mut b, f); + b +} + +pub(super) fn ocio_file(ctx: &EffectCtx, mut b: Buf) -> Buf { + let Some(x) = load_file(ctx.params.s("file")) else { return b }; + let interp = match ctx.params.e("interpolation") { + 0 => 0, + 1 => 1, + _ => 2, + }; + let inv = ctx.params.e("direction") == 1; + let id = ctx.params.s("cccId").to_string(); + map_px(&mut b, |c| x.apply(c, interp, inv, &id)); + b +} + +/// Parse a looks string: comma/space separated names, `-Name` = inverse. +pub fn parse_looks(s: &str) -> Vec<(Cdl, bool)> { + let all = looks(); + s.split([',', ':']) + .map(str::trim) + .filter(|t| !t.is_empty()) + .filter_map(|t| { + let (inv, n) = match t.strip_prefix('-') { + Some(r) => (true, r.trim()), + None => (false, t.trim_start_matches('+').trim()), + }; + all.iter().find(|(name, _)| name.eq_ignore_ascii_case(n)).map(|(_, c)| (*c, inv)) + }) + .collect() +} + +pub(super) fn ocio_look(ctx: &EffectCtx, mut b: Buf) -> Buf { + let mut list = parse_looks(ctx.params.s("looks")); + let pick = ctx.params.e("look"); + if pick > 0 + && let Some((_, c)) = looks().get(pick as usize - 1) + { + list.insert(0, (*c, false)); + } + if list.is_empty() { + return b; + } + let (src, dst) = (space_at(ctx.params.e("source")), space_at(ctx.params.e("destination"))); + let inv = ctx.params.e("direction") == 1; + let cct = &ACES_CCT; + let (a, z) = if inv { (dst, src) } else { (src, dst) }; + let to_p = Xform::new(a, cct); + let from_p = Xform::new(cct, z); + if inv { + list.reverse(); + } + map_px(&mut b, |c| { + let mut v = to_p.apply(c).map(|x| x as f64); + for (cdl, li) in &list { + v = if *li != inv { cdl.invert(v, false) } else { cdl.apply(v, false) }; + } + from_p.apply(v.map(|x| x as f32)) + }); + b +} + +// ---- Color Profile Converter (Utility) + +pub const PROFILES: &[&str] = &["Project Working Space", "sRGB IEC61966-2.1", "HDTV (Rec. 709)", "Rec. 2020", "Display P3", "ACEScg", "ACES2065-1"]; + +pub(super) fn profile(ctx: &EffectCtx, i: u32) -> (Space, bool) { + let ws = |cs: ColorSpace| match cs { + ColorSpace::Srgb => sp("sRGB", REC709, D65, Tf::Srgb), + ColorSpace::Rec709 => sp("Rec.709", REC709, D65, Tf::Gamma(2.4)), + ColorSpace::Rec2020 => sp("Rec.2020", REC2020, D65, Tf::Gamma(2.4)), + ColorSpace::DisplayP3 => sp("Display P3", P3, D65, Tf::Srgb), + ColorSpace::AcesCg => ACES_CG, + ColorSpace::Aces2065 => ACES2065_1, + // HDR encodings are output spaces, never a working space. + ColorSpace::Rec2100Pq | ColorSpace::Rec2100Hlg => sp("Rec.2020", REC2020, D65, Tf::Gamma(2.4)), + }; + match i { + 0 => (ws(ctx.env.working_space.unwrap_or(ColorSpace::Srgb)), ctx.env.working_linear), + 1 => (ws(ColorSpace::Srgb), false), + 2 => (ws(ColorSpace::Rec709), false), + 3 => (ws(ColorSpace::Rec2020), false), + 4 => (ws(ColorSpace::DisplayP3), false), + 5 => (ACES_CG, true), + _ => (ACES2065_1, true), + } +} + +/// Gamut compression for the perceptual intent: pull out-of-gamut (negative) colours toward +/// their luminance just enough to bring every channel to ≥ 0, rolling off smoothly. +fn compress_gamut(c: [f64; 3], luma: [f64; 3]) -> [f64; 3] { + let l = c[0] * luma[0] + c[1] * luma[1] + c[2] * luma[2]; + if l <= 0.0 { + return c.map(|v| v.max(0.0)); + } + let mn = c.iter().cloned().fold(f64::INFINITY, f64::min); + if mn >= 0.0 { + return c; + } + // Fraction of the way toward grey that reaches zero on the most negative channel. + let t = (l / (l - mn)).clamp(0.0, 1.0); + c.map(|v| l + (v - l) * t) +} + +pub(super) fn profile_converter(ctx: &EffectCtx, mut b: Buf) -> Buf { + let (inp, in_lin_default) = profile(ctx, ctx.params.e("inputProfile")); + let (out, out_lin_default) = profile(ctx, ctx.params.e("outputProfile")); + let in_lin = ctx.params.b("linearizeInputProfile") || in_lin_default && ctx.params.e("inputProfile") == 0; + let out_lin = ctx.params.b("linearizeOutputProfile") || out_lin_default && ctx.params.e("outputProfile") == 0; + let intent = ctx.params.e("intent"); + let dec = if in_lin { Tf::Linear } else { inp.tf }; + let enc = if out_lin { Tf::Linear } else { out.tf }; + // Absolute colorimetric keeps the source white (no adaptation); the others adapt. + let m = if intent == 3 { mul(&invert(&out.to_ref(false)), &inp.to_ref(false)) } else { matrix(&inp, &out) }; + let identity = dec == enc && (0..3).all(|i| (0..3).all(|j| (m[i][j] - IDENTITY[i][j]).abs() < 1e-9)); + if identity { + return b; + } + let out_luma = out.to_ref(true)[1]; + map_px(&mut b, |c| { + let l = c.map(|v| dec.decode(v as f64)); + let mut o = mul_vec(&m, l); + match intent { + 0 => o = compress_gamut(o, out_luma), + 2 => o = o.map(|v| v.max(0.0)), + _ => {} + } + o.map(|v| enc.encode(v) as f32) + }); + b +} + +// ---- Color Stabilizer + +/// Mean straight colour of a square (half-size `r` layer pixels) around layer point `pt`. +fn sample_mean(b: &Buf, pt: [f64; 2], r: f64) -> [f64; 3] { + let (cx, cy) = b.to_px(pt); + let rr = (r * b.scale).max(0.5); + let (x0, x1) = ((cx - rr).floor().max(0.0) as i64, ((cx + rr).ceil() as i64).min(b.img.width as i64)); + let (y0, y1) = ((cy - rr).floor().max(0.0) as i64, ((cy + rr).ceil() as i64).min(b.img.height as i64)); + let mut acc = [0.0f64; 4]; + for y in y0..y1 { + for x in x0..x1 { + let p = b.img.get(x, y); + for k in 0..4 { + acc[k] += p[k] as f64; + } + } + } + if acc[3] <= 1e-9 { + return [0.0; 3]; + } + [acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3]] +} + +/// Monotone piecewise-quadratic curve through (0,0), three samples and (1,1) (linear between +/// them is enough for a stabiliser and stays monotone). +fn curve3(xs: [f64; 3], ys: [f64; 3], v: f64) -> f64 { + let mut pts = vec![(0.0, 0.0)]; + let mut pairs: Vec<(f64, f64)> = xs.iter().cloned().zip(ys.iter().cloned()).collect(); + pairs.sort_by(|a, b| a.0.total_cmp(&b.0)); + for (x, y) in pairs { + if pts.last().is_some_and(|l| x > l.0 + 1e-4) && x < 1.0 - 1e-4 { + pts.push((x, y)); + } + } + pts.push((1.0, 1.0)); + let i = pts.partition_point(|p| p.0 < v).clamp(1, pts.len() - 1); + let (a, c) = (pts[i - 1], pts[i]); + a.1 + (c.1 - a.1) * (v - a.0) / (c.0 - a.0).max(1e-9) +} + +/// Color Stabilizer's correction of buffer `b` as one piecewise-linear map per channel (points +/// with increasing x; the end segments extend), the same maps `color_stabilizer` applies. +/// `None` without a host or reference frame (the layer passes through). (The GPU effect.) +pub fn color_stabilizer_maps(ctx: &EffectCtx, b: &Buf) -> Option<[Vec<(f64, f64)>; 3]> { + let reference = ctx.env.host?.self_at(ctx.params.f("referenceFrame"), ctx.env.effect_index)?; + let r = ctx.params.f("sampleSize").max(0.5); + let pts = [ctx.params.v2("blackPoint"), ctx.params.v2("midPoint"), ctx.params.v2("whitePoint")]; + let cur = pts.map(|pt| sample_mean(b, pt, r)); + let refv = pts.map(|pt| sample_mean(&reference, pt, r)); + let mode = ctx.params.e("stabilize"); + Some([0, 1, 2].map(|k| { + let offset = |c0: f64, r0: f64| vec![(0.0, r0 - c0), (1.0, 1.0 + r0 - c0)]; + match mode { + 0 => offset(cur[0][k], refv[0][k]), + 1 => { + let (c0, c1, r0, r1) = (cur[0][k], cur[2][k], refv[0][k], refv[2][k]); + if (c1 - c0).abs() < 1e-6 { + offset(c0, r0) + } else if c0 < c1 { + vec![(c0, r0), (c1, r1)] + } else { + vec![(c1, r1), (c0, r0)] + } + } + _ => { + let mut v = vec![(0.0, 0.0)]; + let mut pairs: Vec<(f64, f64)> = (0..3).map(|i| (cur[i][k], refv[i][k])).collect(); + pairs.sort_by(|a, b| a.0.total_cmp(&b.0)); + for (x, y) in pairs { + if v.last().is_some_and(|l| x > l.0 + 1e-4) && x < 1.0 - 1e-4 { + v.push((x, y)); + } + } + v.push((1.0, 1.0)); + v + } + } + })) +} + +pub(super) fn color_stabilizer(ctx: &EffectCtx, mut b: Buf) -> Buf { + let Some(host) = ctx.env.host else { return b }; + let Some(reference) = host.self_at(ctx.params.f("referenceFrame"), ctx.env.effect_index) else { return b }; + let r = ctx.params.f("sampleSize").max(0.5); + let pts = [ctx.params.v2("blackPoint"), ctx.params.v2("midPoint"), ctx.params.v2("whitePoint")]; + let cur = pts.map(|pt| sample_mean(&b, pt, r)); + let refv = pts.map(|pt| sample_mean(&reference, pt, r)); + let mode = ctx.params.e("stabilize"); + map_px(&mut b, |c| { + [0, 1, 2].map(|k| { + let v = c[k] as f64; + let o = match mode { + // Brightness: one sample (black point) — offset each channel. + 0 => v + refv[0][k] - cur[0][k], + // Levels: black and white samples — linear map per channel. + 1 => { + let (c0, c1, r0, r1) = (cur[0][k], cur[2][k], refv[0][k], refv[2][k]); + if (c1 - c0).abs() < 1e-6 { v + r0 - c0 } else { r0 + (v - c0) * (r1 - r0) / (c1 - c0) } + } + // Curves: three samples — a curve through them per channel. + _ => curve3([cur[0][k], cur[1][k], cur[2][k]], [refv[0][k], refv[1][k], refv[2][k]], v), + }; + o as f32 + }) + }); + b +} diff --git a/crates/effects/src/ocio/lut.rs b/crates/effects/src/ocio/lut.rs new file mode 100644 index 000000000..8a6481eb6 --- /dev/null +++ b/crates/effects/src/ocio/lut.rs @@ -0,0 +1,331 @@ +use super::cdl::{Cdl, parse_cdl_xml}; +use super::{Arc, HashMap, Lut, Mutex, OnceLock, parse_cube}; + +// ---------------------------------------------------------------- LUT files + +/// A loaded transform file. +#[derive(Clone, Debug)] +pub enum FileXform { + /// A lattice/1D LUT with an optional per-channel piecewise-linear shaper (cineSpace pre-LUT). + Lut { lut: Lut, shaper: Option<[Vec<(f32, f32)>; 3]> }, + /// CDL corrections by id (in file order). + Cdl(Vec<(String, Cdl)>), + /// A matrix with offset (Sony Imageworks `.spimtx`), as a MatrixTransform's. + Matrix { m: [f64; 16], offset: [f64; 4] }, +} + +/// Parse a Sony Imageworks `.spimtx` matrix: three rows of four numbers, three matrix columns +/// and an offset in 16-bit code values (÷ 65535). +pub fn parse_spimtx(text: &str) -> Option { + let v: Vec = text.split_whitespace().map(|t| t.parse().ok()).collect::>()?; + if v.len() != 12 || v.iter().any(|x| !x.is_finite()) { + return None; + } + let mut m = [0.0; 16]; + let mut offset = [0.0; 4]; + for (r, row) in v.as_chunks::<4>().0.iter().enumerate() { + m[r * 4..r * 4 + 3].copy_from_slice(&row[..3]); + offset[r] = row[3] / 65535.0; + } + m[15] = 1.0; + Some(FileXform::Matrix { m, offset }) +} + +/// Parse a Sony Imageworks `.spi1d` 1D LUT: `From `, `Length `, `Components +/// <1 | 3>`, then `{` the values `}`. +pub fn parse_spi1d(text: &str) -> Option { + let (mut from, mut len, mut comps) = ([0.0f32, 1.0], 0usize, 1usize); + let mut values: Vec = vec![]; + let mut body = false; + for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) { + if body { + if line.starts_with('}') { + break; + } + for t in line.split_whitespace() { + values.push(t.parse().ok()?); + } + continue; + } + let mut t = line.split_whitespace(); + match t.next()? { + "{" => body = true, + "From" => from = [t.next()?.parse().ok()?, t.next()?.parse().ok()?], + "Length" => len = t.next()?.parse().ok()?, + "Components" => comps = t.next()?.parse().ok()?, + _ => {} + } + } + // A 1D table is interpolated by index, so cap it like the lattices (2²⁴ entries). + if !(2..=1 << 24).contains(&len) + || !matches!(comps, 1 | 3) + || values.len() != len.checked_mul(comps)? + || from[1] <= from[0] + || from.iter().any(|v| !v.is_finite()) + { + return None; + } + let d1 = values.chunks_exact(comps).map(|c| if comps == 3 { [c[0], c[1], c[2]] } else { [c[0]; 3] }).collect(); + Some(Lut { n1: len, d1, n3: 0, d3: vec![], dmin: [from[0]; 3], dmax: [from[1]; 3] }) +} + +/// Parse a Sony Imageworks `.spi3d` lattice: `SPILUT 1.0`, `3 3`, the sizes, then one +/// `r g b R G B` line per lattice point (indices, then the colour). +pub fn parse_spi3d(text: &str) -> Option { + let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty()); + if !lines.next()?.starts_with("SPILUT") { + return None; + } + lines.next()?; + let size: Vec = lines.next()?.split_whitespace().map(|t| t.parse().ok()).collect::>()?; + let n = *size.first()?; + if size.len() != 3 || size.iter().any(|s| *s != n) || !(2..=129).contains(&n) { + return None; + } + let mut d3 = vec![[0.0f32; 3]; n * n * n]; + let mut seen = 0usize; + for line in lines { + let v: Vec<&str> = line.split_whitespace().collect(); + let [r, g, b, cr, cg, cb] = v.as_slice() else { return None }; + let (r, g, b): (usize, usize, usize) = (r.parse().ok()?, g.parse().ok()?, b.parse().ok()?); + if r >= n || g >= n || b >= n { + return None; + } + // Our lattice is red fastest. + *d3.get_mut(r + g * n + b * n * n)? = [cr.parse().ok()?, cg.parse().ok()?, cb.parse().ok()?]; + seen += 1; + } + (seen == n * n * n).then_some(Lut { n1: 0, d1: vec![], n3: n, d3, dmin: [0.0; 3], dmax: [1.0; 3] }) +} + +/// Parse an Autodesk `.3dl` lattice (integers, blue fastest; an optional input-mesh line). +pub fn parse_3dl(text: &str) -> Option { + let mut mesh: Option> = None; + let mut rows: Vec<[f64; 3]> = vec![]; + let mut out_bits: Option = None; + for line in text.lines() { + let l = line.trim(); + if l.is_empty() || l.starts_with('#') || l.starts_with('<') { + continue; + } + if l.starts_with(|c: char| c.is_ascii_alphabetic()) { + // "Mesh " (Lustre header); other keywords are ignored. + let t: Vec<&str> = l.split_whitespace().collect(); + if t[0].eq_ignore_ascii_case("mesh") && t.len() >= 3 { + out_bits = t[2].parse().ok(); + } + continue; + } + let v: Vec = l.split_whitespace().filter_map(|t| t.parse().ok()).collect(); + if v.len() > 3 && mesh.is_none() && rows.is_empty() { + mesh = Some(v); + } else if v.len() == 3 { + rows.push([v[0], v[1], v[2]]); + } else { + return None; + } + } + let is_cube = |k: usize| { + let n = (k as f64).cbrt().round() as usize; + n * n * n == k + }; + // A 3-entry input mesh (3³ lattice) looks like a data row: tell them apart by the count. + if mesh.is_none() && !is_cube(rows.len()) && rows.len() == 28 { + mesh = Some(rows.remove(0).to_vec()); + } + let n = match &mesh { + Some(m) => m.len(), + None => (rows.len() as f64).cbrt().round() as usize, + }; + if n < 2 || rows.len() != n * n * n { + return None; + } + let maxv = rows.iter().flat_map(|r| r.iter()).fold(0.0f64, |m, &v| m.max(v)); + let out_max = match out_bits { + Some(b) if b > 0 && b < 32 => (1u64 << b) as f64 - 1.0, + _ => [255.0, 1023.0, 4095.0, 65535.0].into_iter().find(|&m| maxv <= m).unwrap_or(maxv.max(1.0)), + }; + // .3dl rows: red slowest, blue fastest. Our lattice is red fastest. + let mut d3 = vec![[0.0f32; 3]; n * n * n]; + for (k, r) in rows.iter().enumerate() { + let (ri, gi, bi) = (k / (n * n), (k / n) % n, k % n); + d3[ri + gi * n + bi * n * n] = r.map(|v| (v / out_max) as f32); + } + Some(Lut { n1: 0, d1: vec![], n3: n, d3, dmin: [0.0; 3], dmax: [1.0; 3] }) +} + +/// Parse a cineSpace `.csp` LUT (1D or 3D, with its three pre-LUT shapers). +pub fn parse_csp(text: &str) -> Option<(Lut, [Vec<(f32, f32)>; 3])> { + let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty()); + if !lines.next()?.starts_with("CSPLUTV100") { + return None; + } + let kind = lines.next()?; + let mut lines = lines.peekable(); + if lines.peek().is_some_and(|l| l.starts_with("BEGIN METADATA")) { + for l in lines.by_ref() { + if l.starts_with("END METADATA") { + break; + } + } + } + let nums = |l: &str| -> Option> { l.split_whitespace().map(|t| t.parse().ok()).collect() }; + let mut shaper: [Vec<(f32, f32)>; 3] = Default::default(); + for ch in shaper.iter_mut() { + let n: usize = lines.next()?.parse().ok()?; + let ins = nums(lines.next()?)?; + let outs = nums(lines.next()?)?; + if ins.len() != n || outs.len() != n || n < 2 { + return None; + } + *ch = ins.into_iter().zip(outs).collect(); + } + if kind.starts_with("3D") { + let sz = nums(lines.next()?)?; + if sz.len() != 3 || sz[0] != sz[1] || sz[1] != sz[2] { + return None; + } + let n = sz[0] as usize; + let mut d3 = Vec::with_capacity(n * n * n); + for _ in 0..n * n * n { + let v = nums(lines.next()?)?; + if v.len() != 3 { + return None; + } + d3.push([v[0], v[1], v[2]]); + } + (n >= 2).then_some((Lut { n1: 0, d1: vec![], n3: n, d3, dmin: [0.0; 3], dmax: [1.0; 3] }, shaper)) + } else { + let n: usize = lines.next()?.parse().ok()?; + let mut d1 = Vec::with_capacity(n); + for _ in 0..n { + let v = nums(lines.next()?)?; + if v.len() != 3 { + return None; + } + d1.push([v[0], v[1], v[2]]); + } + (n >= 2).then_some((Lut { n1: n, d1, n3: 0, d3: vec![], dmin: [0.0; 3], dmax: [1.0; 3] }, shaper)) + } +} + +fn shape(curve: &[(f32, f32)], x: f32) -> f32 { + if curve.len() < 2 { + return x; + } + let i = curve.partition_point(|p| p.0 < x).clamp(1, curve.len() - 1); + let (a, b) = (curve[i - 1], curve[i]); + let t = ((x - a.0) / (b.0 - a.0).max(1e-12)).clamp(0.0, 1.0); + a.1 + (b.1 - a.1) * t +} + +fn shape_inv(curve: &[(f32, f32)], y: f32) -> f32 { + let flipped: Vec<(f32, f32)> = curve.iter().map(|&(a, b)| (b, a)).collect(); + shape(&flipped, y) +} + +/// Parse transform text by its format (detected from `ext` or the content). +pub fn parse_file(text: &str, ext: &str) -> Option { + let ext = ext.to_ascii_lowercase(); + if ext == "cc" || ext == "ccc" || ext == "cdl" || text.contains(">>>; + +/// Load (and cache) a transform from a file path or inline content. +pub fn load_file(src: &str) -> Option> { + let src_t = src.trim(); + if src_t.is_empty() { + return None; + } + static CACHE: OnceLock = OnceLock::new(); + let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); + if let Ok(m) = cache.lock() + && let Some(v) = m.get(src) + { + return v.clone(); + } + let inline = src.contains('\n'); + let parsed = if inline { + parse_file(src, "") + } else { + let ext = std::path::Path::new(src_t).extension().and_then(std::ffi::OsStr::to_str).unwrap_or(""); + std::fs::read_to_string(src_t).ok().and_then(|t| parse_file(&t, ext)) + } + .map(Arc::new); + if let Ok(mut m) = cache.lock() { + m.insert(src.to_string(), parsed.clone()); + } + parsed +} + +impl FileXform { + pub fn apply(&self, c: [f32; 3], interp: u32, inverse: bool, ccc_id: &str) -> [f32; 3] { + match self { + FileXform::Cdl(list) => { + let cdl = list.iter().find(|(id, _)| !ccc_id.is_empty() && id == ccc_id).or(list.first()).map(|(_, c)| *c).unwrap_or_default(); + let c64 = c.map(|v| v as f64); + let o = if inverse { cdl.invert(c64, true) } else { cdl.apply(c64, true) }; + o.map(|v| v as f32) + } + FileXform::Matrix { m, offset } => crate::ocio_config::matrix_apply(m, offset, c.map(|v| v as f64), inverse).map(|v| v as f32), + FileXform::Lut { lut, shaper } => { + let fwd = |c: [f32; 3]| { + let c = match shaper { + Some(s) => [shape(&s[0], c[0]), shape(&s[1], c[1]), shape(&s[2], c[2])], + None => c, + }; + lut.apply_interp(c, interp) + }; + if !inverse { + return fwd(c); + } + if lut.n3 == 0 { + // 1D: invert each channel exactly through the (monotone) tables. + let mut x = c; + if lut.n1 > 1 { + x = [0, 1, 2].map(|i| { + let curve: Vec<(f32, f32)> = + (0..lut.n1).map(|k| (lut.d1[k][i], lut.dmin[i] + (lut.dmax[i] - lut.dmin[i]) * k as f32 / (lut.n1 - 1) as f32)).collect(); + shape(&curve, c[i]) + }); + } + if let Some(s) = shaper { + x = [shape_inv(&s[0], x[0]), shape_inv(&s[1], x[1]), shape_inv(&s[2], x[2])]; + } + return x; + } + // 3D: damped fixed-point (Newton with an identity Jacobian) from the target. + let mut x = c; + for _ in 0..40 { + let y = fwd(x); + let e = [c[0] - y[0], c[1] - y[1], c[2] - y[2]]; + if e.iter().all(|v| v.abs() < 1e-6) { + break; + } + x = [x[0] + e[0], x[1] + e[1], x[2] + e[2]]; + } + x + } + } + } +} diff --git a/crates/effects/src/ocio/program.rs b/crates/effects/src/ocio/program.rs new file mode 100644 index 000000000..a223dc7b4 --- /dev/null +++ b/crates/effects/src/ocio/program.rs @@ -0,0 +1,252 @@ +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::{Arc, EffectCtx, Lut, invert, mul}; + +// ---- Colour programs (the GPU compositor's colour-management kernels) + +/// One per-pixel step of a colour transform, in the order the CPU effect applies them (see +/// [`color_program`]). +#[derive(Clone, Debug)] +pub enum ColorOp { + /// Transfer function, encoded → linear. + Decode(Tf), + /// Transfer function, linear → encoded. + Encode(Tf), + /// Linear-RGB matrix. + Matrix([[f32; 3]; 3]), + /// ASC CDL (forward or exact inverse; `clamp` = the v1.2 style). + Cdl { cdl: Cdl, clamp: bool, inverse: bool }, + /// The Tone Mapped view's roll-off (or its inverse). + Tonemap { inverse: bool }, + /// Color Profile Converter's perceptual gamut compression toward luminance `luma`. + Gamut([f64; 3]), + /// Negative channels to 0 (the saturation intent). + Max0, + /// A LUT (0 nearest, 1 trilinear, 2 tetrahedral) with an optional per-channel shaper, forward + /// or inverse (as `FileXform::apply`). + Lut { lut: Arc, shaper: Option; 3]>>, interp: u32, inverse: bool }, + /// `m × (c + pre) + post` (a custom config's MatrixTransform and RangeTransform). + Affine { m: [[f32; 3]; 3], pre: [f32; 3], post: [f32; 3] }, + /// `max(c, 0) ^ p` per channel (ExponentTransform). + Pow([f32; 3]), + /// LogTransform / LogAffineTransform, forward or inverse (as `ocio_config::Xf::apply`). + LogAffine { base: f32, log_slope: [f32; 3], log_offset: [f32; 3], lin_slope: [f32; 3], lin_offset: [f32; 3], inverse: bool }, + /// ExponentWithLinearTransform, forward (decode) or inverse (as + /// [`crate::ocio_config::moncurve`]). + MonCurve { gamma: [f32; 3], offset: [f32; 3], inverse: bool, mirror: bool }, + /// Clamp each channel to `lo..=hi` (a clamping RangeTransform; ±`f32::MAX` = unbounded). + Clamp { lo: [f32; 3], hi: [f32; 3] }, +} + +/// The ops of a matrix-and-offset transform (MatrixTransform, `.spimtx`), forward or inverse +/// (as [`crate::ocio_config::matrix_apply`]). +fn matrix_ops(m: &[f64; 16], offset: &[f64; 4], inverse: bool, ops: &mut Vec) { + if !inverse { + let m = [0, 1, 2].map(|r| [m[r * 4] as f32, m[r * 4 + 1] as f32, m[r * 4 + 2] as f32]); + ops.push(ColorOp::Affine { m, pre: [0.0; 3], post: [offset[0] as f32, offset[1] as f32, offset[2] as f32] }); + } else if let Some(inv) = crate::ocio_config::mat3_inverse(m) { + let m = inv.map(|r| r.map(|v| v as f32)); + ops.push(ColorOp::Affine { m, pre: [-offset[0] as f32, -offset[1] as f32, -offset[2] as f32], post: [0.0; 3] }); + } +} + +/// The ops of a LUT / CDL / matrix file transform (as [`FileXform::apply`]). +fn file_ops(x: &FileXform, interp: u32, inverse: bool, ccc_id: &str, ops: &mut Vec) { + match x { + FileXform::Cdl(list) => { + let cdl = list.iter().find(|(i, _)| !ccc_id.is_empty() && i == ccc_id).or(list.first()).map(|(_, c)| *c).unwrap_or_default(); + ops.push(ColorOp::Cdl { cdl, clamp: true, inverse }); + } + FileXform::Lut { lut, shaper } => { + ops.push(ColorOp::Lut { lut: Arc::new(lut.clone()), shaper: shaper.clone().map(Arc::new), interp, inverse }); + } + FileXform::Matrix { m, offset } => matrix_ops(m, offset, inverse, ops), + } +} + +/// The ops of a custom config's transform `x` (as `Xf::apply`). +fn config_xf_ops(x: &crate::ocio_config::Xf, inverse: bool, ops: &mut Vec) { + use crate::ocio_config::Xf; + let f3 = |v: &[f64; 3]| v.map(|x| x as f32); + match x { + Xf::Inverse(x) => config_xf_ops(x, !inverse, ops), + Xf::Matrix { m, offset } => matrix_ops(m, offset, inverse, ops), + Xf::File { xf: Some(x), interp, ccc, .. } => file_ops(x, *interp, inverse, ccc, ops), + Xf::ExponentLinear { gamma, offset, mirror } => ops.push(ColorOp::MonCurve { gamma: f3(gamma), offset: f3(offset), inverse, mirror: *mirror }), + // A BuiltinTransform's curve: forward decodes. + Xf::Curve(tf) => ops.push(if inverse { ColorOp::Encode(*tf) } else { ColorOp::Decode(*tf) }), + Xf::Named(_) => {} + Xf::File { xf: None, .. } | Xf::Unsupported(_) => {} + Xf::Exponent(e) => ops.push(ColorOp::Pow([0, 1, 2].map(|k| (if inverse { 1.0 / e[k].max(1e-9) } else { e[k] }) as f32))), + Xf::Log { base } => { + ops.push(ColorOp::LogAffine { base: *base as f32, log_slope: [1.0; 3], log_offset: [0.0; 3], lin_slope: [1.0; 3], lin_offset: [0.0; 3], inverse }) + } + Xf::LogAffine { base, log_slope, log_offset, lin_slope, lin_offset } => ops.push(ColorOp::LogAffine { + base: *base as f32, + log_slope: f3(log_slope), + log_offset: f3(log_offset), + lin_slope: f3(lin_slope), + lin_offset: f3(lin_offset), + inverse, + }), + Xf::Cdl(cdl) => ops.push(ColorOp::Cdl { cdl: *cdl, clamp: true, inverse }), + Xf::Range(r) => { + let (k, off, lo, hi) = r.parts(inverse); + let k = k as f32; + ops.push(ColorOp::Affine { m: [[k, 0.0, 0.0], [0.0, k, 0.0], [0.0, 0.0, k]], pre: [0.0; 3], post: [off as f32; 3] }); + if lo.is_finite() || hi.is_finite() { + let bound = |v: f64| (v as f32).clamp(-f32::MAX, f32::MAX); + ops.push(ColorOp::Clamp { lo: [bound(lo); 3], hi: [bound(hi); 3] }); + } + } + Xf::Group(list) => { + if inverse { + list.iter().rev().for_each(|x| config_xf_ops(x, true, ops)); + } else { + list.iter().for_each(|x| config_xf_ops(x, false, ops)); + } + } + } +} + +/// How a colour program meets premultiplied pixels. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Straight { + /// `unpremul` (colour 0 below alpha 1e-6); fully transparent pixels untouched (OCIO effects). + Unpremul, + /// Colour ÷ alpha; fully transparent pixels untouched (`Image::map_straight`, Apply Color LUT). + Divide, +} + +fn xform_ops(x: &Xform, ops: &mut Vec) { + if x.dec != Tf::Linear { + ops.push(ColorOp::Decode(x.dec)); + } + if !x.ident { + ops.push(ColorOp::Matrix(x.m)); + } + if x.enc != Tf::Linear { + ops.push(ColorOp::Encode(x.enc)); + } +} + +/// Effect `id`'s per-pixel transform as a list of [`ColorOp`]s that reproduces the CPU effect +/// (empty = the layer passes through; custom OCIO configs compile their transforms too). `None` +/// for another effect. +pub fn color_program(id: &str, ctx: &EffectCtx) -> Option<(Vec, Straight)> { + let pr = ctx.params; + let mut ops = vec![]; + // Configuration ▸ Custom compiles the config's transforms (a config that can't be read, or + // names unknown spaces, passes the pixels through). + match custom_xf(id, pr) { + Ok(None) => {} + Ok(Some(xf)) => { + config_xf_ops(&xf, false, &mut ops); + return Some((ops, Straight::Unpremul)); + } + Err(_) => return Some((ops, Straight::Unpremul)), + } + match id { + "ec.utility.applylut" => { + if let Some(lut) = crate::utility::load_lut(pr.s("lut")) { + ops.push(ColorOp::Lut { lut, shaper: None, interp: 2, inverse: false }); + } + return Some((ops, Straight::Divide)); + } + "ec.color.ociocolorspace" => { + let (a, z) = (space_at(pr.e("source")), space_at(pr.e("destination"))); + let (a, z) = if pr.e("direction") == 1 { (z, a) } else { (a, z) }; + if a.name != z.name { + xform_ops(&Xform::new(a, z), &mut ops); + } + } + "ec.color.ociocdl" => { + let cdl = cdl_from_params(pr); + if cdl != Cdl::default() { + ops.push(ColorOp::Cdl { cdl, clamp: pr.e("style") == 0, inverse: pr.e("direction") == 1 }); + } + } + "ec.color.ociodisplay" => { + let input = space_at(pr.e("source")); + let (display, view, inverse) = (pr.e("display") as usize, pr.e("view") as usize, pr.e("direction") == 1); + let (_, prims, tf) = DISPLAYS[display.min(DISPLAYS.len() - 1)]; + if view != 2 && !input.raw { + let disp = Space { name: "display", prims: Some((prims, D65)), tf: Tf::Linear, raw: false }; + let lin = Space { tf: Tf::Linear, ..*input }; + if !inverse { + ops.push(ColorOp::Decode(input.tf)); + xform_ops(&Xform::new(&lin, &disp), &mut ops); + if view == 1 { + ops.push(ColorOp::Tonemap { inverse: false }); + } + ops.push(ColorOp::Encode(tf)); + } else { + ops.push(ColorOp::Decode(tf)); + if view == 1 { + ops.push(ColorOp::Tonemap { inverse: true }); + } + xform_ops(&Xform::new(&disp, &lin), &mut ops); + ops.push(ColorOp::Encode(input.tf)); + } + } + } + "ec.color.ociofile" => { + if let Some(x) = load_file(pr.s("file")) { + let interp = match pr.e("interpolation") { + 0 => 0, + 1 => 1, + _ => 2, + }; + file_ops(&x, interp, pr.e("direction") == 1, pr.s("cccId"), &mut ops); + } + } + "ec.color.ociolook" => { + let mut list = parse_looks(pr.s("looks")); + let pick = pr.e("look"); + if pick > 0 + && let Some((_, c)) = looks().get(pick as usize - 1) + { + list.insert(0, (*c, false)); + } + if !list.is_empty() { + let (src, dst) = (space_at(pr.e("source")), space_at(pr.e("destination"))); + let inv = pr.e("direction") == 1; + let cct = &ACES_CCT; + let (a, z) = if inv { (dst, src) } else { (src, dst) }; + if inv { + list.reverse(); + } + xform_ops(&Xform::new(a, cct), &mut ops); + for (cdl, li) in &list { + ops.push(ColorOp::Cdl { cdl: *cdl, clamp: false, inverse: *li != inv }); + } + xform_ops(&Xform::new(cct, z), &mut ops); + } + } + "ec.utility.colorprofileconverter" => { + let (inp, in_lin_default) = profile(ctx, pr.e("inputProfile")); + let (out, out_lin_default) = profile(ctx, pr.e("outputProfile")); + let in_lin = pr.b("linearizeInputProfile") || in_lin_default && pr.e("inputProfile") == 0; + let out_lin = pr.b("linearizeOutputProfile") || out_lin_default && pr.e("outputProfile") == 0; + let intent = pr.e("intent"); + let dec = if in_lin { Tf::Linear } else { inp.tf }; + let enc = if out_lin { Tf::Linear } else { out.tf }; + let m = if intent == 3 { mul(&invert(&out.to_ref(false)), &inp.to_ref(false)) } else { matrix(&inp, &out) }; + let identity = dec == enc && (0..3).all(|i| (0..3).all(|j| (m[i][j] - IDENTITY[i][j]).abs() < 1e-9)); + if !identity { + ops.push(ColorOp::Decode(dec)); + ops.push(ColorOp::Matrix(m.map(|r| r.map(|v| v as f32)))); + match intent { + 0 => ops.push(ColorOp::Gamut(out.to_ref(true)[1])), + 2 => ops.push(ColorOp::Max0), + _ => {} + } + ops.push(ColorOp::Encode(enc)); + } + } + _ => return None, + } + Some((ops, Straight::Unpremul)) +} diff --git a/crates/effects/src/ocio/registry.rs b/crates/effects/src/ocio/registry.rs new file mode 100644 index 000000000..cd3bd02a7 --- /dev/null +++ b/crates/effects/src/ocio/registry.rs @@ -0,0 +1,129 @@ +use super::cdl::sl; +use super::effects::{ + CONFIGS, DISPLAYS, PROFILES, VIEWS, color_stabilizer, direction, looks, ocio_cdl, ocio_cst, ocio_display, ocio_file, ocio_look, profile_converter, + space_param, spec, +}; +use super::space::SPACES; +use super::{EffectSpec, ParamUi, Value, num, p, popup, slider}; + +pub fn specs() -> Vec { + let cc = "Color Correction"; + let srgb = SPACES.iter().position(|s| s.name == "sRGB").unwrap_or(0) as u32; + let lin709 = SPACES.iter().position(|s| s.name == "Linear Rec.709 (sRGB)").unwrap_or(0) as u32; + let acescg = SPACES.iter().position(|s| s.name == "ACEScg").unwrap_or(0) as u32; + let mut look_opts = vec!["None"]; + look_opts.extend(looks().iter().map(|(n, _)| *n)); + vec![ + spec( + "ec.color.ociocdl", + "OCIO CDL Transform", + cc, + vec![ + p("slopeRed", "Slope Red", num(1.0), sl(0.0, 100.0, 0.0, 4.0)), + p("slopeGreen", "Slope Green", num(1.0), sl(0.0, 100.0, 0.0, 4.0)), + p("slopeBlue", "Slope Blue", num(1.0), sl(0.0, 100.0, 0.0, 4.0)), + p("offsetRed", "Offset Red", num(0.0), sl(-100.0, 100.0, -1.0, 1.0)), + p("offsetGreen", "Offset Green", num(0.0), sl(-100.0, 100.0, -1.0, 1.0)), + p("offsetBlue", "Offset Blue", num(0.0), sl(-100.0, 100.0, -1.0, 1.0)), + p("powerRed", "Power Red", num(1.0), sl(0.0, 100.0, 0.0, 4.0)), + p("powerGreen", "Power Green", num(1.0), sl(0.0, 100.0, 0.0, 4.0)), + p("powerBlue", "Power Blue", num(1.0), sl(0.0, 100.0, 0.0, 4.0)), + p("saturation", "Saturation", num(1.0), sl(0.0, 100.0, 0.0, 4.0)), + p("style", "Style", Value::Enum(0), popup(&["ASC CDL v1.2", "No Clamp"])), + direction(), + ], + ocio_cdl, + ), + spec( + "ec.color.ociocolorspace", + "OCIO Color Space Transform", + cc, + vec![ + p("config", "Configuration", Value::Enum(0), popup(CONFIGS)), + p("configFile", "Config File (.ocio)", Value::Str(String::new()), crate::file_ui(&["ocio"])), + space_param("source", "Source", lin709), + p("sourceName", "Source (config name)", Value::Str(String::new()), ParamUi::Text), + space_param("destination", "Destination", srgb), + p("destinationName", "Destination (config name)", Value::Str(String::new()), ParamUi::Text), + direction(), + ], + ocio_cst, + ), + spec( + "ec.color.ociodisplay", + "OCIO Display Transform", + cc, + vec![ + p("config", "Configuration", Value::Enum(0), popup(CONFIGS)), + p("configFile", "Config File (.ocio)", Value::Str(String::new()), crate::file_ui(&["ocio"])), + space_param("source", "Input Color Space", lin709), + p("sourceName", "Source (config name)", Value::Str(String::new()), ParamUi::Text), + p("display", "Display Device", Value::Enum(0), popup(&DISPLAYS.iter().map(|d| d.0).collect::>())), + p("displayName", "Display (config name)", Value::Str(String::new()), ParamUi::Text), + p("view", "View Transform", Value::Enum(0), popup(VIEWS)), + p("viewName", "View (config name)", Value::Str(String::new()), ParamUi::Text), + direction(), + ], + ocio_display, + ), + // `file`: a path to a .cube/.3dl/.csp/.cc/.ccc/.cdl file, or the file's text itself. + spec( + "ec.color.ociofile", + "OCIO File Transform", + cc, + vec![ + p("file", "File", Value::Str(String::new()), crate::file_ui(&["cube", "3dl", "csp", "cc", "ccc", "cdl", "spi1d", "spi3d", "spimtx"])), + p("cccId", "CCC ID", Value::Str(String::new()), ParamUi::Text), + p("interpolation", "Interpolation", Value::Enum(3), popup(&["Nearest", "Linear", "Tetrahedral", "Best"])), + direction(), + ], + ocio_file, + ), + spec( + "ec.color.ociolook", + "OCIO Look Transform", + cc, + vec![ + p("config", "Configuration", Value::Enum(0), popup(&["Built-in (EffectCraft minimal)"])), + space_param("source", "Source", acescg), + space_param("destination", "Destination", acescg), + p("look", "Look", Value::Enum(0), popup(&look_opts)), + p("looks", "Looks", Value::Str(String::new()), ParamUi::Text), + direction(), + ], + ocio_look, + ), + spec( + "ec.color.colorstabilizer", + "Color Stabilizer", + cc, + vec![ + // The pivot frame (After Effects' Set Frame button stores it). + p("referenceFrame", "Reference Frame (s)", num(0.0), slider(-100000.0, 100000.0, 0.0, 60.0, 2)), + p("stabilize", "Stabilize", Value::Enum(0), popup(&["Brightness", "Levels", "Curves"])), + p("blackPoint", "Black Point", Value::Vec2([0.25, 0.75]), ParamUi::Point), + p("midPoint", "Mid Point", Value::Vec2([0.5, 0.5]), ParamUi::Point), + p("whitePoint", "White Point", Value::Vec2([0.75, 0.25]), ParamUi::Point), + p("sampleSize", "Sample Size", num(5.0), slider(1.0, 100.0, 1.0, 50.0, 0)), + ], + color_stabilizer, + ), + spec( + "ec.utility.colorprofileconverter", + "Color Profile Converter", + "Utility", + vec![ + p("inputProfile", "Input Profile", Value::Enum(0), popup(PROFILES)), + p("linearizeInputProfile", "Linearize Input Profile", Value::Bool(false), ParamUi::Checkbox), + p("outputProfile", "Output Profile", Value::Enum(1), popup(PROFILES)), + p("linearizeOutputProfile", "Linearize Output Profile", Value::Bool(false), ParamUi::Checkbox), + p("intent", "Intent", Value::Enum(1), popup(&["Perceptual", "Relative Colorimetric", "Saturation", "Absolute Colorimetric"])), + // Our built-in profiles share a zero black point and none is scene-referred, so + // these two are kept for compatibility and do not change the result. + p("useBlackPointCompensation", "Use Black Point Compensation", Value::Bool(true), ParamUi::Checkbox), + p("sceneRefCompensation", "Scene-ref. Profile Compensation", Value::Enum(2), popup(&["On", "Off", "Use Project Setting"])), + ], + profile_converter, + ), + ] +} diff --git a/crates/effects/src/ocio/space.rs b/crates/effects/src/ocio/space.rs new file mode 100644 index 000000000..1a1d8f69b --- /dev/null +++ b/crates/effects/src/ocio/space.rs @@ -0,0 +1,214 @@ +use super::{invert, mul, mul_vec, rgb_to_xyz}; + +pub(super) type M3 = [[f64; 3]; 3]; + +pub(crate) const D65: [f64; 2] = [0.3127, 0.3290]; + +const ACES_WHITE: [f64; 2] = [0.32168, 0.33767]; + +const AP0: [[f64; 2]; 3] = [[0.7347, 0.2653], [0.0, 1.0], [0.0001, -0.0770]]; + +const AP1: [[f64; 2]; 3] = [[0.713, 0.293], [0.165, 0.830], [0.128, 0.044]]; + +pub(crate) const REC709: [[f64; 2]; 3] = [[0.640, 0.330], [0.300, 0.600], [0.150, 0.060]]; + +pub(crate) const REC2020: [[f64; 2]; 3] = [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]]; + +pub(crate) const P3: [[f64; 2]; 3] = [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]]; + +pub(super) const IDENTITY: M3 = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + +/// Transfer functions (encoded ↔ linear). +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Tf { + Linear, + Srgb, + Gamma(f64), + AcesCct, + /// ACEScc (Academy S-2014-003), the pure-log grading encoding. + AcesCc, + /// SMPTE ST 2084 with 1.0 = 100 cd/m². + Pq, +} + +/// ACEScc: the lowest code value (linear 0 and below) and the end of its toe (linear 2⁻¹⁵). +const ACESCC_MIN: f64 = (-16.0 + 9.72) / 17.52; + +const ACESCC_TOE: f64 = (-15.0 + 9.72) / 17.52; + +impl Tf { + pub fn decode(self, v: f64) -> f64 { + match self { + Tf::Linear => v, + Tf::Srgb => { + let a = v.abs(); + let l = if a <= 0.04045 { a / 12.92 } else { ((a + 0.055) / 1.055).powf(2.4) }; + l.copysign(v) + } + Tf::Gamma(g) => v.abs().powf(g).copysign(v), + Tf::AcesCct => { + if v <= 0.155_251_141_552_511 { + (v - 0.072_905_534_195_835_5) / 10.540_237_741_654_5 + } else if v < (65504f64.log2() + 9.72) / 17.52 { + 2f64.powf(v * 17.52 - 9.72) + } else { + 65504.0 + } + } + Tf::AcesCc => { + if v < ACESCC_TOE { + (2f64.powf(v * 17.52 - 9.72) - 2f64.powi(-16)) * 2.0 + } else if v < (65504f64.log2() + 9.72) / 17.52 { + 2f64.powf(v * 17.52 - 9.72) + } else { + 65504.0 + } + } + Tf::Pq => { + let (m1, m2, c1, c2, c3) = pq_consts(); + let e = v.max(0.0).powf(1.0 / m2); + let l = ((e - c1).max(0.0) / (c2 - c3 * e)).powf(1.0 / m1); + l * 100.0 + } + } + } + pub fn encode(self, v: f64) -> f64 { + match self { + Tf::Linear => v, + Tf::Srgb => { + let a = v.abs(); + let e = if a <= 0.003_130_8 { a * 12.92 } else { 1.055 * a.powf(1.0 / 2.4) - 0.055 }; + e.copysign(v) + } + Tf::Gamma(g) => v.abs().powf(1.0 / g).copysign(v), + Tf::AcesCct => { + if v <= 0.007_812_5 { + 10.540_237_741_654_5 * v + 0.072_905_534_195_835_5 + } else { + (v.log2() + 9.72) / 17.52 + } + } + Tf::AcesCc => { + if v <= 0.0 { + ACESCC_MIN + } else if v < 2f64.powi(-15) { + ((2f64.powi(-16) + v * 0.5).log2() + 9.72) / 17.52 + } else { + (v.log2() + 9.72) / 17.52 + } + } + Tf::Pq => { + let (m1, m2, c1, c2, c3) = pq_consts(); + let y = (v / 100.0).max(0.0).powf(m1); + ((c1 + c2 * y) / (1.0 + c3 * y)).powf(m2) + } + } + } +} + +fn pq_consts() -> (f64, f64, f64, f64, f64) { + (2610.0 / 16384.0, 2523.0 / 4096.0 * 128.0, 3424.0 / 4096.0, 2413.0 / 4096.0 * 32.0, 2392.0 / 4096.0 * 32.0) +} + +/// A colour space of the built-in config. +#[derive(Clone, Copy, Debug)] +pub struct Space { + pub name: &'static str, + /// Primaries and white (None = CIE XYZ itself). + pub prims: Option<([[f64; 2]; 3], [f64; 2])>, + pub tf: Tf, + /// Passes data through untouched. + pub raw: bool, +} + +pub(crate) const fn sp(name: &'static str, prims: [[f64; 2]; 3], white: [f64; 2], tf: Tf) -> Space { + Space { name, prims: Some((prims, white)), tf, raw: false } +} + +/// The built-in configuration's colour spaces, in popup order. +pub(crate) const ACES2065_1: Space = sp("ACES2065-1", AP0, ACES_WHITE, Tf::Linear); + +pub(crate) const ACES_CG: Space = sp("ACEScg", AP1, ACES_WHITE, Tf::Linear); + +pub(super) const ACES_CCT: Space = sp("ACEScct", AP1, ACES_WHITE, Tf::AcesCct); + +pub const SPACES: &[Space] = &[ + ACES2065_1, + ACES_CG, + ACES_CCT, + sp("Linear Rec.709 (sRGB)", REC709, D65, Tf::Linear), + sp("sRGB", REC709, D65, Tf::Srgb), + sp("Gamma 2.4 Rec.709", REC709, D65, Tf::Gamma(2.4)), + sp("Linear Rec.2020", REC2020, D65, Tf::Linear), + sp("Gamma 2.4 Rec.2020", REC2020, D65, Tf::Gamma(2.4)), + sp("Linear P3-D65", P3, D65, Tf::Linear), + sp("Display P3", P3, D65, Tf::Srgb), + Space { name: "CIE-XYZ-D65", prims: None, tf: Tf::Linear, raw: false }, + Space { name: "Raw", prims: None, tf: Tf::Linear, raw: true }, +]; + +pub fn space_names() -> Vec<&'static str> { + SPACES.iter().map(|s| s.name).collect() +} + +#[cfg(test)] +pub fn space_by_name(n: &str) -> Option<&'static Space> { + SPACES.iter().find(|s| s.name.eq_ignore_ascii_case(n)) +} + +/// Bradford chromatic adaptation from white `src` to white `dst` (xy). +pub fn bradford(src: [f64; 2], dst: [f64; 2]) -> M3 { + if src == dst { + return IDENTITY; + } + const MB: M3 = [[0.8951, 0.2664, -0.1614], [-0.7502, 1.7135, 0.0367], [0.0389, -0.0685, 1.0296]]; + let xyz = |c: [f64; 2]| [c[0] / c[1], 1.0, (1.0 - c[0] - c[1]) / c[1]]; + let s = mul_vec(&MB, xyz(src)); + let d = mul_vec(&MB, xyz(dst)); + let diag = [[d[0] / s[0], 0.0, 0.0], [0.0, d[1] / s[1], 0.0], [0.0, 0.0, d[2] / s[2]]]; + mul(&invert(&MB), &mul(&diag, &MB)) +} + +impl Space { + /// Linear RGB → XYZ D65 (Bradford-adapted when `adapt`, else the space's own white). + pub fn to_ref(self, adapt: bool) -> M3 { + match self.prims { + None => IDENTITY, + Some((p, w)) => { + let m = rgb_to_xyz(p, w); + if adapt { mul(&bradford(w, D65), &m) } else { m } + } + } + } +} + +/// Linear-RGB matrix from space `a` to space `b`. +pub fn matrix(a: &Space, b: &Space) -> M3 { + mul(&invert(&b.to_ref(true)), &a.to_ref(true)) +} + +/// A compiled colour-space conversion. +#[derive(Clone, Copy, Debug)] +pub struct Xform { + pub(super) dec: Tf, + pub(super) m: [[f32; 3]; 3], + pub(super) ident: bool, + pub(super) enc: Tf, +} + +impl Xform { + pub fn new(a: &Space, b: &Space) -> Xform { + if a.raw || b.raw { + return Xform { dec: Tf::Linear, m: IDENTITY.map(|r| r.map(|v| v as f32)), ident: true, enc: Tf::Linear }; + } + let m = matrix(a, b); + let ident = (0..3).all(|i| (0..3).all(|j| (m[i][j] - IDENTITY[i][j]).abs() < 1e-12)); + Xform { dec: a.tf, m: m.map(|r| r.map(|v| v as f32)), ident, enc: b.tf } + } + #[inline] + pub fn apply(&self, c: [f32; 3]) -> [f32; 3] { + let l = c.map(|v| self.dec.decode(v as f64) as f32); + let l = if self.ident { l } else { [0, 1, 2].map(|i| self.m[i][0] * l[0] + self.m[i][1] * l[1] + self.m[i][2] * l[2]) }; + l.map(|v| self.enc.encode(v as f64) as f32) + } +} diff --git a/crates/effects/src/ocio/tests.rs b/crates/effects/src/ocio/tests.rs new file mode 100644 index 000000000..edbc6eff2 --- /dev/null +++ b/crates/effects/src/ocio/tests.rs @@ -0,0 +1,313 @@ +use super::*; +use crate::{EffectEnv, EffectHost, Image, LayerPixels, run_fx}; + +fn close(a: M3, b: M3, tol: f64) { + for i in 0..3 { + for j in 0..3 { + assert!((a[i][j] - b[i][j]).abs() < tol, "{a:?} vs {b:?}"); + } + } +} + +#[test] +fn aces_matrices_match_published_values() { + let ap0 = space_by_name("ACES2065-1").unwrap(); + let ap1 = space_by_name("ACEScg").unwrap(); + // Academy S-2014-004 AP0 → AP1. + close( + matrix(ap0, ap1), + [ + [1.451_439_316_1, -0.236_510_746_9, -0.214_928_569_3], + [-0.076_553_773_4, 1.176_229_699_8, -0.099_675_926_4], + [0.008_316_148_4, -0.006_032_449_8, 0.997_716_301_4], + ], + 2e-4, + ); + // ACEScg → linear Rec.709 with Bradford adaptation (published ACES config value). + let lin = space_by_name("Linear Rec.709 (sRGB)").unwrap(); + close(matrix(ap1, lin), [[1.705_05, -0.621_79, -0.083_26], [-0.130_26, 1.140_80, -0.010_55], [-0.024_00, -0.128_97, 1.152_97]], 2e-3); + // White maps to white. + let w = mul_vec(&matrix(ap1, lin), [1.0, 1.0, 1.0]); + assert!(w.iter().all(|v| (v - 1.0).abs() < 1e-3), "{w:?}"); +} + +#[test] +fn transfer_functions_round_trip() { + for tf in [Tf::Srgb, Tf::Gamma(2.4), Tf::AcesCct, Tf::Pq] { + for v in [0.0, 0.001, 0.18, 0.5, 1.0, 4.0] { + let r = tf.decode(tf.encode(v)); + assert!((r - v).abs() < 1e-6 * v.max(1.0), "{tf:?} {v} {r}"); + } + } + // ACEScct of 18 % grey (S-2016-001). + assert!((Tf::AcesCct.encode(0.18) - 0.413_588).abs() < 1e-5); + // PQ: 100 nits (1.0) encodes to ≈ 0.508. + assert!((Tf::Pq.encode(1.0) - 0.508).abs() < 1e-3); +} + +#[test] +fn color_space_transform_effect_round_trips() { + let img = Image::filled(4, 4, [0.2, 0.5, 0.7, 1.0]); + let srgb = SPACES.iter().position(|s| s.name == "sRGB").unwrap() as u32; + let cg = SPACES.iter().position(|s| s.name == "ACEScg").unwrap() as u32; + let a = run_fx("ec.color.ociocolorspace", &[("source", Value::Enum(srgb)), ("destination", Value::Enum(cg))], img.clone(), 0.0, EffectEnv::default()); + assert_ne!(a.img.data, img.data); + let b = run_fx("ec.color.ociocolorspace", &[("source", Value::Enum(cg)), ("destination", Value::Enum(srgb))], a.img.clone(), 0.0, EffectEnv::default()); + let c = run_fx( + "ec.color.ociocolorspace", + &[("source", Value::Enum(srgb)), ("destination", Value::Enum(cg)), ("direction", Value::Enum(1))], + a.img, + 0.0, + EffectEnv::default(), + ); + for (x, y) in b.img.data.iter().zip(&img.data).chain(c.img.data.iter().zip(&img.data)) { + assert!((0..4).all(|k| (x[k] - y[k]).abs() < 1e-4), "{x:?} {y:?}"); + } +} + +#[test] +fn cdl_math() { + let cdl = Cdl { slope: [1.2, 1.0, 0.8], offset: [0.05, 0.0, -0.02], power: [1.1, 1.0, 0.9], sat: 0.8 }; + let c = [0.3, 0.5, 0.7]; + // Hand computation of the v1.2 formula. + let sop = [(0.3f64 * 1.2 + 0.05).powf(1.1), 0.5, (0.7f64 * 0.8 - 0.02).powf(0.9)]; + let l = sop[0] * 0.2126 + sop[1] * 0.7152 + sop[2] * 0.0722; + let want = sop.map(|v| l + 0.8 * (v - l)); + let got = cdl.apply(c, true); + for k in 0..3 { + assert!((got[k] - want[k]).abs() < 1e-12); + } + let back = cdl.invert(got, true); + for k in 0..3 { + assert!((back[k] - c[k]).abs() < 1e-9); + } + // v1.2 clamps, No Clamp keeps negatives. + let neg = Cdl { offset: [-0.5; 3], ..Cdl::default() }; + assert_eq!(neg.apply([0.2; 3], true), [0.0; 3]); + assert!((neg.apply([0.2; 3], false)[0] + 0.3).abs() < 1e-12); + let img = Image::filled(2, 2, [0.3, 0.5, 0.7, 1.0]); + let out = run_fx("ec.color.ociocdl", &[("slopeRed", num(1.2)), ("offsetRed", num(0.05)), ("powerRed", num(1.1))], img, 0.0, EffectEnv::default()); + let r = (0.3f64 * 1.2 + 0.05).powf(1.1); + assert!((out.img.get(0, 0)[0] as f64 - r).abs() < 1e-5); +} + +fn identity_lattice(n: usize, f: impl Fn([f32; 3]) -> [f32; 3]) -> Vec<[f32; 3]> { + let mut v = vec![]; + for b in 0..n { + for g in 0..n { + for r in 0..n { + let s = (n - 1) as f32; + v.push(f([r as f32 / s, g as f32 / s, b as f32 / s])); + } + } + } + v +} + +/// Sony Imageworks `.spi1d`, `.spi3d` and `.spimtx` (Blender's configs use them, #410), and +/// malformed ones rejected without panicking. +#[test] +fn spi_files_parse() { + let spi1d = "Version 1\nFrom 0.0 2.0\nLength 3\nComponents 1\n{\n0.0\n0.25\n1.0\n}\n"; + let x = parse_file(spi1d, "spi1d").unwrap(); + let o = x.apply([1.0, 0.5, 2.0], 1, false, ""); + assert!((o[0] - 0.25).abs() < 1e-6 && (o[1] - 0.125).abs() < 1e-6 && (o[2] - 1.0).abs() < 1e-6, "{o:?}"); + let back = x.apply(o, 1, true, ""); + assert!((back[0] - 1.0).abs() < 1e-5 && (back[2] - 2.0).abs() < 1e-5, "{back:?}"); + let three = parse_spi1d("Version 1\nFrom 0 1\nLength 2\nComponents 3\n{\n0 0 0\n1 0.5 2\n}\n").unwrap(); + assert_eq!(three.d1, vec![[0.0; 3], [1.0, 0.5, 2.0]]); + let mut spi3d = String::from("SPILUT 1.0\n3 3\n2 2 2\n"); + for (r, g, b) in (0..8).map(|i| (i & 1, (i >> 1) & 1, i >> 2)) { + spi3d += &format!("{r} {g} {b} {} {} {}\n", r as f32 * 0.5, g, b); + } + let x = parse_file(&spi3d, "spi3d").unwrap(); + let o = x.apply([1.0, 0.5, 0.25], 1, false, ""); + assert!((o[0] - 0.5).abs() < 1e-6 && (o[1] - 0.5).abs() < 1e-6 && (o[2] - 0.25).abs() < 1e-6, "{o:?}"); + let m = parse_file("2 0 0 6553.5\n0 1 0 0\n0 0 1 0\n", "spimtx").unwrap(); + let o = m.apply([0.25, 0.5, 1.0], 1, false, ""); + assert!((o[0] - 0.6).abs() < 1e-6 && o[1] == 0.5 && o[2] == 1.0, "{o:?}"); + assert!((m.apply(o, 1, true, "")[0] - 0.25).abs() < 1e-6); + for bad in [ + "Version 1\nFrom 0 1\nLength 99999999999\nComponents 1\n{\n0\n}\n", + "Version 1\nFrom 1 0\nLength 2\nComponents 1\n{\n0\n1\n}\n", + "Version 1\nFrom 0 1\nLength 3\nComponents 1\n{\n0\n1\n}\n", + "Version 1\nFrom 0 1\nLength 2\nComponents 2\n{\n0 0\n1 1\n}\n", + ] { + assert!(parse_spi1d(bad).is_none(), "{bad}"); + } + assert!(parse_spi3d("SPILUT 1.0\n3 3\n4000 4000 4000\n").is_none()); + assert!(parse_spi3d("SPILUT 1.0\n3 3\n2 2 2\n9 0 0 1 1 1\n").is_none()); + assert!(parse_spi3d("SPILUT 1.0\n3 3\n2 2 2\n0 0 0 1 1 1\n").is_none(), "incomplete lattice"); + assert!(parse_spimtx("1 0 0\n0 1 0\n").is_none()); + assert!(parse_spimtx("NaN 0 0 0 0 1 0 0 0 0 1 0").is_none()); +} + +#[test] +fn lut_files_parse_and_interpolate() { + // .cube with a non-linear lattice: trilinear between lattice points. + let f = |c: [f32; 3]| [c[0] * c[0], c[1], 1.0 - c[2]]; + let mut cube = String::from("LUT_3D_SIZE 3\n"); + for r in identity_lattice(3, f) { + cube += &format!("{} {} {}\n", r[0], r[1], r[2]); + } + let Some(FileXform::Lut { lut, .. }) = parse_file(&cube, "cube") else { panic!() }; + // Trilinear between 0.5 (0.25) and 1.0 (1.0) at 0.75 → 0.625. + let o = lut.apply_interp([0.75, 0.3, 0.2], 1); + assert!((o[0] - 0.625).abs() < 1e-6 && (o[1] - 0.3).abs() < 1e-6 && (o[2] - 0.8).abs() < 1e-6, "{o:?}"); + // Nearest snaps. + assert_eq!(lut.apply_interp([0.74, 0.0, 0.0], 0)[0], 0.25); + // .3dl (blue fastest, 10-bit integers, mesh line). + let mut tdl = String::from("0 511 1023\n"); + for r in 0..3 { + for g in 0..3 { + for b in 0..3 { + let v = [r, g, b].map(|i| i as f64 * 511.5); + tdl += &format!("{} {} {}\n", v[0].round(), v[1].round(), (1023.0 - v[2]).round()); + } + } + } + let Some(FileXform::Lut { lut, .. }) = parse_file(&tdl, "3dl") else { panic!() }; + let o = lut.apply_interp([0.25, 0.5, 0.25], 1); + assert!((o[0] - 0.25).abs() < 2e-3 && (o[1] - 0.5).abs() < 2e-3 && (o[2] - 0.75).abs() < 2e-3, "{o:?}"); + // .csp with a squaring pre-LUT on red and an identity 2³ lattice. + let mut csp = String::from("CSPLUTV100\n3D\n\n3\n0 0.5 1\n0 0.25 1\n2\n0 1\n0 1\n2\n0 1\n0 1\n\n2 2 2\n"); + for r in identity_lattice(2, |c| c) { + csp += &format!("{} {} {}\n", r[0], r[1], r[2]); + } + let x = parse_file(&csp, "csp").unwrap(); + let o = x.apply([0.5, 0.4, 0.6], 2, false, ""); + assert!((o[0] - 0.25).abs() < 1e-6 && (o[1] - 0.4).abs() < 1e-6, "{o:?}"); + let inv = x.apply(o, 2, true, ""); + assert!((inv[0] - 0.5).abs() < 1e-4, "{inv:?}"); + // CDL collection: pick by id. + let ccc = r#"2 2 20 0 01 1 1 + 0.5 0.5 0.51"#; + let x = parse_file(ccc, "ccc").unwrap(); + assert!((x.apply([0.4; 3], 2, false, "b")[0] - 0.2).abs() < 1e-6); + assert!((x.apply([0.4; 3], 2, false, "a")[0] - 0.8).abs() < 1e-6); + assert!((x.apply([0.4; 3], 2, false, "")[0] - 0.8).abs() < 1e-6); + // Effect with inline content; inverse of a 3D lattice. + let img = Image::filled(2, 2, [0.6, 0.3, 0.2, 1.0]); + let out = run_fx("ec.color.ociofile", &[("file", Value::Str(cube.clone()))], img.clone(), 0.0, EffectEnv::default()); + let back = run_fx("ec.color.ociofile", &[("file", Value::Str(cube)), ("direction", Value::Enum(1))], out.img, 0.0, EffectEnv::default()); + assert!((back.img.get(0, 0)[0] - 0.6).abs() < 1e-3, "{:?}", back.img.get(0, 0)); + assert!(parse_file("garbage", "3dl").is_none()); +} + +#[test] +fn display_and_look_transforms() { + // Linear 0.18 through the sRGB display / Standard view = sRGB encoding of 0.18. + let img = Image::filled(2, 2, [0.18, 0.18, 0.18, 1.0]); + let out = run_fx("ec.color.ociodisplay", &[], img.clone(), 0.0, EffectEnv::default()); + assert!((out.img.get(0, 0)[0] as f64 - Tf::Srgb.encode(0.18)).abs() < 1e-5); + let back = run_fx("ec.color.ociodisplay", &[("direction", Value::Enum(1))], out.img, 0.0, EffectEnv::default()); + assert!((back.img.get(0, 0)[0] - 0.18).abs() < 1e-5); + // Tone mapped view keeps highlights below 1. + let hot = Image::filled(1, 1, [8.0, 8.0, 8.0, 1.0]); + let tm = run_fx("ec.color.ociodisplay", &[("view", Value::Enum(1))], hot, 0.0, EffectEnv::default()); + assert!(tm.img.get(0, 0)[0] < 1.0); + assert!((tonemap_inv(tonemap(3.0)) - 3.0).abs() < 1e-9); + // Looks: Warm raises red over blue; its inverse undoes it. + let grey = Image::filled(1, 1, [0.18, 0.18, 0.18, 1.0]); + let w = run_fx("ec.color.ociolook", &[("looks", Value::Str("Warm".into()))], grey.clone(), 0.0, EffectEnv::default()); + let px = w.img.get(0, 0); + assert!(px[0] > px[2]); + let u = run_fx("ec.color.ociolook", &[("looks", Value::Str("-Warm".into()))], w.img, 0.0, EffectEnv::default()); + assert!((u.img.get(0, 0)[0] - 0.18).abs() < 1e-4 && (u.img.get(0, 0)[2] - 0.18).abs() < 1e-4); + assert_eq!(parse_looks("Warm, -Cool, nope").len(), 2); +} + +#[test] +fn profile_converter_converts_and_round_trips() { + let img = Image::filled(2, 2, [0.8, 0.4, 0.1, 1.0]); + // sRGB → Rec. 2020 (relative), then back. + let a = run_fx( + "ec.utility.colorprofileconverter", + &[("inputProfile", Value::Enum(1)), ("outputProfile", Value::Enum(3))], + img.clone(), + 0.0, + EffectEnv::default(), + ); + assert_ne!(a.img.data, img.data); + let b = + run_fx("ec.utility.colorprofileconverter", &[("inputProfile", Value::Enum(3)), ("outputProfile", Value::Enum(1))], a.img, 0.0, EffectEnv::default()); + assert!((0..3).all(|k| (b.img.get(0, 0)[k] - img.get(0, 0)[k]).abs() < 1e-4)); + // Same profile both sides: identity. Working space defaults to sRGB. + let c = run_fx( + "ec.utility.colorprofileconverter", + &[("inputProfile", Value::Enum(0)), ("outputProfile", Value::Enum(1))], + img.clone(), + 0.0, + EffectEnv::default(), + ); + assert_eq!(c.img.data, img.data); + // Perceptual pulls out-of-gamut colours inside: saturated Rec.2020 green into sRGB. + let g = Image::filled(1, 1, [0.0, 1.0, 0.0, 1.0]); + let rel = run_fx( + "ec.utility.colorprofileconverter", + &[("inputProfile", Value::Enum(3)), ("outputProfile", Value::Enum(1))], + g.clone(), + 0.0, + EffectEnv::default(), + ); + assert!(rel.img.get(0, 0)[0] < 0.0); + let per = run_fx( + "ec.utility.colorprofileconverter", + &[("inputProfile", Value::Enum(3)), ("outputProfile", Value::Enum(1)), ("intent", Value::Enum(0))], + g, + 0.0, + EffectEnv::default(), + ); + assert!(per.img.get(0, 0).iter().all(|v| *v >= -1e-6)); +} + +struct RefHost(Image); +impl EffectHost for RefHost { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn self_at(&self, _: f64, _: usize) -> Option { + Some(Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 }) + } +} + +#[test] +fn color_stabilizer_matches_the_reference_frame() { + let mk = |k: f32, o: f32| { + let mut img = Image::new(40, 40); + for y in 0..40 { + for x in 0..40 { + let v = (x as f32 / 40.0) * k + o; + img.set(x, y, [v, v * 0.9, v * 0.8, 1.0]); + } + } + img + }; + let reference = mk(1.0, 0.0); + let flicker = mk(0.8, 0.1); + let host = RefHost(reference.clone()); + let env = EffectEnv { host: Some(&host), ..Default::default() }; + let pts = [("blackPoint", Value::Vec2([5.0, 20.0])), ("midPoint", Value::Vec2([20.0, 20.0])), ("whitePoint", Value::Vec2([35.0, 20.0]))]; + let mut vals: Vec<(&str, Value)> = pts.to_vec(); + vals.push(("stabilize", Value::Enum(1))); + vals.push(("sampleSize", num(1.0))); + let out = run_fx("ec.color.colorstabilizer", &vals, flicker.clone(), 0.0, env); + for x in [5i64, 20, 35] { + let (o, r) = (out.img.get(x, 20), reference.get(x, 20)); + assert!((o[0] - r[0]).abs() < 0.01, "x {x}: {o:?} vs {r:?}"); + } + // Deterministic, and a no-op without a host. + let again = run_fx("ec.color.colorstabilizer", &vals, flicker.clone(), 0.0, env); + assert_eq!(out.img.data, again.img.data); + let none = run_fx("ec.color.colorstabilizer", &vals, flicker.clone(), 0.0, EffectEnv::default()); + assert_eq!(none.img.data, flicker.data); + // Brightness mode corrects an offset at the black point. + let lifted = mk(1.0, 0.1); + let mut vals2: Vec<(&str, Value)> = pts.to_vec(); + vals2.push(("sampleSize", num(1.0))); + let b = run_fx("ec.color.colorstabilizer", &vals2, lifted, 0.0, env); + assert!((b.img.get(5, 20)[0] - reference.get(5, 20)[0]).abs() < 0.01); +} diff --git a/crates/effects/src/ocio_config.rs b/crates/effects/src/ocio_config.rs index 5ea844414..af860c2fc 100644 --- a/crates/effects/src/ocio_config.rs +++ b/crates/effects/src/ocio_config.rs @@ -20,7 +20,7 @@ //! * `looks` (`name`, `process_space`, `transform`, `inverse_transform`) and look strings //! (`+A, -B`, `A | B` = the first one the config has); //! * transforms: `MatrixTransform` (`matrix` 4×4 row-major, `offset`), `FileTransform` (the LUT, -//! CDL and matrix files [`super::ocio::parse_file`] reads; `interpolation`, `ccc_id`), +//! CDL and matrix files `super::ocio::parse_file` reads; `interpolation`, `ccc_id`), //! `ExponentTransform` (`value`), `ExponentWithLinearTransform` (`gamma`, `offset`, `style: //! mirror`; the power curve with a linear toe that the Academy CLF specification calls //! "moncurve"), `LogTransform` (`base`), `LogAffineTransform` (`base`, `logSideSlope`, @@ -1331,401 +1331,4 @@ pub fn load_config(src: &str) -> Result, String> { } #[cfg(test)] -mod tests { - use super::*; - - const CONFIG: &str = r#" -ocio_profile_version: 2 - -name: Test Config # a comment -search_path: "luts:other" -roles: - scene_linear: lin - color_timing: "log2" - -displays: - sRGB: - - ! {name: Standard, colorspace: gamma22} - - ! {name: Raw, colorspace: raw} - -colorspaces: - - ! - name: lin - family: "" - description: | - The reference. - Linear. - isdata: false - - - ! - name: gamma22 - aliases: [g22, "gamma 2.2"] - to_scene_reference: ! {value: [2.2, 2.2, 2.2, 1]} - - - ! - name: half - from_reference: ! {matrix: [0.5, 0, 0, 0, - 0, 0.5, 0, 0, - 0, 0, 0.5, 0, - 0, 0, 0, 1], offset: [0.1, 0.1, 0.1, 0]} - - - ! - name: log2 - from_reference: ! - children: - - ! {matrix: [2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 1]} - - ! {base: 2} - - - ! - name: affine - to_reference: ! {base: 10, logSideSlope: 0.5, logSideOffset: 0.2, linSideSlope: 2, linSideOffset: 0.01, direction: inverse} - - - ! - name: lut - to_reference: ! {src: double.cube, interpolation: linear} - - - ! - name: raw - isdata: true - - - ! - name: weird - to_reference: ! {exposure: 1} -"#; - - fn close(a: [f64; 3], b: [f64; 3], tol: f64) -> bool { - (0..3).all(|i| (a[i] - b[i]).abs() < tol) - } - - fn temp_dir(name: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("ec-ocio-{name}-{}", std::process::id())); - std::fs::create_dir_all(dir.join("luts")).unwrap(); - dir - } - - #[test] - fn yaml_subset() { - let y = parse_yaml("a: 1\nb:\n - x\n - {k: [1, 2], s: 'q: r'}\nc: ! {v: 3}\nd:\n- 4\n- 5\n"); - assert_eq!(y.get("a").and_then(Yaml::str), Some("1")); - assert_eq!(y.get("b").unwrap().seq().len(), 2); - assert_eq!(y.get("b").unwrap().seq()[1].get("k").unwrap().nums(), vec![1.0, 2.0]); - assert_eq!(y.get("b").unwrap().seq()[1].get("s").and_then(Yaml::str), Some("q: r")); - assert_eq!(y.get("c").unwrap().tag(), Some("T")); - assert_eq!(y.get("c").unwrap().get("v").and_then(Yaml::str), Some("3")); - assert_eq!(y.get("d").unwrap().seq().len(), 2, "a sequence at the key's indent"); - } - - /// Hostile nesting (flow brackets and block indentation) is cut off instead of exhausting - /// the stack. - #[test] - fn yaml_nesting_is_bounded() { - let flow = format!("a: {}\nb: 1\n", "[".repeat(200_000)); - assert!(matches!(parse_yaml(&flow), Yaml::Map(_))); - let block: String = (0..2_000).map(|i| format!("{}k{i}:\n", " ".repeat(i))).collect(); - assert!(matches!(parse_yaml(&block), Yaml::Map(_))); - let dashes: String = (0..2_000).map(|i| format!("{}- x{i}:\n", " ".repeat(2 * i))).collect(); - let _ = parse_yaml(&dashes); - assert!(Config::parse(&flow, Path::new(".")).is_err()); - } - - #[test] - fn parses_spaces_roles_displays_and_converts() { - let dir = temp_dir("basic"); - // A 1D LUT that doubles (0..1 → 0..2). - std::fs::write(dir.join("luts/double.cube"), "LUT_1D_SIZE 2\nDOMAIN_MIN 0 0 0\nDOMAIN_MAX 1 1 1\n0 0 0\n2 2 2\n").unwrap(); - let c = Config::parse(CONFIG, &dir).unwrap(); - assert_eq!(c.name, "Test Config"); - assert_eq!(c.search_path, vec!["luts", "other"]); - assert_eq!(c.space_names(), vec!["lin", "gamma22", "half", "log2", "affine", "lut", "raw", "weird"]); - // Roles and aliases resolve. - assert_eq!(c.space("scene_linear").unwrap().name, "lin"); - assert_eq!(c.space("G22").unwrap().name, "gamma22"); - assert_eq!(c.space("gamma 2.2").unwrap().name, "gamma22"); - assert_eq!(c.view("sRGB", "Standard").unwrap().1.colorspace, "gamma22"); - let sp = |n: &str| c.space(n).unwrap(); - // gamma22 → lin: decode with the exponent. - let g = c.convert(sp("gamma22"), sp("lin"), [0.5, 0.25, 1.0]); - assert!(close(g, [0.5f64.powf(2.2), 0.25f64.powf(2.2), 1.0], 1e-9), "{g:?}"); - // lin → gamma22: the inverse (only to_reference given). - let back = c.convert(sp("lin"), sp("gamma22"), g); - assert!(close(back, [0.5, 0.25, 1.0], 1e-9), "{back:?}"); - // The display's view is that conversion. - assert!(close(c.display_path(sp("lin"), "sRGB", "Standard").apply(g, false), [0.5, 0.25, 1.0], 1e-9)); - // lin → half: matrix and offset; and back through the inverse matrix. - let h = c.convert(sp("lin"), sp("half"), [0.4, 0.6, 1.0]); - assert!(close(h, [0.3, 0.4, 0.6], 1e-9), "{h:?}"); - assert!(close(c.convert(sp("half"), sp("lin"), h), [0.4, 0.6, 1.0], 1e-9)); - // lin → log2: a group (×2 then log2): 0.5 → 0; 2 → 2. - let l = c.convert(sp("lin"), sp("log2"), [0.5, 2.0, 4.0]); - assert!(close(l, [0.0, 2.0, 3.0], 1e-9), "{l:?}"); - assert!(close(c.convert(sp("log2"), sp("lin"), l), [0.5, 2.0, 4.0], 1e-9)); - // LogAffine (inverse direction as to_reference) round-trips. - let a = c.convert(sp("lin"), sp("affine"), [0.18, 0.5, 1.0]); - let expect = [0.18f64, 0.5, 1.0].map(|v| 0.5 * (2.0 * v + 0.01).log10() + 0.2); - assert!(close(a, expect, 1e-9), "{a:?} vs {expect:?}"); - assert!(close(c.convert(sp("affine"), sp("lin"), a), [0.18, 0.5, 1.0], 1e-9)); - // FileTransform found on the search path. - let f = c.convert(sp("lut"), sp("lin"), [0.25, 0.5, 0.0]); - assert!(close(f, [0.5, 1.0, 0.0], 1e-6), "{f:?}"); - // Data spaces pass through; unsupported transforms are reported. - assert_eq!(c.convert(sp("raw"), sp("gamma22"), [0.3, 0.3, 0.3]), [0.3, 0.3, 0.3]); - assert_eq!(c.unsupported(), vec!["ExposureContrastTransform".to_string()]); - assert_eq!(c.path(sp("lin"), sp("weird")).unsupported_list(), vec!["ExposureContrastTransform".to_string()]); - assert!(c.path(sp("lin"), sp("half")).unsupported_list().is_empty()); - // Loading from a file path (cached), and errors. - std::fs::write(dir.join("config.ocio"), CONFIG).unwrap(); - let loaded = load_config(&dir.join("config.ocio").to_string_lossy()).unwrap(); - assert_eq!(loaded.spaces.len(), 8); - assert!(load_config("/no/such/config.ocio").is_err()); - assert!(Config::parse("just: text\n", &dir).is_err()); - } - - /// The OCIO Color Space and Display Transform effects with Configuration ▸ Custom. - #[test] - fn effects_use_a_custom_config() { - use effectcraft_keyframe::Value; - let cfg = "ocio_profile_version: 1\nroles:\n default: lin\ndisplays:\n Monitor:\n - ! {name: Video, colorspace: g2}\ncolorspaces:\n - !\n name: lin\n - !\n name: g2\n to_reference: ! {value: [2, 2, 2, 1]}\n"; - let img = crate::Image::filled(2, 2, [0.5, 0.25, 1.0, 1.0]); - let fx = |id: &str, extra: &[(&str, Value)]| { - let mut v = vec![("config", Value::Enum(1)), ("configFile", Value::Str(cfg.into()))]; - v.extend(extra.iter().cloned()); - crate::run_fx(id, &v, img.clone(), 0.0, crate::EffectEnv::default()).img.data[0] - }; - let o = fx("ec.color.ociocolorspace", &[("sourceName", Value::Str("g2".into())), ("destinationName", Value::Str("default".into()))]); - assert!((o[0] - 0.25).abs() < 1e-6 && (o[1] - 0.0625).abs() < 1e-6 && (o[2] - 1.0).abs() < 1e-6, "{o:?}"); - // Inverse direction. - let o = fx( - "ec.color.ociocolorspace", - &[("sourceName", Value::Str("g2".into())), ("destinationName", Value::Str("lin".into())), ("direction", Value::Enum(1))], - ); - assert!((o[0] - 0.5f32.sqrt()).abs() < 1e-6, "{o:?}"); - // Display ▸ view → its colour space. - let o = fx( - "ec.color.ociodisplay", - &[("sourceName", Value::Str("lin".into())), ("displayName", Value::Str("Monitor".into())), ("viewName", Value::Str("Video".into()))], - ); - assert!((o[0] - 0.5f32.sqrt()).abs() < 1e-6, "{o:?}"); - // An unreadable config leaves pixels alone. - let o = crate::run_fx( - "ec.color.ociocolorspace", - &[("config", Value::Enum(1)), ("configFile", Value::Str("/missing.ocio".into()))], - img.clone(), - 0.0, - crate::EffectEnv::default(), - ); - assert_eq!(o.img.data, img.data); - } - - /// A config laid out like Blender 4.x / 5.x's (written for this test): the reference space is - /// CIE XYZ with the E white, the D65 spaces adapt through a `.spimtx` matrix, sRGB is a - /// ColorSpaceTransform plus an ExponentWithLinearTransform, the display-referred spaces meet - /// the scene through the default view transform, and a log space uses an - /// AllocationTransform. - fn blender_like(dir: &Path) -> Config { - use effectcraft_color::space::{invert, rgb_to_xyz}; - let e_to_d65 = crate::ocio::bradford([1.0 / 3.0, 1.0 / 3.0], D65); - let rows = |m: [[f64; 3]; 3], sep: &str, end: &str| m.iter().map(|r| format!("{}{sep}{}{sep}{}{end}", r[0], r[1], r[2])).collect::>(); - std::fs::write(dir.join("luts/e_to_d65.spimtx"), rows(e_to_d65, " ", " 0\n").concat()).unwrap(); - let xyz_to_709 = rows(invert(&rgb_to_xyz(REC709, D65)), ", ", ", 0").join(", "); - let text = format!( - r#"ocio_profile_version: 2.4 -search_path: luts -roles: - reference: Linear CIE-XYZ E - scene_linear: Linear Rec.709 - cie_xyz_d65_interchange: XYZ D65 Display -default_view_transform: Standard -view_transforms: - - ! - name: Standard - from_scene_reference: ! {{src: e_to_d65.spimtx, interpolation: linear}} -displays: - sRGB: - - ! {{name: Standard, colorspace: sRGB}} - - ! {{name: Display, view_transform: Standard, display_colorspace: sRGB Display}} - - ! {{name: Log, colorspace: Log Encoding}} - - ! {{name: Log Brighter, colorspace: Log Encoding, looks: +Brighter}} - - ! {{name: Log Darker, colorspace: Log Encoding, looks: "-Brighter, Missing"}} - - ! {{name: Raw, colorspace: Non-Color}} -display_colorspaces: - - ! - name: XYZ D65 Display - isdata: false - - ! - name: sRGB Display - from_display_reference: ! - children: - - ! {{src: cie_xyz_d65_interchange, dst: Linear Rec.709}} - - ! {{gamma: 2.4, offset: 0.055, direction: inverse}} -colorspaces: - - ! - name: Linear CIE-XYZ E - aliases: [Linear CIE-XYZ I-E] - - ! - name: Linear CIE-XYZ D65 - from_scene_reference: ! {{src: e_to_d65.spimtx, interpolation: linear}} - - ! - name: Linear Rec.709 - from_scene_reference: ! - children: - - ! {{src: Linear CIE-XYZ I-E, dst: Linear CIE-XYZ D65}} - - ! {{matrix: [{xyz_to_709}, 0, 0, 0, 1]}} - - ! - name: sRGB - from_scene_reference: ! - children: - - ! {{src: Linear CIE-XYZ E, dst: Linear Rec.709}} - - ! {{gamma: 2.4, offset: 0.055, direction: inverse}} - - ! - name: Log Encoding - from_scene_reference: ! - children: - - ! {{src: Linear CIE-XYZ E, dst: Linear Rec.709}} - - ! {{allocation: lg2, vars: [-12, 12]}} - - ! - name: ACEScg - from_scene_reference: ! - children: - - ! {{src: Linear CIE-XYZ E, dst: Linear CIE-XYZ D65}} - - ! {{style: "UTILITY - ACES-AP1_to_CIE-XYZ-D65_BFD", direction: inverse}} - - ! - name: Clipped - from_scene_reference: ! - children: - - ! {{src: Linear CIE-XYZ E, dst: Linear Rec.709}} - - ! {{min_in_value: 0, min_out_value: 0}} - - ! - name: Graded - from_scene_reference: ! - children: - - ! {{src: Linear CIE-XYZ E, dst: Linear Rec.709}} - - ! - shadows: {{rgb: [0.2, 0.2, 0.2], master: 0.35, start: 0.4, pivot: 0.1}} - - ! - name: Loop - from_scene_reference: ! {{src: Linear CIE-XYZ E, dst: Loop}} - - ! - name: Fan - from_scene_reference: ! - children: - - ! {{src: Linear CIE-XYZ E, dst: Fan}} - - ! {{src: Linear CIE-XYZ E, dst: Fan}} - - ! {{src: Linear CIE-XYZ E, dst: Fan}} - - ! - name: Non-Color - isdata: true -looks: - - ! - name: Brighter - process_space: Log Encoding - transform: ! {{offset: [0.05, 0.05, 0.05]}} -"# - ); - Config::parse(&text, dir).unwrap() - } - - /// The sRGB encoding (IEC 61966-2-1). - fn srgb(v: f64) -> f64 { - if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } - } - - /// #410: with Blender's config, Linear Rec.709 → sRGB got no sRGB curve and a blue cast: - /// ColorSpaceTransform and ExponentWithLinearTransform passed colours through, so the - /// colours stayed in the E-white XYZ reference. Every route to sRGB now encodes greys to - /// neutral sRGB values (0.18 → 0.4614). - #[test] - fn blender_like_config_converts_linear_rec709_to_srgb() { - let dir = temp_dir("blender"); - let c = blender_like(&dir); - assert!(c.unsupported().iter().all(|t| t == "GradingToneTransform"), "{:?}", c.unsupported()); - let sp = |n: &str| c.space(n).unwrap(); - let lin = sp("scene_linear"); - let routes = [ - ("Color Space Transform", c.path(lin, sp("sRGB"))), - ("display-referred sRGB", c.path(lin, sp("sRGB Display"))), - ("v1 view", c.display_path(lin, "sRGB", "Standard")), - ("v2 view", c.display_path(lin, "sRGB", "Display")), - ]; - for (route, x) in &routes { - assert!(x.unsupported_list().is_empty(), "{route}: {:?}", x.unsupported_list()); - for v in [0.0, 0.001, 0.18, 0.5, 1.0, 4.0] { - let o = x.apply([v; 3], false); - assert!(close(o, [srgb(v); 3], 2e-5), "{route}: {v} → {o:?}, want {}", srgb(v)); - } - let red = x.apply([1.0, 0.0, 0.0], false); - assert!(close(red, [1.0, 0.0, 0.0], 1e-4), "{route}: red {red:?}"); - // And back. - assert!(close(x.apply([srgb(0.18); 3], true), [0.18; 3], 1e-6), "{route}"); - } - // AllocationTransform lg2 and a look in its process space; data passes through. - let log = |v: f64| (v.log2() + 12.0) / 24.0; - let o = c.display_path(lin, "sRGB", "Log").apply([0.18; 3], false); - assert!(close(o, [log(0.18); 3], 1e-6), "{o:?}"); - let o = c.display_path(lin, "sRGB", "Log Brighter").apply([0.18; 3], false); - assert!(close(o, [log(0.18) + 0.05; 3], 1e-6), "{o:?}"); - let darker = c.display_path(lin, "sRGB", "Log Darker"); - assert!(close(darker.apply([0.18; 3], false), [log(0.18) - 0.05; 3], 1e-6)); - assert_eq!(darker.unsupported_list(), vec!["Look `Missing` (not in the config)".to_string()]); - assert_eq!(c.display_path(lin, "sRGB", "Raw").apply([0.3, 2.0, -1.0], false), [0.3, 2.0, -1.0]); - // BuiltinTransform: the built-in config's Linear Rec.709 → ACEScg. - let want = crate::ocio::Xform::new(&sp_builtin("Linear Rec.709 (sRGB)"), &sp_builtin("ACEScg")).apply([1.0, 0.25, 0.0]); - let got = c.convert(lin, sp("ACEScg"), [1.0, 0.25, 0.0]); - assert!(close(got, want.map(f64::from), 1e-4), "{got:?} vs {want:?}"); - // RangeTransform with only a minimum clamps below and shifts nothing. - let o = c.convert(lin, sp("Clipped"), [-0.5, 0.5, 2.0]); - assert!(close(o, [0.0, 0.5, 2.0], 1e-6), "{o:?}"); - // Unsupported steps are listed (and pass through); loops stop. - assert_eq!(c.path(lin, sp("Graded")).unsupported_list(), vec!["GradingToneTransform".to_string()]); - assert!(c.path(lin, sp("Loop")).unsupported_list()[0].contains("nested")); - let fan = c.path(lin, sp("Fan")); - assert!(!fan.unsupported_list().is_empty()); - assert!(matches!(&fan, Xf::Group(v) if v.len() <= MAX_OPS + 1)); - } - - fn sp_builtin(name: &str) -> Space { - *crate::ocio::SPACES.iter().find(|s| s.name == name).unwrap() - } - - /// ExponentWithLinearTransform is the sRGB curve at gamma 2.4 / offset 0.055, continuous at - /// its break point and inverted exactly; mirror mirrors negatives. - #[test] - fn moncurve_matches_srgb_and_inverts() { - for v in [-0.2, 0.0, 0.002, 0.039, 0.0393, 0.2, 0.5, 1.0, 3.0] { - let lin = moncurve(v, 2.4, 0.055, false, false); - assert!((moncurve(lin, 2.4, 0.055, true, false) - v).abs() < 1e-12, "{v}"); - if v >= 0.0 { - assert!((moncurve(srgb(v), 2.4, 0.055, false, false) - v).abs() < 2e-5, "{v}"); - } - } - let xb = 0.055 / 1.4; - assert!((moncurve(xb - 1e-12, 2.4, 0.055, false, false) - moncurve(xb + 1e-12, 2.4, 0.055, false, false)).abs() < 1e-9); - assert_eq!(moncurve(-0.5, 2.4, 0.055, false, true), -moncurve(0.5, 2.4, 0.055, false, false)); - assert_eq!(moncurve(-0.5, 1.0, 0.0, false, false), 0.0); - } - - #[test] - fn builtin_styles() { - for style in ["IDENTITY", "DISPLAY - CIE-XYZ-D65_to_sRGB", "display - cie-xyz-d65_to_rec.2100-pq", "ACEScc_to_ACES2065-1", "ACEScct_to_ACES2065-1"] { - assert!(builtin(style).is_some(), "{style}"); - } - assert!(builtin("ACES-OUTPUT - ACES2065-1_to_CIE-XYZ-D65 - SDR-VIDEO_1.0").is_none()); - // D65 white in XYZ is display white. - let xyz = rgb_white_xyz(); - for style in ["DISPLAY - CIE-XYZ-D65_to_sRGB", "DISPLAY - CIE-XYZ-D65_to_REC.1886-REC.709", "DISPLAY - CIE-XYZ-D65_to_DisplayP3"] { - let o = builtin(style).unwrap().apply(xyz, false); - assert!(close(o, [1.0; 3], 1e-6), "{style}: {o:?}"); - } - // PQ: 1.0 = 100 nits → code value ≈ 0.508. - let pq = builtin("DISPLAY - CIE-XYZ-D65_to_REC.2100-PQ").unwrap().apply(xyz, false); - assert!((pq[0] - 0.508).abs() < 1e-3, "{pq:?}"); - // ACEScc and ACEScct of 0.18 (ACES2065-1 grey stays grey). - for (style, code) in [("ACEScc_to_ACES2065-1", 0.413_588_402_492_442_1), ("ACEScct_to_ACES2065-1", 0.413_588_402_492_442_1)] { - let o = builtin(style).unwrap().apply([code; 3], false); - assert!(close(o, [0.18; 3], 1e-6), "{style}: {o:?}"); - } - } - - fn rgb_white_xyz() -> [f64; 3] { - [D65[0] / D65[1], 1.0, (1.0 - D65[0] - D65[1]) / D65[1]] - } -} +mod tests; diff --git a/crates/effects/src/ocio_config/tests.rs b/crates/effects/src/ocio_config/tests.rs new file mode 100644 index 000000000..f534fe0e2 --- /dev/null +++ b/crates/effects/src/ocio_config/tests.rs @@ -0,0 +1,394 @@ +use super::*; + +const CONFIG: &str = r#" +ocio_profile_version: 2 + +name: Test Config # a comment +search_path: "luts:other" +roles: + scene_linear: lin + color_timing: "log2" + +displays: + sRGB: + - ! {name: Standard, colorspace: gamma22} + - ! {name: Raw, colorspace: raw} + +colorspaces: + - ! + name: lin + family: "" + description: | + The reference. + Linear. + isdata: false + + - ! + name: gamma22 + aliases: [g22, "gamma 2.2"] + to_scene_reference: ! {value: [2.2, 2.2, 2.2, 1]} + + - ! + name: half + from_reference: ! {matrix: [0.5, 0, 0, 0, + 0, 0.5, 0, 0, + 0, 0, 0.5, 0, + 0, 0, 0, 1], offset: [0.1, 0.1, 0.1, 0]} + + - ! + name: log2 + from_reference: ! + children: + - ! {matrix: [2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 1]} + - ! {base: 2} + + - ! + name: affine + to_reference: ! {base: 10, logSideSlope: 0.5, logSideOffset: 0.2, linSideSlope: 2, linSideOffset: 0.01, direction: inverse} + + - ! + name: lut + to_reference: ! {src: double.cube, interpolation: linear} + + - ! + name: raw + isdata: true + + - ! + name: weird + to_reference: ! {exposure: 1} +"#; + +fn close(a: [f64; 3], b: [f64; 3], tol: f64) -> bool { + (0..3).all(|i| (a[i] - b[i]).abs() < tol) +} + +fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("ec-ocio-{name}-{}", std::process::id())); + std::fs::create_dir_all(dir.join("luts")).unwrap(); + dir +} + +#[test] +fn yaml_subset() { + let y = parse_yaml("a: 1\nb:\n - x\n - {k: [1, 2], s: 'q: r'}\nc: ! {v: 3}\nd:\n- 4\n- 5\n"); + assert_eq!(y.get("a").and_then(Yaml::str), Some("1")); + assert_eq!(y.get("b").unwrap().seq().len(), 2); + assert_eq!(y.get("b").unwrap().seq()[1].get("k").unwrap().nums(), vec![1.0, 2.0]); + assert_eq!(y.get("b").unwrap().seq()[1].get("s").and_then(Yaml::str), Some("q: r")); + assert_eq!(y.get("c").unwrap().tag(), Some("T")); + assert_eq!(y.get("c").unwrap().get("v").and_then(Yaml::str), Some("3")); + assert_eq!(y.get("d").unwrap().seq().len(), 2, "a sequence at the key's indent"); +} + +/// Hostile nesting (flow brackets and block indentation) is cut off instead of exhausting +/// the stack. +#[test] +fn yaml_nesting_is_bounded() { + let flow = format!("a: {}\nb: 1\n", "[".repeat(200_000)); + assert!(matches!(parse_yaml(&flow), Yaml::Map(_))); + let block: String = (0..2_000).map(|i| format!("{}k{i}:\n", " ".repeat(i))).collect(); + assert!(matches!(parse_yaml(&block), Yaml::Map(_))); + let dashes: String = (0..2_000).map(|i| format!("{}- x{i}:\n", " ".repeat(2 * i))).collect(); + let _ = parse_yaml(&dashes); + assert!(Config::parse(&flow, Path::new(".")).is_err()); +} + +#[test] +fn parses_spaces_roles_displays_and_converts() { + let dir = temp_dir("basic"); + // A 1D LUT that doubles (0..1 → 0..2). + std::fs::write(dir.join("luts/double.cube"), "LUT_1D_SIZE 2\nDOMAIN_MIN 0 0 0\nDOMAIN_MAX 1 1 1\n0 0 0\n2 2 2\n").unwrap(); + let c = Config::parse(CONFIG, &dir).unwrap(); + assert_eq!(c.name, "Test Config"); + assert_eq!(c.search_path, vec!["luts", "other"]); + assert_eq!(c.space_names(), vec!["lin", "gamma22", "half", "log2", "affine", "lut", "raw", "weird"]); + // Roles and aliases resolve. + assert_eq!(c.space("scene_linear").unwrap().name, "lin"); + assert_eq!(c.space("G22").unwrap().name, "gamma22"); + assert_eq!(c.space("gamma 2.2").unwrap().name, "gamma22"); + assert_eq!(c.view("sRGB", "Standard").unwrap().1.colorspace, "gamma22"); + let sp = |n: &str| c.space(n).unwrap(); + // gamma22 → lin: decode with the exponent. + let g = c.convert(sp("gamma22"), sp("lin"), [0.5, 0.25, 1.0]); + assert!(close(g, [0.5f64.powf(2.2), 0.25f64.powf(2.2), 1.0], 1e-9), "{g:?}"); + // lin → gamma22: the inverse (only to_reference given). + let back = c.convert(sp("lin"), sp("gamma22"), g); + assert!(close(back, [0.5, 0.25, 1.0], 1e-9), "{back:?}"); + // The display's view is that conversion. + assert!(close(c.display_path(sp("lin"), "sRGB", "Standard").apply(g, false), [0.5, 0.25, 1.0], 1e-9)); + // lin → half: matrix and offset; and back through the inverse matrix. + let h = c.convert(sp("lin"), sp("half"), [0.4, 0.6, 1.0]); + assert!(close(h, [0.3, 0.4, 0.6], 1e-9), "{h:?}"); + assert!(close(c.convert(sp("half"), sp("lin"), h), [0.4, 0.6, 1.0], 1e-9)); + // lin → log2: a group (×2 then log2): 0.5 → 0; 2 → 2. + let l = c.convert(sp("lin"), sp("log2"), [0.5, 2.0, 4.0]); + assert!(close(l, [0.0, 2.0, 3.0], 1e-9), "{l:?}"); + assert!(close(c.convert(sp("log2"), sp("lin"), l), [0.5, 2.0, 4.0], 1e-9)); + // LogAffine (inverse direction as to_reference) round-trips. + let a = c.convert(sp("lin"), sp("affine"), [0.18, 0.5, 1.0]); + let expect = [0.18f64, 0.5, 1.0].map(|v| 0.5 * (2.0 * v + 0.01).log10() + 0.2); + assert!(close(a, expect, 1e-9), "{a:?} vs {expect:?}"); + assert!(close(c.convert(sp("affine"), sp("lin"), a), [0.18, 0.5, 1.0], 1e-9)); + // FileTransform found on the search path. + let f = c.convert(sp("lut"), sp("lin"), [0.25, 0.5, 0.0]); + assert!(close(f, [0.5, 1.0, 0.0], 1e-6), "{f:?}"); + // Data spaces pass through; unsupported transforms are reported. + assert_eq!(c.convert(sp("raw"), sp("gamma22"), [0.3, 0.3, 0.3]), [0.3, 0.3, 0.3]); + assert_eq!(c.unsupported(), vec!["ExposureContrastTransform".to_string()]); + assert_eq!(c.path(sp("lin"), sp("weird")).unsupported_list(), vec!["ExposureContrastTransform".to_string()]); + assert!(c.path(sp("lin"), sp("half")).unsupported_list().is_empty()); + // Loading from a file path (cached), and errors. + std::fs::write(dir.join("config.ocio"), CONFIG).unwrap(); + let loaded = load_config(&dir.join("config.ocio").to_string_lossy()).unwrap(); + assert_eq!(loaded.spaces.len(), 8); + assert!(load_config("/no/such/config.ocio").is_err()); + assert!(Config::parse("just: text\n", &dir).is_err()); +} + +/// The OCIO Color Space and Display Transform effects with Configuration ▸ Custom. +#[test] +fn effects_use_a_custom_config() { + use effectcraft_keyframe::Value; + let cfg = "ocio_profile_version: 1\nroles:\n default: lin\ndisplays:\n Monitor:\n - ! {name: Video, colorspace: g2}\ncolorspaces:\n - !\n name: lin\n - !\n name: g2\n to_reference: ! {value: [2, 2, 2, 1]}\n"; + let img = crate::Image::filled(2, 2, [0.5, 0.25, 1.0, 1.0]); + let fx = |id: &str, extra: &[(&str, Value)]| { + let mut v = vec![("config", Value::Enum(1)), ("configFile", Value::Str(cfg.into()))]; + v.extend(extra.iter().cloned()); + crate::run_fx(id, &v, img.clone(), 0.0, crate::EffectEnv::default()).img.data[0] + }; + let o = fx("ec.color.ociocolorspace", &[("sourceName", Value::Str("g2".into())), ("destinationName", Value::Str("default".into()))]); + assert!((o[0] - 0.25).abs() < 1e-6 && (o[1] - 0.0625).abs() < 1e-6 && (o[2] - 1.0).abs() < 1e-6, "{o:?}"); + // Inverse direction. + let o = + fx("ec.color.ociocolorspace", &[("sourceName", Value::Str("g2".into())), ("destinationName", Value::Str("lin".into())), ("direction", Value::Enum(1))]); + assert!((o[0] - 0.5f32.sqrt()).abs() < 1e-6, "{o:?}"); + // Display ▸ view → its colour space. + let o = fx( + "ec.color.ociodisplay", + &[("sourceName", Value::Str("lin".into())), ("displayName", Value::Str("Monitor".into())), ("viewName", Value::Str("Video".into()))], + ); + assert!((o[0] - 0.5f32.sqrt()).abs() < 1e-6, "{o:?}"); + // An unreadable config leaves pixels alone. + let o = crate::run_fx( + "ec.color.ociocolorspace", + &[("config", Value::Enum(1)), ("configFile", Value::Str("/missing.ocio".into()))], + img.clone(), + 0.0, + crate::EffectEnv::default(), + ); + assert_eq!(o.img.data, img.data); +} + +/// A config laid out like Blender 4.x / 5.x's (written for this test): the reference space is +/// CIE XYZ with the E white, the D65 spaces adapt through a `.spimtx` matrix, sRGB is a +/// ColorSpaceTransform plus an ExponentWithLinearTransform, the display-referred spaces meet +/// the scene through the default view transform, and a log space uses an +/// AllocationTransform. +fn blender_like(dir: &Path) -> Config { + use effectcraft_color::space::{invert, rgb_to_xyz}; + let e_to_d65 = crate::ocio::bradford([1.0 / 3.0, 1.0 / 3.0], D65); + let rows = |m: [[f64; 3]; 3], sep: &str, end: &str| m.iter().map(|r| format!("{}{sep}{}{sep}{}{end}", r[0], r[1], r[2])).collect::>(); + std::fs::write(dir.join("luts/e_to_d65.spimtx"), rows(e_to_d65, " ", " 0\n").concat()).unwrap(); + let xyz_to_709 = rows(invert(&rgb_to_xyz(REC709, D65)), ", ", ", 0").join(", "); + let text = format!( + r#"ocio_profile_version: 2.4 +search_path: luts +roles: + reference: Linear CIE-XYZ E + scene_linear: Linear Rec.709 + cie_xyz_d65_interchange: XYZ D65 Display +default_view_transform: Standard +view_transforms: + - ! + name: Standard + from_scene_reference: ! {{src: e_to_d65.spimtx, interpolation: linear}} +displays: + sRGB: + - ! {{name: Standard, colorspace: sRGB}} + - ! {{name: Display, view_transform: Standard, display_colorspace: sRGB Display}} + - ! {{name: Log, colorspace: Log Encoding}} + - ! {{name: Log Brighter, colorspace: Log Encoding, looks: +Brighter}} + - ! {{name: Log Darker, colorspace: Log Encoding, looks: "-Brighter, Missing"}} + - ! {{name: Raw, colorspace: Non-Color}} +display_colorspaces: + - ! + name: XYZ D65 Display + isdata: false + - ! + name: sRGB Display + from_display_reference: ! + children: + - ! {{src: cie_xyz_d65_interchange, dst: Linear Rec.709}} + - ! {{gamma: 2.4, offset: 0.055, direction: inverse}} +colorspaces: + - ! + name: Linear CIE-XYZ E + aliases: [Linear CIE-XYZ I-E] + - ! + name: Linear CIE-XYZ D65 + from_scene_reference: ! {{src: e_to_d65.spimtx, interpolation: linear}} + - ! + name: Linear Rec.709 + from_scene_reference: ! + children: + - ! {{src: Linear CIE-XYZ I-E, dst: Linear CIE-XYZ D65}} + - ! {{matrix: [{xyz_to_709}, 0, 0, 0, 1]}} + - ! + name: sRGB + from_scene_reference: ! + children: + - ! {{src: Linear CIE-XYZ E, dst: Linear Rec.709}} + - ! {{gamma: 2.4, offset: 0.055, direction: inverse}} + - ! + name: Log Encoding + from_scene_reference: ! + children: + - ! {{src: Linear CIE-XYZ E, dst: Linear Rec.709}} + - ! {{allocation: lg2, vars: [-12, 12]}} + - ! + name: ACEScg + from_scene_reference: ! + children: + - ! {{src: Linear CIE-XYZ E, dst: Linear CIE-XYZ D65}} + - ! {{style: "UTILITY - ACES-AP1_to_CIE-XYZ-D65_BFD", direction: inverse}} + - ! + name: Clipped + from_scene_reference: ! + children: + - ! {{src: Linear CIE-XYZ E, dst: Linear Rec.709}} + - ! {{min_in_value: 0, min_out_value: 0}} + - ! + name: Graded + from_scene_reference: ! + children: + - ! {{src: Linear CIE-XYZ E, dst: Linear Rec.709}} + - ! + shadows: {{rgb: [0.2, 0.2, 0.2], master: 0.35, start: 0.4, pivot: 0.1}} + - ! + name: Loop + from_scene_reference: ! {{src: Linear CIE-XYZ E, dst: Loop}} + - ! + name: Fan + from_scene_reference: ! + children: + - ! {{src: Linear CIE-XYZ E, dst: Fan}} + - ! {{src: Linear CIE-XYZ E, dst: Fan}} + - ! {{src: Linear CIE-XYZ E, dst: Fan}} + - ! + name: Non-Color + isdata: true +looks: + - ! + name: Brighter + process_space: Log Encoding + transform: ! {{offset: [0.05, 0.05, 0.05]}} +"# + ); + Config::parse(&text, dir).unwrap() +} + +/// The sRGB encoding (IEC 61966-2-1). +fn srgb(v: f64) -> f64 { + if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } +} + +/// #410: with Blender's config, Linear Rec.709 → sRGB got no sRGB curve and a blue cast: +/// ColorSpaceTransform and ExponentWithLinearTransform passed colours through, so the +/// colours stayed in the E-white XYZ reference. Every route to sRGB now encodes greys to +/// neutral sRGB values (0.18 → 0.4614). +#[test] +fn blender_like_config_converts_linear_rec709_to_srgb() { + let dir = temp_dir("blender"); + let c = blender_like(&dir); + assert!(c.unsupported().iter().all(|t| t == "GradingToneTransform"), "{:?}", c.unsupported()); + let sp = |n: &str| c.space(n).unwrap(); + let lin = sp("scene_linear"); + let routes = [ + ("Color Space Transform", c.path(lin, sp("sRGB"))), + ("display-referred sRGB", c.path(lin, sp("sRGB Display"))), + ("v1 view", c.display_path(lin, "sRGB", "Standard")), + ("v2 view", c.display_path(lin, "sRGB", "Display")), + ]; + for (route, x) in &routes { + assert!(x.unsupported_list().is_empty(), "{route}: {:?}", x.unsupported_list()); + for v in [0.0, 0.001, 0.18, 0.5, 1.0, 4.0] { + let o = x.apply([v; 3], false); + assert!(close(o, [srgb(v); 3], 2e-5), "{route}: {v} → {o:?}, want {}", srgb(v)); + } + let red = x.apply([1.0, 0.0, 0.0], false); + assert!(close(red, [1.0, 0.0, 0.0], 1e-4), "{route}: red {red:?}"); + // And back. + assert!(close(x.apply([srgb(0.18); 3], true), [0.18; 3], 1e-6), "{route}"); + } + // AllocationTransform lg2 and a look in its process space; data passes through. + let log = |v: f64| (v.log2() + 12.0) / 24.0; + let o = c.display_path(lin, "sRGB", "Log").apply([0.18; 3], false); + assert!(close(o, [log(0.18); 3], 1e-6), "{o:?}"); + let o = c.display_path(lin, "sRGB", "Log Brighter").apply([0.18; 3], false); + assert!(close(o, [log(0.18) + 0.05; 3], 1e-6), "{o:?}"); + let darker = c.display_path(lin, "sRGB", "Log Darker"); + assert!(close(darker.apply([0.18; 3], false), [log(0.18) - 0.05; 3], 1e-6)); + assert_eq!(darker.unsupported_list(), vec!["Look `Missing` (not in the config)".to_string()]); + assert_eq!(c.display_path(lin, "sRGB", "Raw").apply([0.3, 2.0, -1.0], false), [0.3, 2.0, -1.0]); + // BuiltinTransform: the built-in config's Linear Rec.709 → ACEScg. + let want = crate::ocio::Xform::new(&sp_builtin("Linear Rec.709 (sRGB)"), &sp_builtin("ACEScg")).apply([1.0, 0.25, 0.0]); + let got = c.convert(lin, sp("ACEScg"), [1.0, 0.25, 0.0]); + assert!(close(got, want.map(f64::from), 1e-4), "{got:?} vs {want:?}"); + // RangeTransform with only a minimum clamps below and shifts nothing. + let o = c.convert(lin, sp("Clipped"), [-0.5, 0.5, 2.0]); + assert!(close(o, [0.0, 0.5, 2.0], 1e-6), "{o:?}"); + // Unsupported steps are listed (and pass through); loops stop. + assert_eq!(c.path(lin, sp("Graded")).unsupported_list(), vec!["GradingToneTransform".to_string()]); + assert!(c.path(lin, sp("Loop")).unsupported_list()[0].contains("nested")); + let fan = c.path(lin, sp("Fan")); + assert!(!fan.unsupported_list().is_empty()); + assert!(matches!(&fan, Xf::Group(v) if v.len() <= MAX_OPS + 1)); +} + +fn sp_builtin(name: &str) -> Space { + *crate::ocio::SPACES.iter().find(|s| s.name == name).unwrap() +} + +/// ExponentWithLinearTransform is the sRGB curve at gamma 2.4 / offset 0.055, continuous at +/// its break point and inverted exactly; mirror mirrors negatives. +#[test] +fn moncurve_matches_srgb_and_inverts() { + for v in [-0.2, 0.0, 0.002, 0.039, 0.0393, 0.2, 0.5, 1.0, 3.0] { + let lin = moncurve(v, 2.4, 0.055, false, false); + assert!((moncurve(lin, 2.4, 0.055, true, false) - v).abs() < 1e-12, "{v}"); + if v >= 0.0 { + assert!((moncurve(srgb(v), 2.4, 0.055, false, false) - v).abs() < 2e-5, "{v}"); + } + } + let xb = 0.055 / 1.4; + assert!((moncurve(xb - 1e-12, 2.4, 0.055, false, false) - moncurve(xb + 1e-12, 2.4, 0.055, false, false)).abs() < 1e-9); + assert_eq!(moncurve(-0.5, 2.4, 0.055, false, true), -moncurve(0.5, 2.4, 0.055, false, false)); + assert_eq!(moncurve(-0.5, 1.0, 0.0, false, false), 0.0); +} + +#[test] +fn builtin_styles() { + for style in ["IDENTITY", "DISPLAY - CIE-XYZ-D65_to_sRGB", "display - cie-xyz-d65_to_rec.2100-pq", "ACEScc_to_ACES2065-1", "ACEScct_to_ACES2065-1"] { + assert!(builtin(style).is_some(), "{style}"); + } + assert!(builtin("ACES-OUTPUT - ACES2065-1_to_CIE-XYZ-D65 - SDR-VIDEO_1.0").is_none()); + // D65 white in XYZ is display white. + let xyz = rgb_white_xyz(); + for style in ["DISPLAY - CIE-XYZ-D65_to_sRGB", "DISPLAY - CIE-XYZ-D65_to_REC.1886-REC.709", "DISPLAY - CIE-XYZ-D65_to_DisplayP3"] { + let o = builtin(style).unwrap().apply(xyz, false); + assert!(close(o, [1.0; 3], 1e-6), "{style}: {o:?}"); + } + // PQ: 1.0 = 100 nits → code value ≈ 0.508. + let pq = builtin("DISPLAY - CIE-XYZ-D65_to_REC.2100-PQ").unwrap().apply(xyz, false); + assert!((pq[0] - 0.508).abs() < 1e-3, "{pq:?}"); + // ACEScc and ACEScct of 0.18 (ACES2065-1 grey stays grey). + for (style, code) in [("ACEScc_to_ACES2065-1", 0.413_588_402_492_442_1), ("ACEScct_to_ACES2065-1", 0.413_588_402_492_442_1)] { + let o = builtin(style).unwrap().apply([code; 3], false); + assert!(close(o, [0.18; 3], 1e-6), "{style}: {o:?}"); + } +} + +fn rgb_white_xyz() -> [f64; 3] { + [D65[0] / D65[1], 1.0, (1.0 - D65[0] - D65[1]) / D65[1]] +} diff --git a/crates/effects/src/psim.rs b/crates/effects/src/psim.rs index aabbcdce8..5716a2dcb 100644 --- a/crates/effects/src/psim.rs +++ b/crates/effects/src/psim.rs @@ -85,21 +85,27 @@ pub trait ParticleSim: Send + Sync { /// (the CPU step's `carry` arithmetic, exactly). `None` when more than `cap` would be born (the /// CPU drops births at its live-particle cap, which an independent simulation can't know). pub fn births(rate: f64, steps: u64, cap: usize) -> Option> { - let per = rate / SPS; - if per * steps as f64 > cap as f64 + 2.0 { + if rate / SPS * steps as f64 > cap as f64 + 2.0 { return None; } + births_at(steps, cap, |_| rate) +} + +/// [`births`] with the rate of each step, `rate(i)` particles per second during step `i` (a +/// keyframed Birth Rate, #1373). +pub fn births_at(steps: u64, cap: usize, rate: impl Fn(u64) -> f64) -> Option> { let mut out = Vec::new(); let mut carry = 0.0f64; for i in 0..steps { - carry += per; + carry += rate(i) / SPS; let n = carry.floor() as u32; carry -= n as f64; - for _ in 0..n { - out.push(i as u32); + if n as usize > cap.saturating_sub(out.len()) { + return None; } + out.extend(std::iter::repeat_n(i as u32, n as usize)); } - (out.len() <= cap).then_some(out) + Some(out) } /// Steps per second of the stepped simulations. diff --git a/crates/effects/src/puppet/mod.rs b/crates/effects/src/puppet.rs similarity index 100% rename from crates/effects/src/puppet/mod.rs rename to crates/effects/src/puppet.rs diff --git a/crates/effects/src/roto.rs b/crates/effects/src/roto.rs index 4917cc231..fe6191714 100644 --- a/crates/effects/src/roto.rs +++ b/crates/effects/src/roto.rs @@ -29,9 +29,9 @@ use std::sync::{Arc, Mutex, OnceLock, RwLock, Weak}; use effectcraft_keyframe::Value; use effectcraft_project::ParamUi; use effectcraft_raster::Image; -use effectcraft_segment::MaskModel; +use effectcraft_segment::{ClipFrame, MaskModel, TrackRequest}; use effectcraft_track::roto::refine::{self, MatteParams}; -use effectcraft_track::roto::{self as rb, RotoData, SegOpts, rle}; +use effectcraft_track::roto::{self as rb, Gmm, RotoData, SegOpts, rle}; use rayon::prelude::*; use serde::{Deserialize, Serialize}; @@ -40,12 +40,16 @@ use crate::{Buf, EffectCtx, EffectSpec, Params, num, p, popup, slider}; pub use effectcraft_track::roto::{FrameSeg, Stroke, StrokeKind}; +mod pending; + /// The effect's id. pub const ID: &str = "ec.matte.rotobrush"; /// Hidden parameters. pub const STROKES: &str = "strokes"; pub const INPUT_KEY: &str = "inputKey"; pub const FROZEN: &str = "frozen"; +/// The saved tracking (see [`TrackCache`]). +pub const TRACKED: &str = "tracked"; /// Default segmentation span each side of the base frame (frames). pub const DEFAULT_SPAN: i64 = 20; @@ -78,6 +82,7 @@ pub fn specs() -> Vec { p("rotoBrushMatte/contrast", "Contrast", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0)), p("rotoBrushMatte/shiftEdge", "Shift Edge", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 0)), p("rotoBrushMatte/reduceChatter", "Reduce Chatter", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0)), + p("rotoBrushMatte/object", "Show Object", num(0.0), slider(0.0, 32.0, 0.0, 32.0, 0)), p("refineEdgeMatte/smooth", "Smooth", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 1)), p("refineEdgeMatte/feather", "Feather", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)), p("refineEdgeMatte/contrast", "Contrast", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0)), @@ -95,6 +100,7 @@ pub fn specs() -> Vec { p(STROKES, "Strokes", Value::Str(String::new()), ParamUi::Hidden), p(INPUT_KEY, "Input Key", Value::Str(String::new()), ParamUi::Hidden), p(FROZEN, "Frozen", Value::Str(String::new()), ParamUi::Hidden), + p(TRACKED, "Tracked", Value::Str(String::new()), ParamUi::Hidden), ], render, gpu: false, @@ -191,9 +197,15 @@ pub fn set_model(m: Option>) { } } +/// Whether an instance with `params` segments with the model Settings chose: Roto Brush 2.0 and +/// 3.0 do, 1.0 is the classic engine. +pub fn uses_model(params: &Params) -> bool { + e(params, "version") != 0 +} + /// The model an instance segments with: its version's choice of the installed one. pub fn model_for(params: &Params) -> Option> { - if e(params, "version") == 0 { + if !uses_model(params) { return None; } installed_model() @@ -208,6 +220,11 @@ pub fn installed_model() -> Option> { /// Seed of the chain keys: what the frames are (the engine-maintained Input Key), the layer /// size, the frame rate, the segmentation settings and the model. pub fn seed(params: &Params, layer_size: [f64; 2], fps: f64) -> u64 { + seed_with(params, layer_size, fps, model_for(params).map_or(effectcraft_segment::CLASSICAL, |m| m.info().id)) +} + +/// [`seed`] with the segmenting model named by its id (saved tracking names the one that made it). +fn seed_with(params: &Params, layer_size: [f64; 2], fps: f64, model: &str) -> u64 { let mut h = 0xcbf2_9ce4_8422_2325; fnv(&mut h, s(params, INPUT_KEY).as_bytes()); for v in [ @@ -221,7 +238,7 @@ pub fn seed(params: &Params, layer_size: [f64; 2], fps: f64) -> u64 { fnv(&mut h, &v.to_bits().to_le_bytes()); } fnv(&mut h, &[e(params, "quality") as u8, e(params, "version") as u8]); - fnv(&mut h, model_for(params).map_or(effectcraft_segment::CLASSICAL, |m| m.info().id).as_bytes()); + fnv(&mut h, model.as_bytes()); h } @@ -298,9 +315,44 @@ fn from_store(key: u64, scale: f64) -> Option> { Some(seg) } +/// Tracker results live under the chain key mixed with this, so a frame the tracker followed and +/// one the classic engine computed never share a cache slot. +const TRACK_SALT: u64 = 0x7472_6163_6b65_7231; + +fn tracked_key(key: u64) -> u64 { + key.rotate_left(17) ^ TRACK_SALT +} + +/// Where one object's matte of a tracked frame lives (kept only when the instance has several objects). +fn object_key(key: u64, object: u32) -> u64 { + tracked_key(key).wrapping_add(0x9e37_79b9_7f4a_7c15u64.wrapping_mul(u64::from(object) + 1)) +} + +/// How far to trust a frame the tracker followed. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrameTrust { + /// 0–1, from the tracker's own object score and mask quality. + pub confidence: f32, + /// The object is not in this frame (an empty matte). + pub occluded: bool, +} + +fn trust_cache() -> &'static Mutex> { + static T: OnceLock>> = OnceLock::new(); + T.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// The tracker's confidence in the frame with chain key `key`, if the tracker followed it. +pub fn trust(key: u64) -> Option { + trust_cache().lock().unwrap_or_else(|e| e.into_inner()).get(&tracked_key(key)).copied() +} + /// Drop the cached segmentations of these chain keys (every scale). pub fn forget(keys: impl IntoIterator) { - let keys: std::collections::HashSet = keys.into_iter().collect(); + let mut trusts = trust_cache().lock().unwrap_or_else(|e| e.into_inner()); + let keys: std::collections::HashSet = keys.into_iter().flat_map(|k| [k, tracked_key(k)]).collect(); + trusts.retain(|k, _| !keys.contains(k)); + drop(trusts); let mut c = cache().lock().unwrap_or_else(|e| e.into_inner()); let gone: Vec<(u64, u64)> = c.map.keys().filter(|k| keys.contains(&k.0)).copied().collect(); for k in gone { @@ -313,6 +365,7 @@ pub fn forget(keys: impl IntoIterator) { /// Drop every cached segmentation (Edit ▸ Purge). pub fn purge() { + trust_cache().lock().unwrap_or_else(|e| e.into_inner()).clear(); let mut c = cache().lock().unwrap_or_else(|e| e.into_inner()); c.map.clear(); c.order.clear(); @@ -333,6 +386,16 @@ pub fn layer_grid(buf: &Buf, layer_size: [f64; 2]) -> Image { img } +/// What a tracking pass found. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TrackSummary { + pub frames: usize, + /// Frames where the object was absent. + pub occluded: usize, + pub min_confidence: f32, +} + /// Everything needed to compute an instance's segmentations. pub struct Chain { pub data: Arc, @@ -341,13 +404,113 @@ pub struct Chain { pub refine_radius: f64, /// The trained model (Version 2.0 / 3.0 with one installed). pub model: Option>, + /// The saved tracking, while it applies (see [`TrackCache`]). + pub(crate) saved: Option, + /// Show Object: the matte of this object only (its id plus one; 0 shows all of them together). + pub show_object: u32, } impl Chain { pub fn new(params: &Params, layer_size: [f64; 2], fps: f64, scale: f64) -> Chain { let data = data(params); let keys = data.chain_keys(seed(params, layer_size, fps)); - Chain { refine_radius: data.refine_radius(), data, keys, opts: seg_opts(params, scale), model: model_for(params) } + let saved = saved_for(params, &data, layer_size, fps); + let show_object = f(params, "rotoBrushMatte/object").round().clamp(0.0, 32.0) as u32; + Chain { refine_radius: data.refine_radius(), data, keys, opts: seg_opts(params, scale), model: model_for(params), saved, show_object } + } + + /// Frame `f`'s segmentation at `scale` from the saved tracking or the caches; computes nothing. + pub fn cached_seg(&self, f: i64, scale: f64) -> Option> { + self.saved_seg(f, scale).or_else(|| self.keys.get(&f).and_then(|k| cached(*k, scale))) + } + + /// Object `o`'s matte of frame `f` at this chain's scale (saved tracking, else this session's), when the instance + /// has several objects and the tracker followed them there; computes nothing. + pub fn object_seg(&self, f: i64, o: u32) -> Option> { + let sc = self.opts.scale; + // A frame the saved tracking has is read from it, under the keys it was made with: the model chosen now + // may not be the one that tracked it, and its own cache must not hold another model's matte. + let saved = self.saved.as_ref().filter(|t| t.cache.frames.contains_key(&f)).and_then(|t| t.keys.get(&f).map(|k| (t, *k))); + let k = match saved { + Some((_, saved_key)) => saved_key, + None => *self.keys.get(&f)?, + }; + let ok = object_key(k, o); + if let Some(s) = cached(ok, sc) { + return Some(s); + } + let full = match cached(ok, 1.0) { + Some(s) => s, + None => { + let (t, _) = saved?; + let s = Arc::new(decode_object(&t.cache, f, o)?); + store(ok, 1.0, s.clone()); + s + } + }; + if sc == 1.0 { + return Some(full); + } + let s = Arc::new(downscale(&full, sc)); + store(ok, sc, s.clone()); + Some(s) + } + + /// What Show Object asks for at frame `f`: `None` shows the whole matte (all objects, a single object, or a frame + /// the tracker did not follow); a missing object on a tracked frame shows nothing. + fn shown_object(&self, f: i64) -> Option> { + let o = self.show_object.checked_sub(1)?; + let objects = self.data.objects(); + if objects.len() < 2 { + return None; + } + if objects.contains(&o) + && let Some(seg) = self.object_seg(f, o) + { + return Some(seg); + } + let sc = self.opts.scale; + let tracked = self.saved_seg(f, sc).or_else(|| { + let key = tracked_key(*self.keys.get(&f)?); + cached(key, sc).or_else(|| cached(key, 1.0).map(|seg| Arc::new(downscale(&seg, sc)))) + })?; + Some(Arc::new(FrameSeg::empty(tracked.w, tracked.h))) + } + + /// The tracker's trust in frame `f`: from the saved tracking, else from this session's tracking. + pub fn trust_at(&self, f: i64) -> Option { + if let Some(fr) = self.saved.as_ref().and_then(|t| t.cache.frames.get(&f)) { + return Some(FrameTrust { confidence: fr.c, occluded: fr.o }); + } + trust(*self.keys.get(&f)?) + } + + /// The model the saved tracking was made with, while it applies. + pub fn saved_model(&self) -> Option<&str> { + self.saved.as_ref().map(|t| t.cache.model.as_str()) + } + + /// Frame `f` of the saved tracking at `scale` (decoded once, then cached). + fn saved_seg(&self, f: i64, scale: f64) -> Option> { + let t = self.saved.as_ref()?; + let k = tracked_key(*t.keys.get(&f)?); + if let Some(s) = cached(k, scale) { + return Some(s); + } + let full = match cached(k, 1.0) { + Some(s) => s, + None => { + let s = Arc::new(decode_tracked(&t.cache, f)?); + store(k, 1.0, s.clone()); + s + } + }; + if scale == 1.0 { + return Some(full); + } + let s = Arc::new(downscale(&full, scale)); + store(k, scale, s.clone()); + Some(s) } /// Frame `f`'s segmentation at this chain's scale: cached, or computed from the nearest @@ -356,37 +519,98 @@ impl Chain { pub fn ensure(&self, f: i64, frame: &mut dyn FnMut(i64) -> Option>, cancel: &dyn Fn() -> bool) -> Option> { let sc = self.opts.scale; let lookup = |g: i64| -> Option> { + // Show Object: one object's matte, where the tracker followed the objects. + if let Some(s) = self.shown_object(g) { + return Some(s); + } + // A frame the tracker followed wins over the classic engine's: the saved tracking, else + // this session's. + if let Some(s) = self.saved_seg(g, sc) { + return Some(s); + } let k = *self.keys.get(&g)?; + if let Some(s) = cached(tracked_key(k), sc) { + return Some(s); + } if let Some(s) = cached(k, sc).or_else(|| from_store(k, sc)) { return Some(s); } if sc != 1.0 { - let full = cached(k, 1.0).or_else(|| from_store(k, 1.0))?; + let full = cached(tracked_key(k), 1.0).or_else(|| cached(k, 1.0)).or_else(|| from_store(k, 1.0))?; let s = Arc::new(downscale(&full, sc)); store(k, sc, s.clone()); return Some(s); } None }; + self.carry(f, None, frame, cancel, &lookup, &mut |g, seg| { + let k = *self.keys.get(&g)?; + store(k, sc, seg.clone()); + if let Some(st) = seg_store() { + st.put_seg(k, sc, seg); + } + Some(()) + }) + } + + /// [`Chain::ensure`]'s walk: the matte carried to `f` from the nearest frame towards the base that `lookup` has, + /// else from the root cut afresh from its strokes. `object: None` carries every stroke from the base frame (the shown + /// matte); `Some(o)` carries only `o`'s, from its foreground-stroked frame nearest the base on the way. `keep` + /// gets every frame made on the way. + fn carry( + &self, + f: i64, + object: Option, + frame: &mut dyn FnMut(i64) -> Option>, + cancel: &dyn Fn() -> bool, + lookup: &dyn Fn(i64) -> Option>, + keep: &mut dyn FnMut(i64, &Arc) -> Option<()>, + ) -> Option> { + let strokes = |g: i64| match object { + Some(o) => self.data.strokes_of(g, o), + None => self.data.strokes_at(g), + }; if let Some(s) = lookup(f) { return Some(s); } let base = self.data.base?; + let root = match object { + Some(o) => { + // one pass over the strokes, not one per frame on the way + let on_the_way = |g: i64| (g == base || g.cmp(&base) == f.cmp(&base)) && g.abs_diff(base) <= f.abs_diff(base); + let fg = self.data.strokes.iter().filter(|s| s.object == o && s.kind == StrokeKind::Fg && on_the_way(s.frame)); + fg.map(|s| s.frame).min_by_key(|g| g.abs_diff(base)).unwrap_or(f) + } + None => base, + }; if !self.data.in_span(f) { return None; } - // Walk towards the base to the nearest available frame. + // A new first stroke can still have a valid tracked matte nearer the base. Look for it + // without reading those frames; without one, start afresh at the object's first stroke. let mut path = vec![f]; + let mut untracked_len = 1; let mut g = f; let mut start: Option> = None; - while let Some(src) = self.data.source_of(g) { + while g != base + && let Some(src) = self.data.source_of(g) + { + if cancel() { + return None; + } if let Some(s) = lookup(src) { start = Some(s); break; } path.push(src); + if src == root { + untracked_len = path.len(); + } g = src; } + if start.is_none() { + path.truncate(untracked_len); + } path.reverse(); let mut prev_img: Option> = None; let mut cur = start; @@ -395,8 +619,8 @@ impl Chain { return None; } let img = frame(g)?; - let strokes = self.data.strokes_at(g); - let seg = match (&cur, g == base) { + let strokes = strokes(g); + let seg = match (&cur, object.is_none() && g == root) { (_, true) | (None, _) => rb::segment_with(&img, &strokes, None, &self.opts, self.refine_radius, self.model.as_deref()), (Some(p), false) => { let src = self.data.source_of(g)?; @@ -407,25 +631,256 @@ impl Chain { if pimg.width != img.width || pimg.height != img.height { return None; } - rb::propagate_with(&pimg, p, &img, &strokes, &self.opts, self.refine_radius, self.model.as_deref()) + // A tracker is too slow to prompt on every frame (its pass is `track`); the classic flow carries the rest. + rb::propagate_with(&pimg, p, &img, &strokes, &self.opts, self.refine_radius, if self.has_tracker() { None } else { self.model.as_deref() }) } }; let seg = Arc::new(seg); - let k = *self.keys.get(&g)?; - store(k, sc, seg.clone()); - if let Some(st) = seg_store() { - st.put_seg(k, sc, &seg); - } + keep(g, &seg)?; cur = Some(seg); prev_img = Some(img); } cur } - /// Whether frame `f` is computed (at full resolution or at `scale`). + /// Whether frame `f` is computed (at full resolution or at `scale`): in this session's caches, + /// or in the tracking saved with the project, which needs no work to be used even before it has + /// been decoded (and whichever model Settings choose now). pub fn is_cached(&self, f: i64, scale: f64) -> bool { - self.keys.get(&f).is_some_and(|k| cached(*k, 1.0).is_some() || cached(*k, scale).is_some()) + self.keys.get(&f).is_some_and(|k| [*k, tracked_key(*k)].iter().any(|k| cached(*k, 1.0).is_some() || cached(*k, scale).is_some())) + || self.saved.as_ref().is_some_and(|t| t.cache.frames.contains_key(&f) && t.keys.contains_key(&f)) } + + /// Whether the tracking saved with the project has frame `f` (and applies to this chain). + pub fn saved_has(&self, f: i64) -> bool { + self.saved.as_ref().is_some_and(|t| t.cache.frames.contains_key(&f) && t.keys.contains_key(&f)) + } + + /// Whether the chosen model can follow an object through a clip (SAM 2). + pub fn has_tracker(&self) -> bool { + self.model.as_ref().is_some_and(|m| m.tracker().is_some()) + } + + /// Explicit propagation replaces tracking made by another model. Rendering and Freeze keep + /// using saved mattes even when that model is unavailable, so this is only called for Propagate. + pub fn prepare_propagation(&mut self) { + if self.saved_model().is_some_and(|saved| self.model.as_ref().is_none_or(|model| model.info().id != saved)) { + self.saved = None; + } + } + + /// Discard a failed track completely before falling back to the classic engine. + pub fn forget_tracking(&mut self) { + let mut objects = self.data.objects(); + if let Some(saved) = &self.saved { + objects.extend(saved.cache.objects.keys().copied()); + objects.sort_unstable(); + objects.dedup(); + } + let keys = self.keys.values().chain(self.saved.iter().flat_map(|saved| saved.keys.values())).copied(); + forget(keys.flat_map(|key| std::iter::once(key).chain(objects.iter().copied().map(move |object| object_key(key, object))))); + self.saved = None; + self.model = None; + } + + /// Follow the object through the span with the model's tracker, from the strokes on their + /// frames (only the frames without a tracked result yet: after a correction stroke, the frames it + /// changes, tracked on from the nearest prompted frame), and cache every frame's segmentation (under the tracker's keys, with its + /// confidence). The strokes stay hard constraints and the edges are refined at full + /// resolution; frames the tracker can't give are left to [`Chain::ensure`]'s classic engine. + /// `progress` gets the number of frames finished. `Ok(None)`: no tracker, nothing done. + pub fn track( + &self, + frame: &mut dyn FnMut(i64) -> Option>, + cancel: &dyn Fn() -> bool, + progress: &mut dyn FnMut(usize), + ) -> Result, String> { + self.track_frames(None, frame, cancel, progress) + } + + /// [`Chain::track`] limited to `wanted` frames (a one-direction Propagate): the window, the work and the + /// progress count cover only those frames of the span. + pub fn track_frames( + &self, + wanted: Option<&[i64]>, + frame: &mut dyn FnMut(i64) -> Option>, + cancel: &dyn Fn() -> bool, + progress: &mut dyn FnMut(usize), + ) -> Result, String> { + self.track_with_pending_limit(wanted, frame, cancel, progress, pending::MAX_PENDING_BYTES) + } + + fn track_with_pending_limit( + &self, + wanted: Option<&[i64]>, + frame: &mut dyn FnMut(i64) -> Option>, + cancel: &dyn Fn() -> bool, + progress: &mut dyn FnMut(usize), + max_pending_bytes: usize, + ) -> Result, String> { + let Some(tracker) = self.model.as_ref().and_then(|m| m.tracker()) else { return Ok(None) }; + let (Some(base), true) = (self.data.base, self.data.span[1] >= self.data.span[0]) else { return Err("no base frame".into()) }; + // Frames of the span that already have a tracked result (this session's, or the saved + // tracking) are kept: only the rest is tracked. A correction stroke changes the keys of the + // frames from it on (both directions), so only those come back here. + let (lo_span, hi_span) = (self.data.span[0], self.data.span[1]); + let needed: Vec = (lo_span..=hi_span) + .filter(|f| wanted.is_none_or(|w| w.contains(f))) + .filter(|f| self.keys.get(f).is_some_and(|k| self.saved_seg(*f, self.opts.scale).is_none() && cached(tracked_key(*k), self.opts.scale).is_none())) + .collect(); + let (Some(&need_lo), Some(&need_hi)) = (needed.first(), needed.last()) else { + return Ok(Some(TrackSummary::default())); + }; + // Each object has its own prompts: a few clicks from its strokes and a box saying how far it reaches (clicks + // alone pick a part of the object). SAM 2 fills the box it is given, so the box surrounds the object's matte + // on that frame: the one shown for a lone object; with several, each object's own, carried with its own strokes + // as the shown one is (from its first prompted frame, or the nearest tracked or prompted frame), so a correction stroke on + // part of an object still boxes all of it. Those per-object mattes are not cached: their keys mean tracked results. + let objects = self.data.objects(); + let several = objects.len() > 1; + let mut by_object: Vec> = vec![]; + for &obj in &objects { + let mut prompts = vec![]; + let mut carried: Option<(i64, Arc)> = None; + // from the base outwards on each side, so each carry goes on from the one before + let mut frames: Vec = self.data.stroke_frames().into_iter().filter(|f| self.data.in_span(*f)).collect(); + frames.sort_by_key(|f| (*f < base, f.abs_diff(base))); + for f in frames { + let strokes = self.data.strokes_of(f, obj); + if !strokes.iter().any(|s| s.kind == StrokeKind::Fg) { + continue; + } + let own = if several { + // a later correction frame carries on from the one before it (only the last can be on its way) + let lookup = |g| self.object_seg(g, obj).or_else(|| carried.as_ref().filter(|(k, _)| *k == g).map(|(_, s)| s.clone())); + let own = self.carry(f, Some(obj), &mut *frame, cancel, &lookup, &mut |_, _| Some(())); + carried = own.clone().map(|s| (f, s)); + own + } else { + self.ensure(f, &mut *frame, cancel) + }; + let matte = match own { + Some(own) => own, + None if cancel() => return Err("tracking cancelled".into()), + None => match frame(f) { + Some(img) => Arc::new(rb::segment_with(&img, &strokes, None, &self.opts, self.refine_radius, self.model.as_deref())), + None => return Err(format!("cannot read Roto Brush prompt frame {f}")), + }, + }; + let p = rb::tracker_prompt_boxed(&strokes, self.opts.scale, &matte); + if p.points.iter().any(|(_, fg)| *fg) { + prompts.push((f, p)); + } + } + prompts.sort_by_key(|q| q.0); + by_object.push(prompts); + } + // objects without a foreground prompt cannot be followed + let followed: Vec<(u32, Vec<(i64, effectcraft_segment::Prompt)>)> = objects.into_iter().zip(by_object).filter(|(_, p)| !p.is_empty()).collect(); + if followed.is_empty() { + return Err("no foreground strokes to follow".into()); + } + // The window to track: every object restarts at the nearest of its prompted frames at or before the first + // frame needed (a correction restarts the object there), so the window starts at the earliest restart; it + // ends at the last frame needed, reaching the farthest first prompt when an object's needed frames lie + // before its prompt. + let lo = followed.iter().map(|(_, p)| p.iter().map(|q| q.0).filter(|f| *f <= need_lo).max().unwrap_or(need_lo)).min().unwrap_or(need_lo); + let first_prompt = followed.iter().filter_map(|(_, p)| p.first().map(|q| q.0)).max().unwrap_or(need_hi); + let hi = need_hi.max(first_prompt); + let objects: Vec = followed.iter().map(|(o, _)| *o).collect(); + let prompts: Vec> = + followed.into_iter().map(|(_, p)| p.into_iter().filter(|q| q.0 >= lo && q.0 <= hi).collect()).collect(); + let req = TrackRequest { first: lo, count: (hi - lo + 1) as usize, objects: prompts }; + let frame = std::cell::RefCell::new(frame); + let mut summary = TrackSummary::default(); + // A frame is finished when every object has reported it (the objects' mattes are united). + let mut pending = pending::PendingMasks::new(objects.len(), max_pending_bytes); + let finish = |g: i64, parts: Vec>, summary: &mut TrackSummary, progress: &mut dyn FnMut(usize)| { + if !wanted.is_none_or(|w| w.contains(&g)) { + return; + } + let (Some(key), Some(img)) = (self.keys.get(&g).copied(), (frame.borrow_mut())(g)) else { return }; + let mut segs: Vec<(u32, effectcraft_track::roto::FrameSeg)> = vec![]; + let (mut confidence, mut present) = (f32::INFINITY, false); + let mut worst = f32::INFINITY; + for (obj, t) in objects.iter().zip(parts) { + let Some(t) = t else { continue }; + let strokes = self.data.strokes_of(g, *obj); + segs.push((*obj, rb::segment_tracked(&img, &strokes, t.prob, t.occluded, &self.opts, self.refine_radius))); + worst = worst.min(t.confidence); + if !t.occluded { + present = true; + confidence = confidence.min(t.confidence); + } + } + if segs.is_empty() { + return; + } + // a frame is occluded when every object is absent; otherwise the doubtful object sets the confidence + let (occluded, confidence) = (!present, if present { confidence } else { worst }); + // with several objects each keeps its own matte too (Show Object, the per-object overlay) + let mut parts = Vec::with_capacity(segs.len()); + for (obj, seg) in segs { + if several { + store(object_key(key, obj), self.opts.scale, Arc::new(seg.clone())); + } + parts.push(seg); + } + store(tracked_key(key), self.opts.scale, Arc::new(effectcraft_track::roto::FrameSeg::union(parts))); + trust_cache().lock().unwrap_or_else(|e| e.into_inner()).insert(tracked_key(key), FrameTrust { confidence, occluded }); + summary.frames += 1; + summary.occluded += usize::from(occluded); + summary.min_confidence = if summary.frames == 1 { confidence } else { summary.min_confidence.min(confidence) }; + progress(summary.frames); + }; + let pending_failed = std::cell::Cell::new(false); + let mut pending_error = None; + let result = tracker.track( + &req, + &mut |g| { + let img = (frame.borrow_mut())(g)?; + Some(Arc::new(ClipFrame { rgb: rb::rgb_of(&img), w: img.width as usize, h: img.height as usize })) + }, + &|| cancel() || pending_failed.get(), + &mut |o, g, t| { + // A tracker that reports object-by-object can otherwise retain a full clip of + // probability maps. Stop buffering immediately, even if it ignores cancellation. + if pending_failed.get() { + return; + } + match pending.insert(o, g, t) { + Ok(Some(parts)) => finish(g, parts, &mut summary, progress), + Ok(None) => {} + Err(e) => { + pending_error = Some(e); + pending_failed.set(true); + } + } + }, + ); + if let Some(e) = pending_error { + return Err(e); + } + result?; + // frames some object never reported (it was not followed there): united from the rest + for (g, parts) in pending.into_incomplete() { + finish(g, parts, &mut summary, progress); + } + Ok(Some(summary)) + } +} + +/// Each tracked object's matte of layer frame `f` in a multi-object instance (full resolution; computes +/// nothing). Show Object limits it to the one chosen. Empty with fewer than two objects in the instance. +pub fn object_mattes(params: &Params, layer_size: [f64; 2], fps: f64, f: i64) -> Vec<(u32, Arc)> { + let mut chain = Chain::new(params, layer_size, fps, 1.0); + let objects = chain.data.objects(); + if objects.len() < 2 || !chain.data.in_span(f) { + return vec![]; + } + let shown = chain.show_object.checked_sub(1); + chain.show_object = 0; + objects.into_iter().filter(|o| shown.is_none_or(|s| s == *o)).filter_map(|o| chain.object_seg(f, o).map(|s| (o, s))).collect() } /// Nearest-neighbour resampling of a segmentation to another scale. @@ -434,7 +889,7 @@ pub fn downscale(s: &FrameSeg, scale: f64) -> FrameSeg { resample(s, w, h) } -pub(crate) fn resample(s: &FrameSeg, w: usize, h: usize) -> FrameSeg { +pub fn resample(s: &FrameSeg, w: usize, h: usize) -> FrameSeg { if s.w == w && s.h == h { return s.clone(); } @@ -513,6 +968,9 @@ pub fn frozen_key(params: &Params, layer_size: [f64; 2], fps: f64) -> String { fnv(&mut h, &k.to_le_bytes()); } fnv(&mut h, format!("{:?}", matte_params(params)).as_bytes()); + if ch.show_object != 0 { + fnv(&mut h, &ch.show_object.to_le_bytes()); + } format!("{h:016x}") } @@ -544,6 +1002,183 @@ fn frozen(params: &Params) -> Option> { Some(d) } +// ------------------------------------------------------------------ saved tracking + +/// The tracker's frames of an instance, saved with the project (the hidden [`TRACKED`] parameter, +/// as [`FrozenCache`] is). They apply while `key` matches the instance's inputs ([`tracks_key`]), +/// whichever model Settings choose: a stroke or span change makes them stale, and the next Propagate +/// replaces them (with the classic engine, that leaves none). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TrackCache { + /// What the frames were tracked from ([`tracks_key`]). + pub key: String, + /// The tracker's model id. + pub model: String, + /// The mattes' width and height (the layer's size). + pub size: [usize; 2], + pub frames: BTreeMap, + /// With several objects: each one's matte of every tracked frame (object id, then frame). + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub objects: BTreeMap>, +} + +/// One object's matte and Refine Edge band of a tracked frame (RLE, base64). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct ObjectFrame { + pub m: String, + pub r: String, +} + +/// One tracked frame: its matte and Refine Edge band (RLE, base64), its colour models and the +/// tracker's trust in it. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct TrackFrame { + /// Matte (RLE, base64). + pub m: String, + /// Refine Edge band (RLE, base64). + pub r: String, + pub fg: Gmm, + pub bg: Gmm, + /// 0–1: the tracker's confidence. + pub c: f32, + /// The object is not in the frame. + pub o: bool, +} + +impl TrackCache { + pub fn from_json(s: &str) -> Option { + if s.trim().is_empty() { + return None; + } + serde_json::from_str(s).ok() + } + pub fn to_json(&self) -> String { + serde_json::to_string(self).unwrap_or_default() + } +} + +/// What saved tracking depends on: the chain keys of its frames (the inputs and the model). +pub fn tracks_key(keys: &BTreeMap) -> String { + let mut h = 0xcbf2_9ce4_8422_2325; + for (f, k) in keys { + fnv(&mut h, &f.to_le_bytes()); + fnv(&mut h, &k.to_le_bytes()); + } + format!("{h:016x}") +} + +/// The saved tracking of `params`, parsed once per content. +fn saved_tracks(params: &Params) -> Option> { + static C: OnceLock>>> = OnceLock::new(); + let j = s(params, TRACKED); + if j.is_empty() { + return None; + } + let mut h = 0xcbf2_9ce4_8422_2325; + fnv(&mut h, j.as_bytes()); + let c = C.get_or_init(Default::default); + if let Some(d) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&h) { + return Some(d.clone()); + } + let d = Arc::new(TrackCache::from_json(j)?); + let mut m = c.lock().unwrap_or_else(|e| e.into_inner()); + if m.len() > 8 { + m.clear(); + } + m.insert(h, d.clone()); + Some(d) +} + +/// A saved tracking that applies to a chain: its frames, keyed under the model that made them. +pub(crate) struct SavedTracking { + cache: Arc, + keys: BTreeMap, +} + +/// The saved tracking of `params` if it applies: the inputs are the instance's now. It does not depend +/// on which model Settings choose or has loaded, so a project renders the same anywhere. +fn saved_for(params: &Params, data: &RotoData, layer_size: [f64; 2], fps: f64) -> Option { + let cache = saved_tracks(params)?; + let keys = data.chain_keys(seed_with(params, layer_size, fps, &cache.model)); + (tracks_key(&keys) == cache.key).then_some(SavedTracking { cache, keys }) +} + +/// Decode a tracked frame of saved tracking (full resolution). +fn decode_tracked(cache: &TrackCache, f: i64) -> Option { + let fr = cache.frames.get(&f)?; + // The size comes from the project file: checked, so a corrupt one is ignored rather than overflowing. + let [w, h] = cache.size; + let n = w.checked_mul(h)?; + if n == 0 || n > rle::MAX_PLANE_BYTES { + return None; + } + let matte = rle::decode_bounded(&fr.m, n)?; + let refine = rle::decode_bounded(&fr.r, n)?; + (matte.len() == n && refine.len() == n).then(|| FrameSeg { w, h, matte, refine, fg: fr.fg.clone(), bg: fr.bg.clone() }) +} + +/// Object `o`'s matte of a tracked frame of saved tracking (full resolution). +fn decode_object(cache: &TrackCache, f: i64, o: u32) -> Option { + let fr = cache.objects.get(&o)?.get(&f)?; + let [w, h] = cache.size; + let n = w.checked_mul(h)?; + if n == 0 || n > rle::MAX_PLANE_BYTES { + return None; + } + let matte = rle::decode_bounded(&fr.m, n)?; + let refine = rle::decode_bounded(&fr.r, n)?; + (matte.len() == n && refine.len() == n).then(|| FrameSeg { w, h, matte, refine, ..Default::default() }) +} + +/// The tracking a chain has just made, to save with its instance: every frame of its span that the +/// tracker followed, at full resolution, with its trust. `None` when it followed none. +pub fn tracks_of(ch: &Chain) -> Option { + let model = ch.model.as_ref()?.info().id; + // The saved tracking applies to this chain's frames when the same model made it: what it has and this session did + // not track again stays (a project reopened and propagated with nothing new to track must keep its tracking). + let saved = ch.saved.as_ref().filter(|t| t.cache.model == model); + let mut size = saved.map(|t| t.cache.size); + let mut frames = BTreeMap::new(); + for (f, k) in &ch.keys { + if let (Some(seg), Some(t)) = (cached(tracked_key(*k), 1.0), trust(*k)) { + size.get_or_insert([seg.w, seg.h]); + frames.insert( + *f, + TrackFrame { m: rle::encode(&seg.matte), r: rle::encode(&seg.refine), fg: seg.fg.clone(), bg: seg.bg.clone(), c: t.confidence, o: t.occluded }, + ); + } else if let Some(kept) = saved.filter(|t| t.keys.get(f) == Some(k)).and_then(|t| t.cache.frames.get(f)) { + frames.insert(*f, kept.clone()); + } + } + let mut objects: BTreeMap> = BTreeMap::new(); + let ids = ch.data.objects(); + if ids.len() > 1 { + for (f, k) in &ch.keys { + if !frames.contains_key(f) { + continue; + } + for o in &ids { + if let Some(seg) = cached(object_key(*k, *o), 1.0) { + objects.entry(*o).or_default().insert(*f, ObjectFrame { m: rle::encode(&seg.matte), r: rle::encode(&seg.refine) }); + } else if let Some(kept) = saved.filter(|t| t.keys.get(f) == Some(k)).and_then(|t| t.cache.objects.get(o)).and_then(|m| m.get(f)) { + objects.entry(*o).or_default().insert(*f, kept.clone()); + } + } + } + } + let cache = TrackCache { key: tracks_key(&ch.keys), model: model.to_string(), size: size?, frames, objects }; + (!cache.frames.is_empty()).then_some(cache) +} + +/// Whether the tracking saved with `params` covers every one of `frames`: they need no model to be read. +pub fn saved_covers(params: &Params, layer_size: [f64; 2], fps: f64, frames: &[i64]) -> bool { + let chain = Chain::new(params, layer_size, fps, 1.0); + !frames.is_empty() && frames.iter().all(|f| chain.saved_has(*f)) +} + /// Encode one frame for [`FrozenCache`]. pub fn freeze_frame(alpha: &[f32], band: &[u8]) -> FrozenFrame { let a: Vec = alpha.iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8).collect(); @@ -634,7 +1269,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 { @@ -731,8 +1366,8 @@ mod tests { #[test] fn renders_the_segmented_disk_on_demand() { let mut d = RotoData::default(); - d.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 3.0, points: vec![[35.0, 40.0], [45.0, 40.0]] }, 20, [0, 24]); - d.add(Stroke { kind: StrokeKind::Bg, frame: 0, radius: 3.0, points: vec![[5.0, 5.0], [95.0, 5.0], [95.0, 75.0]] }, 20, [0, 24]); + d.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 3.0, points: vec![[35.0, 40.0], [45.0, 40.0]], object: 0 }, 20, [0, 24]); + d.add(Stroke { kind: StrokeKind::Bg, frame: 0, radius: 3.0, points: vec![[5.0, 5.0], [95.0, 5.0], [95.0, 75.0]], object: 0 }, 20, [0, 24]); let params = params_with(&d); let host = Seq; let run = |t: f64| { @@ -776,6 +1411,650 @@ mod tests { assert!(o.data.iter().all(|p| p[3] == 0.0)); } + /// A stand-in tracker for two objects: object 0 is the moving disk, object 1 a fixed square. It records how many + /// objects (and prompts per object) it was asked for. + struct TwoObjects(std::sync::Mutex>>); + impl effectcraft_segment::MaskModel for TwoObjects { + fn info(&self) -> &'static effectcraft_segment::ModelInfo { + &effectcraft_segment::SAM2_TINY + } + fn segment(&self, _: &[[f32; 3]], _: usize, _: usize, _: &effectcraft_segment::Prompt) -> effectcraft_segment::Result> { + Err("no weights".into()) + } + fn tracker(&self) -> Option<&dyn effectcraft_segment::ObjectTracker> { + Some(self) + } + } + impl effectcraft_segment::ObjectTracker for TwoObjects { + fn track( + &self, + req: &effectcraft_segment::TrackRequest, + frame: &mut dyn FnMut(i64) -> Option>, + _: &dyn Fn() -> bool, + out: &mut dyn FnMut(usize, i64, effectcraft_segment::TrackedFrame), + ) -> effectcraft_segment::Result<()> { + self.0.lock().unwrap().push(req.objects.iter().map(Vec::len).collect()); + for g in req.first..req.first + req.count as i64 { + let img = frame(g).ok_or("no frame")?; + let c = [40.0 + 2.0 * g as f64, 40.0]; + // the objects report in reverse order on odd frames: the union must not depend on it + let order: Vec = if g % 2 == 1 { (0..req.objects.len()).rev().collect() } else { (0..req.objects.len()).collect() }; + for o in order { + let prob = (0..img.w * img.h) + .map(|i| { + let (x, y) = ((i % img.w) as f64 + 0.5, (i / img.w) as f64 + 0.5); + let inside = if o == 0 { (x - c[0]).hypot(y - c[1]) <= 15.0 } else { (8.0..22.0).contains(&x) && (8.0..22.0).contains(&y) }; + f32::from(inside) + }) + .collect(); + out(o, g, effectcraft_segment::TrackedFrame { prob, w: img.w, h: img.h, confidence: if o == 0 { 0.9 } else { 0.4 }, occluded: false }); + } + } + Ok(()) + } + } + + /// The ordering SAM 2 uses when objects are first prompted on different frames: each object + /// reports its entire span before the next. No weights or large allocations are needed. + #[derive(Default)] + struct SeparatePasses { + emitted: std::sync::atomic::AtomicUsize, + cancelled: std::sync::atomic::AtomicBool, + } + + impl effectcraft_segment::MaskModel for SeparatePasses { + fn info(&self) -> &'static effectcraft_segment::ModelInfo { + &effectcraft_segment::SAM2_TINY + } + fn segment(&self, _: &[[f32; 3]], _: usize, _: usize, _: &effectcraft_segment::Prompt) -> effectcraft_segment::Result> { + Err("no weights".into()) + } + fn tracker(&self) -> Option<&dyn effectcraft_segment::ObjectTracker> { + Some(self) + } + } + + impl effectcraft_segment::ObjectTracker for SeparatePasses { + fn track( + &self, + req: &effectcraft_segment::TrackRequest, + frames: &mut dyn FnMut(i64) -> Option>, + cancel: &dyn Fn() -> bool, + out: &mut dyn FnMut(usize, i64, effectcraft_segment::TrackedFrame), + ) -> effectcraft_segment::Result<()> { + use std::sync::atomic::Ordering::Relaxed; + assert_ne!(req.objects[0][0].0, req.objects[1][0].0); + for o in 0..req.objects.len() { + for g in req.first..req.first + req.count as i64 { + if cancel() { + self.cancelled.store(true, Relaxed); + return Err("tracking cancelled".into()); + } + let img = frames(g).ok_or("no frame")?; + self.emitted.fetch_add(1, Relaxed); + out(o, g, effectcraft_segment::TrackedFrame { prob: vec![0.5; img.w * img.h], w: img.w, h: img.h, confidence: 0.9, occluded: false }); + } + } + Ok(()) + } + } + + #[test] + fn a_single_followed_object_keeps_its_matte_in_a_multi_object_instance() { + for outside_span in [false, true] { + let mut data = RotoData::default(); + data.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 3.0, points: vec![[35.0, 40.0]], object: 0 }, 2, [0, 5]); + data.add(Stroke { kind: StrokeKind::Fg, frame: if outside_span { 5 } else { 0 }, radius: 3.0, points: vec![[85.0, 70.0]], object: 1 }, 2, [0, 5]); + if !outside_span { + data.add(Stroke { kind: StrokeKind::Bg, frame: 0, radius: 8.0, points: vec![[85.0, 70.0]], object: 1 }, 2, [0, 5]); + } + data.span = [0, 2]; + assert_eq!(data.objects(), [0, 1]); + let mut params = params_with(&data); + params.values.insert(INPUT_KEY.into(), Value::Str(format!("one-followed-object-{outside_span}"))); + let mut chain = Chain::new(¶ms, [100.0, 80.0], 25.0, 1.0); + chain.model = Some(Arc::new(TwoObjects(Default::default()))); + chain.keys = data.chain_keys(seed_with(¶ms, [100.0, 80.0], 25.0, effectcraft_segment::SAM2_TINY.id)); + let mut get = |f: i64| Some(Arc::new(frame(f as f64 / 25.0))); + assert_eq!(chain.track(&mut get, &|| false, &mut |_| {}).unwrap().unwrap().frames, 3); + let matte = chain.object_seg(1, 0).expect("the followed object keeps its own matte"); + assert!(matte.area() > 0); + assert!(chain.object_seg(1, 1).is_none(), "the unprompted object has no invented matte"); + chain.show_object = 1; + assert_eq!(chain.ensure(1, &mut get, &|| false).unwrap().matte, matte.matte); + let saved = tracks_of(&chain).unwrap(); + assert_eq!(saved.objects.get(&0).unwrap().len(), 3); + assert!(!saved.objects.contains_key(&1)); + params.values.insert(TRACKED.into(), Value::Str(saved.to_json())); + params.values.insert("rotoBrushMatte/object".into(), Value::Scalar(1.0)); + forget(chain.keys.values().map(|key| object_key(*key, 0))); + let reopened = Chain::new(¶ms, [100.0, 80.0], 25.0, 1.0); + assert_eq!(reopened.object_seg(1, 0).unwrap().matte, matte.matte); + assert_eq!(reopened.ensure(1, &mut |_| None, &|| false).unwrap().matte, matte.matte); + let overlays = object_mattes(¶ms, [100.0, 80.0], 25.0, 1); + assert_eq!(overlays.len(), 1); + assert_eq!((overlays[0].0, overlays[0].1.area()), (0, matte.area())); + } + } + + /// A model that sees only the left part of the disk, and records the tracker's box prompts. + #[derive(Default)] + struct LeftPart(std::sync::Mutex)>>); + impl effectcraft_segment::MaskModel for LeftPart { + fn info(&self) -> &'static effectcraft_segment::ModelInfo { + &effectcraft_segment::SAM2_TINY + } + fn segment(&self, _: &[[f32; 3]], w: usize, h: usize, _: &effectcraft_segment::Prompt) -> effectcraft_segment::Result> { + Ok((0..w * h).map(|i| f32::from((26..36).contains(&(i % w)) && (30..50).contains(&(i / w)))).collect()) + } + fn tracker(&self) -> Option<&dyn effectcraft_segment::ObjectTracker> { + Some(self) + } + } + impl effectcraft_segment::ObjectTracker for LeftPart { + fn track( + &self, + req: &effectcraft_segment::TrackRequest, + _: &mut dyn FnMut(i64) -> Option>, + _: &dyn Fn() -> bool, + _: &mut dyn FnMut(usize, i64, effectcraft_segment::TrackedFrame), + ) -> effectcraft_segment::Result<()> { + *self.0.lock().unwrap() = req.objects.iter().flatten().map(|(f, p)| (*f, p.bbox)).collect(); + Ok(()) + } + } + + #[test] + fn the_tracker_box_surrounds_the_matte_shown_on_each_stroked_frame() { + let mut data = RotoData::default(); + data.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 3.0, points: vec![[30.0, 40.0]], object: 0 }, 3, [0, 3]); + data.add(Stroke { kind: StrokeKind::Fg, frame: 2, radius: 3.0, points: vec![[50.0, 40.0]], object: 0 }, 3, [0, 3]); + data.span = [0, 3]; + let mut params = params_with(&data); + params.values.insert(INPUT_KEY.into(), Value::Str("tracker-box-from-shown-matte".into())); + let mut chain = Chain::new(¶ms, [100.0, 80.0], 25.0, 1.0); + let fake = Arc::new(LeftPart::default()); + chain.model = Some(fake.clone()); + chain.keys = data.chain_keys(seed_with(¶ms, [100.0, 80.0], 25.0, effectcraft_segment::SAM2_TINY.id)); + let mut get = |f: i64| Some(Arc::new(frame(f as f64 / 25.0))); + let expected: Vec<(i64, Option<[f32; 4]>)> = [0, 2] + .into_iter() + .map(|f| (f, rb::tracker_prompt_boxed(&data.strokes_of(f, 0), 1.0, &chain.ensure(f, &mut get, &|| false).expect("a shown matte")).bbox)) + .collect(); + assert!(expected.iter().all(|(_, b)| b.is_some())); + assert!(expected[0].1.unwrap()[2] < 44.0, "the base frame shows the model's part of the disk: {expected:?}"); + chain.track(&mut get, &|| false, &mut |_| {}).unwrap(); + assert_eq!(*fake.0.lock().unwrap(), expected, "the base frame and the correction frame are boxed around what they show"); + } + + /// A model that cuts only a small square around each foreground click, and records every object's box prompts. + #[derive(Default)] + struct ClickSquares(std::sync::Mutex)>>>); + impl effectcraft_segment::MaskModel for ClickSquares { + fn info(&self) -> &'static effectcraft_segment::ModelInfo { + &effectcraft_segment::SAM2_TINY + } + fn segment(&self, _: &[[f32; 3]], w: usize, h: usize, p: &effectcraft_segment::Prompt) -> effectcraft_segment::Result> { + let near = |x: f32, y: f32| p.points.iter().any(|(c, fg)| *fg && (c[0] - x).abs() <= 4.0 && (c[1] - y).abs() <= 4.0); + Ok((0..w * h).map(|i| f32::from(near((i % w) as f32 + 0.5, (i / w) as f32 + 0.5))).collect()) + } + fn tracker(&self) -> Option<&dyn effectcraft_segment::ObjectTracker> { + Some(self) + } + } + impl effectcraft_segment::ObjectTracker for ClickSquares { + fn track( + &self, + req: &effectcraft_segment::TrackRequest, + _: &mut dyn FnMut(i64) -> Option>, + _: &dyn Fn() -> bool, + _: &mut dyn FnMut(usize, i64, effectcraft_segment::TrackedFrame), + ) -> effectcraft_segment::Result<()> { + *self.0.lock().unwrap() = req.objects.iter().map(|o| o.iter().map(|(f, p)| (*f, p.bbox)).collect()).collect(); + Ok(()) + } + } + + #[test] + fn a_correction_stroke_on_part_of_one_of_several_objects_boxes_the_whole_object() { + let mut data = RotoData::default(); + data.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 3.0, points: vec![[30.0, 40.0], [50.0, 40.0]], object: 0 }, 3, [0, 3]); + data.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 3.0, points: vec![[88.0, 72.0]], object: 1 }, 3, [0, 3]); + data.add(Stroke { kind: StrokeKind::Fg, frame: 2, radius: 2.0, points: vec![[54.0, 40.0]], object: 0 }, 3, [0, 3]); + data.span = [0, 3]; + let mut params = params_with(&data); + params.values.insert(INPUT_KEY.into(), Value::Str("object-correction-box".into())); + let mut chain = Chain::new(¶ms, [100.0, 80.0], 25.0, 1.0); + let fake = Arc::new(ClickSquares::default()); + chain.model = Some(fake.clone()); + chain.keys = data.chain_keys(seed_with(¶ms, [100.0, 80.0], 25.0, effectcraft_segment::SAM2_TINY.id)); + chain.track(&mut |f| Some(Arc::new(frame(f as f64 / 25.0))), &|| false, &mut |_| {}).unwrap(); + let boxes = fake.0.lock().unwrap().clone(); + let box_of = |o: usize, f: i64| boxes[o].iter().find(|(g, _)| *g == f).and_then(|(_, b)| *b).expect("a box prompt"); + let (base, correction, other) = (box_of(0, 0), box_of(0, 2), box_of(1, 0)); + assert!(correction[0] <= base[0] + 6.0, "the correction frame boxes the object carried from frame 0 {base:?}, not only the stroke: {correction:?}"); + assert!(correction[2] >= 54.0 && correction[2] - correction[0] >= base[2] - base[0], "the box holds the whole object and the stroke: {correction:?}"); + assert!(correction[2] < other[0], "object 1 {other:?} stays out of object 0's box {correction:?}"); + } + + #[test] + fn a_correction_after_propagate_carries_each_object_on_from_its_tracked_matte() { + let mut data = RotoData::default(); + data.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 3.0, points: vec![[30.0, 40.0]], object: 0 }, 3, [0, 3]); + data.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 3.0, points: vec![[15.0, 15.0]], object: 1 }, 3, [0, 3]); + data.span = [0, 3]; + let mut params = params_with(&data); + params.values.insert(INPUT_KEY.into(), Value::Str("object-correction-after-propagate".into())); + let mut chain = Chain::new(¶ms, [100.0, 80.0], 25.0, 1.0); + chain.model = Some(Arc::new(TwoObjects(Default::default()))); + chain.keys = data.chain_keys(seed_with(¶ms, [100.0, 80.0], 25.0, effectcraft_segment::SAM2_TINY.id)); + assert_eq!(chain.track(&mut |f| Some(Arc::new(frame(f as f64 / 25.0))), &|| false, &mut |_| {}).unwrap().unwrap().frames, 4); + data.add(Stroke { kind: StrokeKind::Fg, frame: 2, radius: 2.0, points: vec![[56.0, 40.0]], object: 0 }, 3, [0, 3]); + chain.data = Arc::new(data.clone()); + chain.keys = data.chain_keys(seed_with(¶ms, [100.0, 80.0], 25.0, effectcraft_segment::SAM2_TINY.id)); + let fake = Arc::new(ClickSquares::default()); + chain.model = Some(fake.clone()); + let mut prompt_reads = vec![]; + let mut get = |f: i64| { + if fake.0.lock().unwrap().is_empty() { + prompt_reads.push(f); + } + Some(Arc::new(frame(f as f64 / 25.0))) + }; + chain.track(&mut get, &|| false, &mut |_| {}).unwrap(); + assert_eq!(prompt_reads, [2, 1], "frame 2 carries on from the tracked frame 1; tracked frame 0 boxes both objects as it is"); + let boxes = fake.0.lock().unwrap().clone(); + let correction = boxes[0].iter().find(|(g, _)| *g == 2).and_then(|(_, b)| *b).expect("a box prompt"); + // the tracked disk carried to frame 2 spans x 29..59 + assert!(correction[0] <= 29.0 && correction[2] >= 59.0, "the box holds the whole disk: {correction:?}"); + } + + #[test] + fn a_correction_before_an_objects_first_stroke_keeps_its_tracked_matte() { + for base in [0, 4] { + let first = if base == 0 { 3 } else { 1 }; + let prior = if base == 0 { 1 } else { 3 }; + let mut data = RotoData::default(); + data.add(Stroke { kind: StrokeKind::Fg, frame: base, radius: 3.0, points: vec![[40.0, 40.0]], object: 0 }, 4, [0, 4]); + data.add(Stroke { kind: StrokeKind::Fg, frame: first, radius: 3.0, points: vec![[15.0, 15.0]], object: 1 }, 4, [0, 4]); + data.span = [0, 4]; + let mut params = params_with(&data); + params.values.insert(INPUT_KEY.into(), Value::Str(format!("earlier-object-correction-{base}"))); + let mut chain = Chain::new(¶ms, [100.0, 80.0], 25.0, 1.0); + chain.model = Some(Arc::new(TwoObjects(Default::default()))); + let seed = seed_with(¶ms, [100.0, 80.0], 25.0, effectcraft_segment::SAM2_TINY.id); + chain.keys = data.chain_keys(seed); + let image = |f: i64| { + let mut img = frame(f as f64 / 25.0); + // Match the second object's tracked square, so the carry has a real image edge. + for y in 8..22 { + for x in 8..22 { + img.set(x, y, [0.95, 0.9, 0.1, 1.0]); + } + } + Arc::new(img) + }; + assert_eq!(chain.track(&mut |f| Some(image(f)), &|| false, &mut |_| {}).unwrap().unwrap().frames, 5); + let prior_key = chain.keys[&prior]; + assert!(chain.object_seg(prior, 1).is_some()); + data.add(Stroke { kind: StrokeKind::Fg, frame: 2, radius: 1.0, points: vec![[19.0, 15.0]], object: 1 }, 4, [0, 4]); + chain.data = Arc::new(data.clone()); + chain.keys = data.chain_keys(seed); + assert_eq!(chain.keys[&prior], prior_key, "the nearer tracked frame is still valid"); + let fake = Arc::new(ClickSquares::default()); + chain.model = Some(fake.clone()); + let mut prompt_reads = vec![]; + let mut get = |f: i64| { + if fake.0.lock().unwrap().is_empty() { + prompt_reads.push(f); + } + Some(image(f)) + }; + chain.track(&mut get, &|| false, &mut |_| {}).unwrap(); + assert_eq!(prompt_reads, [2, prior, first, 2], "corrections carry from the valid tracked matte in either direction"); + let boxes = fake.0.lock().unwrap().clone(); + let correction = boxes[1].iter().find(|(g, _)| *g == 2).and_then(|(_, b)| *b).expect("a correction box"); + assert!(correction[0] <= 10.0 && correction[2] >= 22.0, "the box keeps the whole tracked square, not only the correction: {correction:?}"); + assert!(correction[2] < 30.0, "the other object's disk stays outside this box: {correction:?}"); + } + } + + #[test] + fn corrections_before_the_base_carry_on_from_each_other() { + let mut data = RotoData::default(); + for (frame, object) in [(3, 0), (3, 1), (2, 0), (1, 0)] { + data.add(Stroke { kind: StrokeKind::Fg, frame, radius: 3.0, points: vec![[35.0, 40.0]], object }, 3, [0, 3]); + } + data.span = [0, 3]; + assert_eq!(data.base, Some(3)); + let mut params = params_with(&data); + params.values.insert(INPUT_KEY.into(), Value::Str("corrections-before-the-base".into())); + let mut chain = Chain::new(¶ms, [100.0, 80.0], 25.0, 1.0); + let fake = Arc::new(ClickSquares::default()); + chain.model = Some(fake.clone()); + chain.keys = data.chain_keys(seed_with(¶ms, [100.0, 80.0], 25.0, effectcraft_segment::SAM2_TINY.id)); + let mut prompt_reads = vec![]; + let mut get = |f: i64| { + if fake.0.lock().unwrap().is_empty() { + prompt_reads.push(f); + } + Some(Arc::new(frame(f as f64 / 25.0))) + }; + chain.track(&mut get, &|| false, &mut |_| {}).unwrap(); + assert_eq!(prompt_reads, [3, 2, 3, 1, 2, 3], "object 0: the base, then 2 from 3, then 1 from 2; object 1: the base"); + let frames: Vec> = fake.0.lock().unwrap().iter().map(|o| o.iter().map(|q| q.0).collect()).collect(); + assert_eq!(frames, [vec![1, 2, 3], vec![3]], "prompts reach the tracker in frame order"); + } + + #[test] + fn prompt_segmentation_reads_only_frames_with_that_objects_foreground() { + let mut data = RotoData::default(); + for (kind, frame, object) in [(StrokeKind::Fg, 0, 0), (StrokeKind::Refine, 1, 0), (StrokeKind::Fg, 2, 1)] { + data.add(Stroke { kind, frame, radius: 3.0, points: vec![[35.0, 40.0]], object }, 2, [0, 2]); + } + data.span = [0, 2]; + let mut params = params_with(&data); + params.values.insert(INPUT_KEY.into(), Value::Str("foreground-only-prompts".into())); + let mut chain = Chain::new(¶ms, [100.0, 80.0], 25.0, 1.0); + let fake = Arc::new(TwoObjects(Default::default())); + chain.model = Some(fake.clone()); + let mut prompt_reads = vec![]; + let mut get = |f: i64| { + if fake.0.lock().unwrap().is_empty() { + prompt_reads.push(f); + } + Some(Arc::new(frame(f as f64 / 25.0))) + }; + assert_eq!(chain.track(&mut get, &|| false, &mut |_| {}).unwrap().unwrap().frames, 3); + assert_eq!(prompt_reads, [0, 2], "shared Refine Edge and other objects' frames need no prompt graph cut"); + assert_eq!(fake.0.lock().unwrap()[0], [1, 1]); + } + + #[test] + fn separate_object_passes_cancel_at_the_pending_mask_budget() { + use std::sync::atomic::Ordering::Relaxed; + let mut d = RotoData::default(); + for object in 0..2 { + d.add(Stroke { kind: StrokeKind::Fg, frame: i64::from(object), radius: 3.0, points: vec![[35.0, 40.0]], object }, 8, [0, 24]); + } + let mut params = params_with(&d); + params.values.insert(INPUT_KEY.into(), Value::Str("pending-budget-regression".into())); + let mut chain = Chain::new(¶ms, [100.0, 80.0], 25.0, 1.0); + let fake = Arc::new(SeparatePasses::default()); + chain.model = Some(fake.clone()); + let mut get = |f: i64| Some(Arc::new(frame(f as f64 / 25.0))); + let mut completed = 0; + // Each map takes 32,000 bytes; two incomplete frames exceed this small test budget. + let error = chain.track_with_pending_limit(None, &mut get, &|| false, &mut |n| completed = n, 64_000).unwrap_err(); + assert!(error.contains("pending object masks") && error.contains("memory budget"), "{error}"); + assert!(fake.cancelled.load(Relaxed), "the model must see cancellation before allocating the rest of the clip"); + assert_eq!(fake.emitted.load(Relaxed), 2); + assert_eq!(completed, 0, "partial objects are not complete tracked frames"); + } + + /// A cold chain with saved tracking and the same model keeps the saved frames when asked what to save (nothing + /// was tracked this session), and Show Object reads a frame the saved tracking has under its own keys. + #[test] + fn saved_tracking_is_kept_and_its_objects_stay_out_of_another_models_keys() { + let mut d = RotoData::default(); + d.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 3.0, points: vec![[35.0, 40.0]], object: 0 }, 2, [0, 24]); + let mut params = params_with(&d); + params.values.insert(INPUT_KEY.into(), Value::Str("keep-saved-and-objects".into())); + let size = [100.0, 80.0]; + let model = effectcraft_segment::SAM2_TINY.id; + let keys = d.chain_keys(seed_with(¶ms, size, 25.0, model)); + let (matte, empty) = (vec![1u8; 100 * 80], vec![0u8; 100 * 80]); + let frame = TrackFrame { m: rle::encode(&matte), r: rle::encode(&empty), c: 0.9, ..Default::default() }; + let object = ObjectFrame { m: rle::encode(&matte), r: rle::encode(&empty) }; + let saved = TrackCache { + key: tracks_key(&keys), + model: model.into(), + size: [100, 80], + frames: keys.keys().map(|f| (*f, frame.clone())).collect(), + objects: [(1, keys.keys().map(|f| (*f, object.clone())).collect())].into_iter().collect(), + }; + params.values.insert(TRACKED.into(), Value::Str(saved.to_json())); + let mut chain = Chain::new(¶ms, size, 25.0, 1.0); + chain.model = Some(Arc::new(TwoObjects(Default::default()))); + chain.keys = keys.clone(); + let kept = tracks_of(&chain).expect("the saved tracking is kept"); + assert_eq!(kept.frames.keys().collect::>(), saved.frames.keys().collect::>()); + assert_eq!(kept.frames.get(&1), saved.frames.get(&1)); + // another model is chosen now: its keys differ, and the saved object matte is not stored under them + let other = d.chain_keys(seed_with(¶ms, size, 25.0, effectcraft_segment::SAM2_SMALL.id)); + chain.keys = other.clone(); + let seg = chain.object_seg(1, 1).expect("the saved object matte"); + assert_eq!(seg.matte.len(), 100 * 80); + assert!(cached(object_key(other[&1], 1), 1.0).is_none(), "the other model's cache holds none of it"); + for key in [keys[&1], other[&1]] { + for scale in [1.0, 0.5] { + store(tracked_key(key), scale, seg.clone()); + store(object_key(key, 1), scale, seg.clone()); + } + trust_cache().lock().unwrap().insert(tracked_key(key), FrameTrust { confidence: 0.9, occluded: false }); + } + chain.forget_tracking(); + assert!(chain.model.is_none()); + assert!(!chain.saved_has(1)); + for key in [keys[&1], other[&1]] { + for scale in [1.0, 0.5] { + assert!(cached(tracked_key(key), scale).is_none()); + assert!(cached(object_key(key, 1), scale).is_none()); + } + assert!(trust(key).is_none()); + } + assert!(chain.ensure(1, &mut |_| None, &|| false).is_none(), "neither saved nor in-memory tracking survives fallback"); + } + + #[test] + fn showing_a_missing_tracked_object_never_falls_back_to_other_objects() { + let mut d = RotoData::default(); + for object in 0..2 { + d.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 1.0, points: vec![[1.0, 1.0]], object }, 1, [0, 1]); + } + let union = Arc::new(FrameSeg { w: 4, h: 2, matte: vec![255; 8], refine: vec![0; 8], ..Default::default() }); + for saved in [false, true] { + for scale in [1.0, 0.5] { + let mut params = params_with(&d); + params.values.insert("version".into(), Value::Scalar(0.0)); + params.values.insert(INPUT_KEY.into(), Value::Str(format!("missing-object-{saved}-{scale}"))); + let keys = d.chain_keys(seed(¶ms, [4.0, 2.0], 25.0)); + if saved { + let matte = rle::encode(&union.matte); + let refine = rle::encode(&union.refine); + let cache = TrackCache { + key: tracks_key(&keys), + model: effectcraft_segment::CLASSICAL.into(), + size: [4, 2], + frames: [(0, TrackFrame { m: matte.clone(), r: refine.clone(), ..Default::default() })].into_iter().collect(), + objects: [(0, [(0, ObjectFrame { m: matte, r: refine })].into_iter().collect())].into_iter().collect(), + }; + params.values.insert(TRACKED.into(), Value::Str(cache.to_json())); + } else { + store(tracked_key(keys[&0]), 1.0, union.clone()); + store(object_key(keys[&0], 0), 1.0, union.clone()); + } + let mut chain = Chain::new(¶ms, [4.0, 2.0], 25.0, scale); + let expected = downscale(&union, scale); + for object in [2, 7] { + chain.show_object = object; + let shown = chain.ensure(0, &mut |_| None, &|| false).expect("tracked frame"); + assert_eq!((shown.w, shown.h, shown.area()), (expected.w, expected.h, 0), "saved={saved}, scale={scale}, object={object}"); + } + assert!(chain.shown_object(1).is_none(), "untracked frames keep their existing fallback"); + for object in [0, 1] { + chain.show_object = object; + assert_eq!(chain.ensure(0, &mut |_| None, &|| false).unwrap().matte, expected.matte); + } + } + } + } + + #[test] + fn saved_tracking_decoders_validate_dimensions_and_plane_lengths() { + let valid = rle::encode(&[255, 0, 255, 0]); + let mut cache = TrackCache { + size: [2, 2], + frames: [(0, TrackFrame { m: valid.clone(), r: valid.clone(), ..Default::default() })].into_iter().collect(), + objects: [(1, [(0, ObjectFrame { m: valid.clone(), r: valid.clone() })].into_iter().collect())].into_iter().collect(), + ..Default::default() + }; + assert_eq!(decode_tracked(&cache, 0).unwrap().matte, [255, 0, 255, 0]); + assert_eq!(decode_object(&cache, 0, 1).unwrap().refine, [255, 0, 255, 0]); + for (size, matte, refine) in [ + ([0, 2], rle::encode(&[]), rle::encode(&[])), + ([2, 0], rle::encode(&[]), rle::encode(&[])), + ([usize::MAX, 2], valid.clone(), valid.clone()), + ([rle::MAX_PLANE_BYTES + 1, 1], valid.clone(), valid.clone()), + ([2, 2], rle::encode(&[0; 5]), valid.clone()), + ([2, 2], valid.clone(), rle::encode(&[0; 5])), + ([2, 2], rle::encode(&[0; 3]), valid.clone()), + ([2, 2], valid, rle::encode(&[0; 3])), + ] { + cache.size = size; + cache.frames.insert(0, TrackFrame { m: matte.clone(), r: refine.clone(), ..Default::default() }); + cache.objects.insert(1, [(0, ObjectFrame { m: matte, r: refine })].into_iter().collect()); + assert!(decode_tracked(&cache, 0).is_none(), "tracked plane {size:?}"); + assert!(decode_object(&cache, 0, 1).is_none(), "object plane {size:?}"); + } + } + + #[test] + fn a_one_direction_pass_tracks_and_reports_only_its_frames() { + let mut d = RotoData::default(); + d.add(Stroke { kind: StrokeKind::Fg, frame: 12, radius: 3.0, points: vec![[35.0, 40.0]], object: 0 }, 12, [0, 24]); + let params = params_with(&d); + let mut chain = Chain::new(¶ms, [100.0, 80.0], 25.0, 1.0); + chain.model = Some(Arc::new(TwoObjects(Default::default()))); + let mut get = |f: i64| Some(Arc::new(frame(f as f64 / 25.0))); + let forward: Vec = (12..=24).collect(); + let mut last = 0; + let summary = chain.track_frames(Some(&forward), &mut get, &|| false, &mut |n| last = n).unwrap().unwrap(); + assert_eq!((summary.frames, last), (forward.len(), forward.len()), "progress never exceeds the frames asked for"); + let _ = chain.ensure(5, &mut get, &|| false); + assert!(tracks_of(&chain).is_none_or(|t| t.frames.keys().all(|f| *f >= 12)), "earlier frames were not tracked"); + } + + #[test] + fn propagation_replaces_saved_tracking_from_a_different_model() { + let mut d = RotoData::default(); + d.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 3.0, points: vec![[35.0, 40.0]], object: 0 }, 2, [0, 24]); + let mut params = params_with(&d); + params.values.insert(INPUT_KEY.into(), Value::Str("replace-saved-tracking-model".into())); + let size = [100.0, 80.0]; + let old_model = effectcraft_segment::SAM2_SMALL.id; + let old_keys = d.chain_keys(seed_with(¶ms, size, 25.0, old_model)); + let empty = vec![0; 100 * 80]; + let old_frame = TrackFrame { m: rle::encode(&empty), r: rle::encode(&empty), c: 0.2, ..Default::default() }; + let saved = TrackCache { + key: tracks_key(&old_keys), + model: old_model.into(), + size: [100, 80], + frames: old_keys.keys().map(|f| (*f, old_frame.clone())).collect(), + ..Default::default() + }; + params.values.insert(TRACKED.into(), Value::Str(saved.to_json())); + let mut chain = Chain::new(¶ms, size, 25.0, 1.0); + let fake = Arc::new(TwoObjects(Default::default())); + chain.model = Some(fake.clone()); + chain.keys = d.chain_keys(seed_with(¶ms, size, 25.0, fake.info().id)); + let mut get = |f: i64| Some(Arc::new(frame(f as f64 / 25.0))); + assert_eq!(chain.saved_model(), Some(old_model)); + assert_eq!(chain.ensure(0, &mut get, &|| false).unwrap().area(), 0, "rendering keeps the saved matte until propagation"); + chain.prepare_propagation(); + assert_eq!(chain.saved_model(), None); + assert_eq!(chain.track(&mut get, &|| false, &mut |_| {}).unwrap().unwrap().frames, 3); + assert_eq!(fake.0.lock().unwrap().len(), 1, "the newly selected tracker must actually run"); + assert!(chain.ensure(0, &mut get, &|| false).unwrap().area() > 0); + let replacement = tracks_of(&chain).expect("new tracking is saved"); + assert_eq!(replacement.model, effectcraft_segment::SAM2_TINY.id); + assert_eq!(replacement.frames.len(), 3); + params.values.insert(TRACKED.into(), Value::Str(replacement.to_json())); + let mut reopened = Chain::new(¶ms, size, 25.0, 1.0); + assert_eq!(reopened.saved_model(), Some(effectcraft_segment::SAM2_TINY.id)); + assert!(reopened.ensure(0, &mut get, &|| false).unwrap().area() > 0, "reopening uses the replacement without loading its model"); + reopened.model = Some(fake.clone()); + reopened.keys = chain.keys.clone(); + reopened.prepare_propagation(); + assert_eq!(reopened.saved_model(), Some(effectcraft_segment::SAM2_TINY.id)); + assert_eq!(reopened.track(&mut get, &|| false, &mut |_| {}).unwrap().unwrap().frames, 0, "matching saved tracking is reused"); + assert_eq!(fake.0.lock().unwrap().len(), 1); + let mut classic = Chain::new(¶ms, size, 25.0, 1.0); + classic.model = None; + classic.prepare_propagation(); + assert_eq!(classic.saved_model(), None, "Classic propagation also replaces saved model tracking"); + } + + #[test] + fn objects_are_followed_in_one_pass_and_their_mattes_united() { + let mut d = RotoData::default(); + let stroke = |kind, points: Vec<[f64; 2]>, object| Stroke { kind, frame: 0, radius: 3.0, points, object }; + d.add(stroke(StrokeKind::Fg, vec![[35.0, 40.0], [45.0, 40.0]], 0), 8, [0, 24]); + d.add(stroke(StrokeKind::Fg, vec![[12.0, 15.0], [18.0, 15.0]], 1), 8, [0, 24]); + d.add(stroke(StrokeKind::Bg, vec![[85.0, 70.0], [95.0, 70.0]], 0), 8, [0, 24]); + assert_eq!(d.objects(), vec![0, 1]); + // the Refine Edge strokes belong to every object; the others only to their own + assert_eq!(d.strokes_of(0, 1).len(), 1); + assert_eq!(d.strokes_of(0, 0).len(), 2); + let params = params_with(&d); + let mut chain = Chain::new(¶ms, [100.0, 80.0], 25.0, 1.0); + let fake = Arc::new(TwoObjects(Default::default())); + chain.model = Some(fake.clone()); + // the clip: the moving disk and a fixed yellow square + let mut get = |f: i64| { + let mut img = frame(f as f64 / 25.0 + 0.001); + for y in 8..22 { + for x in 8..22 { + img.set(x, y, [0.95, 0.9, 0.1 + ((x * 3 + y * 5) % 7) as f32 / 70.0, 1.0]); + } + } + Some(Arc::new(img)) + }; + let mut seen = 0; + let summary = chain.track(&mut get, &|| false, &mut |n| seen = n).unwrap().unwrap(); + // one request for both objects; object 0 has its foreground and background prompt on the frame, object 1 its own + assert_eq!(fake.0.lock().unwrap().len(), 1); + assert_eq!(fake.0.lock().unwrap()[0], vec![1, 1]); + assert_eq!(summary.frames, 9); + assert_eq!(seen, 9); + // the least sure object sets the frame's confidence + assert!((summary.min_confidence - 0.4).abs() < 1e-6, "{}", summary.min_confidence); + assert_eq!(summary.occluded, 0); + let seg = cached(tracked_key(chain.keys[&3]), 1.0).expect("frame 3 is tracked"); + let at = |x: usize, y: usize| seg.matte[y * 100 + x]; + assert!(at(46, 40) != 0, "the moving disk (frame 3 centre x = 46)"); + assert!(at(15, 15) != 0, "the fixed square"); + assert_eq!(at(60, 70), 0, "nothing elsewhere"); + + // each object keeps its own matte + let (disk, square) = (chain.object_seg(3, 0).expect("object 0"), chain.object_seg(3, 1).expect("object 1")); + assert!(disk.matte[40 * 100 + 46] != 0 && disk.matte[15 * 100 + 15] == 0); + assert!(square.matte[15 * 100 + 15] != 0 && square.matte[40 * 100 + 46] == 0); + // Show Object picks one (its id plus one); an object the instance does not have shows nothing; 0 shows all + let mut shown = |c: &Chain| c.ensure(3, &mut get, &|| false).expect("frame 3"); + chain.show_object = 2; + let one = shown(&chain); + assert!(one.matte[15 * 100 + 15] != 0 && one.matte[40 * 100 + 46] == 0, "only the square"); + chain.show_object = 1; + let zero = shown(&chain); + assert!(zero.matte[40 * 100 + 46] != 0 && zero.matte[15 * 100 + 15] == 0, "only the disk"); + chain.show_object = 7; + assert_eq!(shown(&chain).area(), 0, "no such object"); + chain.show_object = 0; + assert!(shown(&chain).matte[15 * 100 + 15] != 0, "all together"); + + // the objects are saved with the tracking and come back from the project file + let saved = tracks_of(&chain).expect("tracking to save"); + assert_eq!(saved.objects.keys().copied().collect::>(), vec![0, 1]); + assert_eq!(saved.objects.get(&1).map(|f| f.len()), Some(9)); + assert_eq!(TrackCache::from_json(&saved.to_json()), Some(saved.clone())); + // a tracking saved before objects existed has none + let old = TrackCache { objects: Default::default(), ..saved.clone() }; + assert!(!old.to_json().contains("objects")); + let rt = decode_object(&saved, 3, 1).expect("decoded"); + assert!(rt.matte[15 * 100 + 15] != 0 && rt.matte[40 * 100 + 46] == 0); + } + /// Instances saved before the propagation controls got their own twirl-down (and motion /// blur / decontamination their nested ones) load with their values in the new places. #[test] diff --git a/crates/effects/src/roto/pending.rs b/crates/effects/src/roto/pending.rs new file mode 100644 index 000000000..256789ee9 --- /dev/null +++ b/crates/effects/src/roto/pending.rs @@ -0,0 +1,138 @@ +//! Bounded assembly of the objects a tracker reports separately for each frame. + +use std::collections::BTreeMap; +use std::mem::size_of; + +use effectcraft_segment::TrackedFrame; + +/// Separate from the model's memory banks: probability maps are full-resolution and can otherwise +/// grow with the entire clip when objects start on different frames. +pub(super) const MAX_PENDING_BYTES: usize = 256 << 20; + +pub(super) struct PendingMasks { + frames: BTreeMap>>, + objects: usize, + bytes: usize, + limit: usize, +} + +impl PendingMasks { + pub(super) fn new(objects: usize, limit: usize) -> Self { + Self { frames: BTreeMap::new(), objects, bytes: 0, limit } + } + + /// Probability allocation plus a conservative allowance for the map node and object slots. + fn frame_bytes(&self) -> usize { + self.objects.saturating_mul(size_of::>()).saturating_add(256) + } + + fn mask_bytes(mask: &TrackedFrame) -> usize { + mask.prob.capacity().saturating_mul(size_of::()) + } + + /// Complete frames leave immediately; incomplete frames may never exceed the budget. A rejected + /// insertion leaves the buffer unchanged, including when it replaces an object's previous result. + pub(super) fn insert(&mut self, object: usize, frame: i64, mask: TrackedFrame) -> Result>>, String> { + if object >= self.objects { + return Err("tracker reported an unknown object".into()); + } + if self.objects == 1 { + return Ok(Some(vec![Some(mask)])); + } + let existing = self.frames.get(&frame); + let old_bytes = existing.and_then(|p| p.get(object)).and_then(Option::as_ref).map_or(0, Self::mask_bytes); + let added = Self::mask_bytes(&mask).saturating_add(if existing.is_none() { self.frame_bytes() } else { 0 }); + let bytes = self.bytes.saturating_sub(old_bytes).checked_add(added); + let Some(bytes) = bytes.filter(|n| *n <= self.limit) else { + return Err(format!("pending object masks exceed their {} MiB memory budget", self.limit >> 20)); + }; + let parts = self.frames.entry(frame).or_insert_with(|| vec![None; self.objects]); + if let Some(slot) = parts.get_mut(object) { + *slot = Some(mask); + } + self.bytes = bytes; + if parts.iter().all(Option::is_some) { + let parts = self.frames.remove(&frame).unwrap_or_default(); + let used = parts.iter().flatten().fold(self.frame_bytes(), |n, m| n.saturating_add(Self::mask_bytes(m))); + self.bytes = self.bytes.saturating_sub(used); + return Ok(Some(parts)); + } + Ok(None) + } + + pub(super) fn into_incomplete(self) -> BTreeMap>> { + self.frames + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mask(value: f32) -> TrackedFrame { + TrackedFrame { prob: vec![value; 16], w: 4, h: 4, confidence: value, occluded: false } + } + + #[test] + fn different_start_passes_stop_before_buffering_the_whole_clip() { + let mut pending = PendingMasks::new(2, 2048); + let mut accepted = 0; + // SAM 2 can report all of object 0 before starting object 1. A long clip must stop + // at the memory limit even though no frame has received all its objects yet. + for frame in 0..10_000 { + match pending.insert(0, frame, mask(0.9)) { + Ok(None) => accepted += 1, + Ok(Some(_)) => panic!("no frame is complete yet"), + Err(e) => { + assert!(e.contains("memory budget"), "{e}"); + break; + } + } + } + assert!(accepted > 0 && accepted < 10_000); + assert_eq!(pending.frames.len(), accepted); + assert!(pending.bytes <= pending.limit); + let before = pending.bytes; + assert!(pending.insert(0, 10_001, mask(0.8)).is_err()); + assert_eq!(pending.bytes, before, "rejection must not retain the new map"); + } + + #[test] + fn interleaved_objects_release_the_budget_and_keep_their_masks() { + let mut pending = PendingMasks::new(2, 2048); + for frame in 0..10_000 { + let first = (frame % 2) as usize; + assert!(pending.insert(first, frame, mask(first as f32)).unwrap().is_none()); + let parts = pending.insert(1 - first, frame, mask((1 - first) as f32)).unwrap().unwrap(); + assert_eq!(parts[0].as_ref().unwrap().prob, vec![0.0; 16]); + assert_eq!(parts[1].as_ref().unwrap().prob, vec![1.0; 16]); + assert!(pending.frames.is_empty()); + assert_eq!(pending.bytes, 0); + } + } + + #[test] + fn replacements_charge_capacity_and_partial_frames_are_preserved() { + let mut pending = PendingMasks::new(2, 2048); + pending.insert(0, 7, mask(0.1)).unwrap(); + let before = pending.bytes; + pending.insert(0, 7, mask(0.2)).unwrap(); + assert_eq!(pending.bytes, before, "a duplicate replaces rather than accumulates"); + let mut oversized = mask(0.3); + oversized.prob.reserve(2048); + assert!(pending.insert(0, 7, oversized).is_err(), "capacity, not length, owns the memory"); + assert_eq!(pending.bytes, before); + let frames = pending.into_incomplete(); + assert_eq!(frames[&7][0].as_ref().unwrap().prob, vec![0.2; 16]); + assert!(frames[&7][1].is_none()); + } + + #[test] + fn single_objects_need_no_pending_budget_and_unknown_objects_are_rejected() { + let mut pending = PendingMasks::new(1, 0); + assert!(pending.insert(0, 0, mask(0.9)).unwrap().is_some()); + assert_eq!(pending.bytes, 0); + assert!(pending.insert(1, 0, mask(0.9)).is_err()); + assert!(pending.frames.is_empty()); + } +} diff --git a/crates/effects/src/sim.rs b/crates/effects/src/sim.rs index 14146a9f4..ca5bc5126 100644 --- a/crates/effects/src/sim.rs +++ b/crates/effects/src/sim.rs @@ -15,2019 +15,31 @@ 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}; -pub(crate) fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { - EffectSpec { id, name, category: "Simulation", params, render, gpu: false, float: true } -} - -pub(crate) fn pt(x: f64, y: f64) -> Value { - Value::Vec2([x, y]) -} - -pub(crate) fn pct() -> ParamUi { - slider(0.0, 100.0, 0.0, 100.0, 1) -} - -/// Deterministic hash → [0, 1). -#[inline] -pub(crate) fn h(i: u32, k: u32, seed: u32) -> f32 { - hash1(i, k, seed) -} - -/// Signed hash → [-1, 1). -#[inline] -pub(crate) fn hs(i: u32, k: u32, seed: u32) -> f32 { - hash1(i, k, seed) * 2.0 - 1.0 -} - -// ------------------------------------------------------------------ band rasteriser - -const BAND: usize = 16; - -/// How rasterised items combine with what is already in the target. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum Acc { - /// Premultiplied "over" (later items on top). - Over, - /// Additive (glows, light). - Add, -} - -/// Rasterise many small items into `img` (buffer pixels). `bbox` gives an item's pixel bounds -/// `[x0, y0, x1, y1]` (or `None` to skip), `shade` its premultiplied colour at a pixel centre. -/// Items are bucketed into horizontal bands rendered in parallel; within a band they are drawn -/// in slice order. -pub(crate) fn raster( - img: &mut Image, - items: &[T], - bbox: impl Fn(&T) -> Option<[f32; 4]> + Sync, - shade: impl Fn(&T, f32, f32) -> Option + Sync, - acc: Acc, -) { - let (w, hgt) = (img.width as usize, img.height as usize); - if w == 0 || hgt == 0 || items.is_empty() { - return; - } - let nb = hgt.div_ceil(BAND); - let boxes: Vec> = items - .par_iter() - .map(|it| { - let b = bbox(it)?; - if !(b[0].is_finite() && b[1].is_finite() && b[2].is_finite() && b[3].is_finite()) { - return None; - } - let x0 = b[0].floor().max(0.0) as i32; - let y0 = b[1].floor().max(0.0) as i32; - let x1 = (b[2].ceil() as i32).min(w as i32 - 1); - let y1 = (b[3].ceil() as i32).min(hgt as i32 - 1); - if x1 < x0 || y1 < y0 || b[2] < 0.0 || b[3] < 0.0 { - return None; - } - Some([x0, y0, x1, y1]) - }) - .collect(); - let mut buckets: Vec> = vec![Vec::new(); nb]; - for (i, b) in boxes.iter().enumerate() { - if let Some(b) = b { - for band in (b[1] as usize / BAND)..=(b[3] as usize / BAND).min(nb - 1) { - buckets[band].push(i as u32); - } - } - } - img.data.par_chunks_mut(w * BAND).enumerate().for_each(|(band, chunk)| { - let by0 = band * BAND; - let rows = chunk.len() / w; - for &i in &buckets[band] { - let Some(b) = boxes[i as usize] else { continue }; - let it = &items[i as usize]; - let ya = (b[1] as usize).max(by0); - let yb = (b[3] as usize).min(by0 + rows - 1); - for y in ya..=yb { - let row = &mut chunk[(y - by0) * w..(y - by0 + 1) * w]; - for x in b[0] as usize..=b[2] as usize { - if let Some(c) = shade(it, x as f32 + 0.5, y as f32 + 0.5) { - let d = &mut row[x]; - match acc { - Acc::Over => { - let k = 1.0 - c[3]; - *d = [c[0] + d[0] * k, c[1] + d[1] * k, c[2] + d[2] * k, c[3] + d[3] * k]; - } - Acc::Add => { - *d = [d[0] + c[0], d[1] + c[1], d[2] + c[2], (d[3] + c[3]).min(1.0)]; - } - } - } - } - } - } - }); -} - -// ------------------------------------------------------------------ sprites - -#[derive(Clone, Copy, PartialEq, Debug)] -pub enum Shape { - /// Soft round dot (Gaussian-like falloff). - Soft, - /// Hard anti-aliased disc. - Disc, - /// Lit sphere (upper-left light, specular). - Sphere, - /// Disc fading linearly to its rim. - Faded, - /// Thin ring with a highlight. - Bubble, - /// Four-point star. - Star, - /// Rotated square (angle in `rot`). - Square, - /// Triangle (angle in `rot`). - Tri, - /// Segment from (x, y) by (dx, dy), half-width `r`. - Line { dx: f32, dy: f32 }, -} - -/// A particle sprite in buffer pixels; `c` is straight colour + opacity. -#[derive(Clone, Copy, Debug)] -pub struct Sprite { - pub x: f32, - pub y: f32, - pub r: f32, - pub c: [f32; 4], - pub shape: Shape, - pub rot: f32, -} - -impl Sprite { - pub(crate) fn new(x: f32, y: f32, r: f32, c: [f32; 4], shape: Shape) -> Sprite { - Sprite { x, y, r, c, shape, rot: 0.0 } - } -} - -/// Where a sprite's colour comes from when it depends on the layer's pixels (resolved by -/// [`SpritePlan::resolve`] on the CPU, per pixel by the GPU compositor). Positions are buffer -/// pixels. -#[derive(Clone, Copy, Debug)] -pub enum Tint { - /// The sprite's own colour. - Fixed, - /// CC Rainfall's refracting drops: where the layer shows (alpha > 0), colour × (layer - /// colour × 0.6 + 0.5) sampled at (x, y). - Refract { x: f64, y: f64 }, - /// CC Star Burst: the layer's colour at (x, y), opacity × its alpha (none where clear). - Source { x: f64, y: f64 }, - /// CC Hair: the root's colour at (x, y) mixed with the hair colour (`inherit` = 1 keeps the - /// root's), × `shade`, + `spec`; no strand where the root's alpha is below ½. - Root { x: f64, y: f64, hair: [f32; 3], inherit: f32, shade: f32, spec: f32 }, -} - -/// How a sprite layer meets the original layer. -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum Post { - /// [`combine`] with this mode (0 Composite … 5 sprites only). - Combine(u32), - /// The sprites alone. - Replace, - /// The sprites, mixed toward the original by this fraction. - Lerp(f32), - /// The sprites over opaque black. - OverBlack, -} - -/// What a sprite-drawing effect draws this frame: built on the CPU (closed-form or from the -/// simulation state), rasterised by [`SpritePlan::finish`] or the GPU compositor. -#[derive(Clone, Debug)] -pub struct SpritePlan { - pub sprites: Vec, - /// Per sprite (empty = all [`Tint::Fixed`]). - pub tints: Vec, - pub acc: Acc, - pub post: Post, -} - -impl SpritePlan { - /// Resolve layer-dependent colours against the layer `src`. - pub fn resolve(&mut self, src: &Image) { - let tints = std::mem::take(&mut self.tints); - self.sprites.par_iter_mut().zip(tints.par_iter()).for_each(|(sp, t)| match *t { - Tint::Fixed => {} - Tint::Refract { x, y } => { - let (sc, sa) = unpremul(src.sample_bilinear(x, y)); - if sa > 0.0 { - let c = sp.c; - sp.c = [(sc[0] * 0.6 + 0.5) * c[0], (sc[1] * 0.6 + 0.5) * c[1], (sc[2] * 0.6 + 0.5) * c[2], c[3]]; - } - } - Tint::Source { x, y } => { - let (c, a) = unpremul(src.sample_bilinear(x, y)); - sp.c = if a <= 0.0 { [0.0; 4] } else { [c[0], c[1], c[2], a * sp.c[3]] }; - } - Tint::Root { x, y, hair, inherit, shade, spec } => { - let root = src.sample_bilinear(x, y); - if root[3] < 0.5 { - sp.c = [0.0; 4]; - return; - } - let (rc, _) = unpremul(root); - let base: [f32; 3] = std::array::from_fn(|k| rc[k] + (hair[k] - rc[k]) * (1.0 - inherit)); - sp.c = [base[0] * shade + spec, base[1] * shade + spec, base[2] * shade + spec, sp.c[3]]; - } - }); - } - - /// Rasterise (after [`SpritePlan::resolve`]) and meet the original layer `b`. - pub fn finish(&self, b: Buf) -> Buf { - let fx = splat(b.img.width, b.img.height, &self.sprites, self.acc); - self.finish_with(b, fx) - } - - /// [`finish`](Self::finish) with a prepared sprite image (the motion-blurred average of - /// several frames, #1195). - pub fn finish_with(&self, b: Buf, fx: Image) -> Buf { - self.post.apply(b, fx) - } -} - -impl Post { - /// Meet the sprite image `fx` with the original layer `b` (see [`SpritePlan::finish`]). - fn apply(self, mut b: Buf, fx: Image) -> Buf { - b.img = match self { - Post::Combine(m) => combine(&b.img, &fx, m), - Post::Replace => fx, - Post::Lerp(t) => { - let mut o = fx; - o.data.par_iter_mut().zip(b.img.data.par_iter()).for_each(|(o, s0)| *o = crate::util::lerp4(*o, *s0, t)); - o - } - Post::OverBlack => combine(&Image::filled(b.img.width, b.img.height, [0.0, 0.0, 0.0, 1.0]), &fx, 0), - }; - b - } -} - -/// 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 -/// render pass). -pub fn sprite_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option { - match id { - "ec.sim.ccrainfall" => Some(rainfall_plan(ctx, b)), - "ec.sim.ccsnowfall" => Some(snowfall_plan(ctx, b)), - "ec.sim.ccstarburst" => Some(star_burst_plan(ctx, b)), - "ec.sim.cchair" => crate::sim3::hair_plan(ctx, b), - "ec.sim.waveworld" => crate::sim2::wave_world_plan(ctx, b).and_then(|p| match p { - crate::sim2::WavePlan::Wire(sp) => Some(sp), - crate::sim2::WavePlan::Height { .. } => None, - }), - "ec.sim.foam" => crate::sim2::foam_plan(ctx, b), - "ec.sim.ccparticleworld" => particle_world_plan(ctx, b), - "ec.sim.ccparticlesystems2" => Some(particle_systems2_plan(ctx, b)), - _ => None, - } -} - -fn sprite_bbox(s: &Sprite) -> Option<[f32; 4]> { - if s.c[3] <= 1e-5 || s.r <= 0.0 { - return None; - } - let r = s.r.max(0.5) + 1.0; - Some(match s.shape { - Shape::Line { dx, dy } => [s.x.min(s.x + dx) - r, s.y.min(s.y + dy) - r, s.x.max(s.x + dx) + r, s.y.max(s.y + dy) + r], - Shape::Star => [s.x - r * 2.0, s.y - r * 2.0, s.x + r * 2.0, s.y + r * 2.0], - _ => [s.x - r, s.y - r, s.x + r, s.y + r], - }) -} - -#[inline] -fn aa(edge: f32) -> f32 { - (edge + 0.5).clamp(0.0, 1.0) -} - -fn sprite_shade(s: &Sprite, x: f32, y: f32) -> Option { - // Sub-pixel sprites keep their energy: draw at radius 0.5 with reduced opacity. - let (r, fade) = if s.r < 0.5 { (0.5, s.r / 0.5) } else { (s.r, 1.0) }; - let (dx, dy) = (x - s.x, y - s.y); - let mut rgb = [s.c[0], s.c[1], s.c[2]]; - let cov = match s.shape { - Shape::Soft => { - let d2 = (dx * dx + dy * dy) / (r * r); - if d2 >= 1.0 { - return None; - } - let q = 1.0 - d2; - q * q - } - Shape::Disc => aa(r - (dx * dx + dy * dy).sqrt()), - Shape::Faded => { - let d = (dx * dx + dy * dy).sqrt() / r; - (1.0 - d).max(0.0).powf(1.5) - } - Shape::Sphere => { - let d = (dx * dx + dy * dy).sqrt(); - let cv = aa(r - d); - if cv <= 0.0 { - return None; - } - let (nx, ny) = (dx / r, dy / r); - let nz = (1.0 - nx * nx - ny * ny).max(0.0).sqrt(); - let (lx, ly, lz) = (-0.48, -0.58, 0.66); - let diff = (nx * lx + ny * ly + nz * lz).max(0.0); - let rz = 2.0 * diff * nz - lz; - let spec = rz.max(0.0).powi(20) * 0.6; - let k = 0.2 + 0.8 * diff; - rgb = [rgb[0] * k + spec, rgb[1] * k + spec, rgb[2] * k + spec]; - cv - } - Shape::Bubble => { - let d = (dx * dx + dy * dy).sqrt() / r; - if d > 1.0 + 1.0 / r { - return None; - } - let ring = (1.0 - ((d - 0.9) / 0.12).powi(2)).max(0.0); - let hl = (1.0 - (((dx / r) + 0.4).powi(2) + ((dy / r) + 0.4).powi(2)) / 0.04).max(0.0); - (0.12 * (d <= 1.0) as u8 as f32 + ring * 0.8 + hl).min(1.0) - } - Shape::Star => { - let (sn, cs) = s.rot.sin_cos(); - let (ux, uy) = ((dx * cs + dy * sn) / r, (-dx * sn + dy * cs) / r); - let d = (ux * ux + uy * uy).sqrt(); - let core = (1.0 - d).max(0.0).powi(2); - let sx = (1.0 - ux.abs() / 2.0).max(0.0) * (1.0 - (uy.abs() * 6.0)).max(0.0); - let sy = (1.0 - uy.abs() / 2.0).max(0.0) * (1.0 - (ux.abs() * 6.0)).max(0.0); - (core + sx + sy).min(1.0) - } - Shape::Square => { - let (sn, cs) = s.rot.sin_cos(); - let (ux, uy) = (dx * cs + dy * sn, -dx * sn + dy * cs); - let e = r * 0.75; - aa(e - ux.abs()) * aa(e - uy.abs()) - } - Shape::Tri => { - let (sn, cs) = s.rot.sin_cos(); - let (ux, uy) = (dx * cs + dy * sn, -dx * sn + dy * cs); - // Equilateral triangle with circumradius r: three half-planes. - let e = r * 0.5; - let mut m = f32::INFINITY; - for k in 0..3 { - let a = std::f32::consts::FRAC_PI_2 + k as f32 * std::f32::consts::TAU / 3.0; - let (ns, nc) = a.sin_cos(); - m = m.min(e - (ux * nc + uy * ns)); - } - aa(m) - } - Shape::Line { dx: lx, dy: ly } => { - let l2 = lx * lx + ly * ly; - let t = if l2 > 0.0 { ((dx * lx + dy * ly) / l2).clamp(0.0, 1.0) } else { 0.0 }; - let (px, py) = (dx - lx * t, dy - ly * t); - aa(r - (px * px + py * py).sqrt()) - } - }; - let a = cov * s.c[3] * fade; - if a <= 1e-6 { - return None; - } - Some([rgb[0] * a, rgb[1] * a, rgb[2] * a, a]) -} - -/// Draw sprites into a new transparent image of `w`×`h`. -pub(crate) fn splat(w: u32, hgt: u32, sprites: &[Sprite], acc: Acc) -> Image { - let mut img = Image::new(w, hgt); - raster(&mut img, sprites, sprite_bbox, sprite_shade, acc); - img -} - -/// Combine an effect layer `fx` with the original `src` (both premultiplied, same size): -/// 0 Composite (fx over src), 1 Screen, 2 Add, 3 Lighten, 4 Darken, 5 fx only. -pub(crate) fn combine(src: &Image, fx: &Image, mode: u32) -> Image { - let mut out = fx.clone(); - out.data.par_iter_mut().zip(src.data.par_iter()).for_each(|(o, s)| { - let f = *o; - *o = match mode { - 0 => { - let k = 1.0 - f[3]; - [f[0] + s[0] * k, f[1] + s[1] * k, f[2] + s[2] * k, f[3] + s[3] * k] - } - 1 => [s[0] + f[0] - s[0] * f[0], s[1] + f[1] - s[1] * f[1], s[2] + f[2] - s[2] * f[2], s[3] + f[3] - s[3] * f[3]], - 2 => [s[0] + f[0], s[1] + f[1], s[2] + f[2], (s[3] + f[3]).min(1.0)], - 3 => [s[0].max(f[0]), s[1].max(f[1]), s[2].max(f[2]), s[3] + f[3] - s[3] * f[3]], - 4 => { - let (fc, fa) = unpremul(f); - let m = |c: f32, k: usize| c - (c - c.min(fc[k] * s[3])) * fa; - [m(s[0], 0), m(s[1], 1), m(s[2], 2), s[3]] - } - _ => f, - }; - }); - out -} - -// ------------------------------------------------------------------ CC Rainfall / CC Snowfall - -/// Shared weather layout: per-item depth factor `k` in (0, 1] (1 = nearest) from Scene Depth. -#[inline] -fn depth_k(z: f32, scene_depth: f32) -> f32 { - 1.0 / (1.0 + z * scene_depth / 2500.0) -} - -/// Wrap `v` into `[lo, lo + span)`. -#[inline] -fn wrap(v: f32, lo: f32, span: f32) -> f32 { - if span <= 0.0 { v } else { lo + (v - lo).rem_euclid(span) } -} - -fn rainfall(ctx: &EffectCtx, b: Buf) -> Buf { - let mut plan = rainfall_plan(ctx, &b); - plan.resolve(&b.img); - plan.finish(b) -} - -fn rainfall_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan { - let pr = ctx.params; - let n = pr.f("drops").clamp(0.0, 100_000.0) as u32; - let size = pr.f("size").max(0.0) as f32; - let depth = pr.f("sceneDepth").max(0.0) as f32; - let speed = pr.f("speed") as f32; - let wind = pr.f("wind") as f32; - let wvar = pr.f("windVariation") as f32 / 100.0; - let spread = pr.f("spread") as f32; - let color = pr.color("color"); - let opacity = pr.f("opacity") as f32 / 100.0; - let mode = pr.e("transferMode"); - let with_orig = pr.b("compositeWithOriginal"); - let refract = pr.e("extras/appearance") == 0; - let off = pr.v2("extras/offset"); - let seed = pr.f("extras/randomSeed") as u32 ^ ctx.seed.wrapping_mul(0x9e37); - let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); - let s = b.scale as f32; - let t = ctx.time as f32; - let (ox, oy) = (off[0] as f32 - lw * 0.5, off[1] as f32 - lh * 0.5); - let (sprites, tints): (Vec, Vec) = (0..n) - .into_par_iter() - .map(|i| { - let z = h(i, 1, seed); - let k = depth_k(z, depth); - let vy = speed / 4000.0 * lh * 2.2 * k; - let w = wind / 1000.0 * lh * (1.0 + wvar * hs(i, 2, seed)) * k + spread * hs(i, 7, seed) * k; - let len = (vy * 0.04 * size.max(0.2)).max(1.0); - let lenx = w * 0.04 * size.max(0.2); - let margin = len + lenx.abs() + 4.0; - let y = wrap(h(i, 3, seed) * (lh + 2.0 * margin) + vy * t + oy, -margin, lh + 2.0 * margin); - let x = wrap(h(i, 4, seed) * (lw + 2.0 * margin) + w * t + ox, -margin, lw + 2.0 * margin); - let (bx, by) = b.to_px([x as f64, y as f64]); - let c = [color[0], color[1], color[2], opacity * (0.35 + 0.65 * k)]; - // Refracting drops show the scene behind them, brightened and offset. - let tint = if refract { Tint::Refract { x: bx + 2.0 * s as f64, y: by } } else { Tint::Fixed }; - let r = (size * 0.6 * k * s).max(0.25); - (Sprite::new(bx as f32, by as f32, r, c, Shape::Line { dx: -lenx * s, dy: -len * s }), tint) - }) - .unzip(); - SpritePlan { sprites, tints, acc: Acc::Over, post: weather_post(mode, with_orig) } -} - -fn weather_post(mode: u32, with_orig: bool) -> Post { - if with_orig { Post::Combine(if mode == 1 { 3 } else { 0 }) } else { Post::Replace } -} - -fn snowfall(ctx: &EffectCtx, b: Buf) -> Buf { - snowfall_plan(ctx, &b).finish(b) -} - -fn snowfall_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan { - let pr = ctx.params; - let n = pr.f("flakes").clamp(0.0, 200_000.0) as u32; - let size = pr.f("size").max(0.0) as f32; - let svar = pr.f("sizeVariation") as f32 / 100.0; - let depth = pr.f("sceneDepth").max(0.0) as f32; - let speed = pr.f("speed") as f32; - let spvar = pr.f("speedVariation") as f32 / 100.0; - let wind = pr.f("wind") as f32; - let wvar = pr.f("windVariation") as f32 / 100.0; - let spread = pr.f("spread") as f32; - let wig = pr.f("wiggle/wiggleAmount") as f32; - let wigvar = pr.f("wiggle/wiggleAmountVariation") as f32 / 100.0; - let wfreq = pr.f("wiggle/wiggleFrequency") as f32; - let wfvar = pr.f("wiggle/wiggleFrequencyVariation") as f32 / 100.0; - let color = pr.color("color"); - let opacity = pr.f("opacity") as f32 / 100.0; - let mode = pr.e("transferMode"); - let with_orig = pr.b("compositeWithOriginal"); - let off = pr.v2("extras/offset"); - let seed = pr.f("extras/randomSeed") as u32 ^ ctx.seed.wrapping_mul(0x85eb); - let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); - let s = b.scale as f32; - let t = ctx.time as f32; - let (ox, oy) = (off[0] as f32 - lw * 0.5, off[1] as f32 - lh * 0.5); - let sprites: Vec = (0..n) - .into_par_iter() - .map(|i| { - let z = h(i, 1, seed); - let k = depth_k(z, depth); - let r = (size * 0.5 * k * (1.0 + svar * hs(i, 2, seed))).max(0.05); - let vy = speed * (lh / 600.0) * k * (1.0 + spvar * hs(i, 3, seed)); - let vx = wind * (lh / 1200.0) * k * (1.0 + wvar * hs(i, 4, seed)) + spread * hs(i, 8, seed) * k; - let amp = wig * 2.0 * k * (1.0 + wigvar * hs(i, 5, seed)); - let fr = wfreq * (1.0 + wfvar * hs(i, 6, seed)); - let ph = h(i, 9, seed) * std::f32::consts::TAU; - let margin = r + amp.abs() + 2.0; - let y = wrap(h(i, 10, seed) * (lh + 2.0 * margin) + vy * t + oy, -margin, lh + 2.0 * margin); - let x = wrap(h(i, 11, seed) * (lw + 2.0 * margin) + vx * t + ox, -margin, lw + 2.0 * margin) + amp * (fr * t * std::f32::consts::TAU + ph).sin(); - let (bx, by) = b.to_px([x as f64, y as f64]); - Sprite::new(bx as f32, by as f32, r * s, [color[0], color[1], color[2], opacity * (0.4 + 0.6 * k)], Shape::Soft) - }) - .collect(); - SpritePlan { sprites, tints: vec![], acc: Acc::Over, post: weather_post(mode, with_orig) } -} - -// ------------------------------------------------------------------ CC Bubbles - -/// A CC Bubbles bubble in buffer pixels. -#[derive(Clone, Copy, Debug)] -pub struct Bubble { - pub x: f32, - pub y: f32, - pub r: f32, - /// Source sample centre (buffer px). - pub sx: f32, - pub sy: f32, -} - -/// CC Bubbles' bubbles this frame (closed-form in time) for buffer geometry `b`. -pub fn bubble_list(ctx: &EffectCtx, b: &Buf) -> Vec { - let pr = ctx.params; - let n = pr.f("bubbleAmount").clamp(0.0, 10_000.0) as u32; - let speed = pr.f("bubbleSpeed") as f32; - let wamp = pr.f("wobbleAmplitude") as f32; - let wfreq = pr.f("wobbleFrequency") as f32; - let bsize = pr.f("bubbleSize").max(0.0) as f32; - let seed = ctx.seed.wrapping_mul(0x27d4); - let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); - let s = b.scale as f32; - let t = ctx.time as f32; - let base = lw.min(lh) * 0.035 * bsize; - (0..n) - .map(|i| { - let r = base * (0.5 + h(i, 1, seed)); - let travel = lh + 2.0 * r; - let v = speed * lh * 0.3 * (0.6 + 0.8 * h(i, 2, seed)); - let y = lh + r - (h(i, 3, seed) * travel + v * t).rem_euclid(travel); - let x0 = h(i, 4, seed) * lw; - let x = x0 + wamp * (r / base.max(1e-3)) * (t * wfreq * std::f32::consts::TAU + h(i, 5, seed) * std::f32::consts::TAU).sin(); - let (bx, by) = b.to_px([x as f64, y as f64]); - let (sx, sy) = b.to_px([x0 as f64, y as f64]); - Bubble { x: bx as f32, y: by as f32, r: r * s, sx: sx as f32, sy: sy as f32 } - }) - .collect() -} - -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 src = b.img.clone(); - let mut out = Image::new(src.width, src.height); - raster( - &mut out, - &list, - |q| Some([q.x - q.r - 1.0, q.y - q.r - 1.0, q.x + q.r + 1.0, q.y + q.r + 1.0]), - |q, x, y| { - let (dx, dy) = ((x - q.x) / q.r.max(0.5), (y - q.y) / q.r.max(0.5)); - let d2 = dx * dx + dy * dy; - let d = d2.sqrt(); - let cov = aa((1.0 - d) * q.r); - if cov <= 0.0 { - return None; - } - // 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 (c, sa) = unpremul(p); - let mut c = if sa <= 0.0 { [0.6, 0.7, 0.8] } else { c }; - let mut a = cov; - match shading { - 1 => c = c.map(|v| v * 0.7 + 0.3 * (1.0 - nz)), - 2 => c = c.map(|v| v * (0.5 + 0.5 * nz)), - 3 => a *= nz.powf(0.5), - 4 => a *= 1.0 - nz * 0.8, - _ => {} - } - let hl = (1.0 - ((dx + 0.35).powi(2) + (dy + 0.35).powi(2)) / 0.05).max(0.0) * 0.8; - let c = c.map(|v| v + hl); - Some([c[0] * a, c[1] * a, c[2] * a, a]) - }, - Acc::Over, - ); - b.img = out; - b -} - -// ------------------------------------------------------------------ CC Drizzle - -/// CC Drizzle's live drops this frame (buffer px: centre x, y, ring radius, amplitude); -/// `None` = no drops yet (the layer passes through). -pub fn drizzle_drops(ctx: &EffectCtx, b: &Buf) -> Option> { - let pr = ctx.params; - let rate = pr.f("dripRate").max(0.0); - let life = pr.f("longevity").max(0.01); - let spreading = pr.f("spreading") as f32; - let height = pr.f("rippleHeight") as f32 / 100.0; - let seed = ctx.seed.wrapping_mul(0x165667b1); - let t = ctx.time; - let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); - let s = b.scale as f32; - if rate <= 0.0 || t < 0.0 { - return None; - } - // Drops born at k / rate with hashed positions; alive while younger than the longevity. - let k0 = ((t - life) * rate).floor().max(0.0) as u32; - let k1 = (t * rate).floor() as u32; - let mut drops = Vec::new(); - for k in k0..=k1 { - let born = k as f64 / rate; - let age = (t - born) as f32; - if age < 0.0 || age as f64 >= life { - continue; - } - let (x, y) = b.to_px([(h(k, 1, seed) * lw) as f64, (h(k, 2, seed) * lh) as f64]); - let radius = spreading * age * lh / 400.0 * s; - let amp = height * (1.0 - age / life as f32) * (0.6 + 0.4 * h(k, 3, seed)); - drops.push([x as f32, y as f32, radius, amp]); - } - Some(drops) -} - -fn drizzle(ctx: &EffectCtx, mut b: Buf) -> Buf { - let pr = ctx.params; - let Some(drops) = drizzle_drops(ctx, &b) else { return b }; - let rippling = (pr.f("rippling") / 360.0).max(0.05) as f32; - let disp = pr.f("displacement") as f32; - let li = pr.f("light/lightIntensity") as f32 / 100.0; - let lc = pr.color("light/lightColor"); - let lheight = pr.f("light/lightHeight") as f32 / 100.0; - let ldir = (pr.f("light/lightDirection") as f32).to_radians(); - let ambient = pr.f("shading/ambient") as f32 / 100.0; - let diffuse = pr.f("shading/diffuse") as f32 / 100.0; - let specular = pr.f("shading/specular") as f32 / 100.0; - let rough = pr.f("shading/roughness").max(0.001) as f32; - let lh = ctx.layer_size[1] as f32; - let s = b.scale as f32; - let wavelen = (lh * 0.04 * s).max(2.0) / rippling.clamp(0.25, 4.0); - let width = wavelen * (1.0 + rippling * 2.0); - let field = |x: f32, y: f32| -> f32 { - let mut v = 0.0; - for &[dx, dy, r, a] in &drops { - let d = ((x - dx).powi(2) + (y - dy).powi(2)).sqrt(); - let u = (d - r) / width; - if u.abs() < 1.0 { - let env = 0.5 + 0.5 * (u * std::f32::consts::PI).cos(); - v += a * env * ((d - r) / wavelen * std::f32::consts::TAU).cos(); - } - } - v - }; - let src = b.img.clone(); - let (ls, lcs) = ldir.sin_cos(); - let l = { - let v = [lcs, ls, lheight.max(0.05)]; - let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); - [v[0] / n, v[1] / n, v[2] / n] - }; - b.img.rows_mut().for_each(|(y, row)| { - for (x, px) in row.iter_mut().enumerate() { - let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5); - let gx = field(fx + 1.0, fy) - field(fx - 1.0, fy); - let gy = field(fx, fy + 1.0) - field(fx, fy - 1.0); - if gx == 0.0 && gy == 0.0 { - continue; - } - let p = src.sample_bilinear_clamped((fx - gx * disp * s * 4.0) as f64, (fy - gy * disp * s * 4.0) as f64); - let n = { - let v = [-gx * 6.0, -gy * 6.0, 1.0]; - let m = (v[0] * v[0] + v[1] * v[1] + 1.0).sqrt(); - [v[0] / m, v[1] / m, v[2] / m] - }; - let nl = (n[0] * l[0] + n[1] * l[1] + n[2] * l[2]).max(0.0); - let hz = (l[2] + 1.0) * 0.5; - let hv = { - let v = [l[0] * 0.5, l[1] * 0.5, hz]; - let m = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); - [v[0] / m, v[1] / m, v[2] / m] - }; - let sp = (n[0] * hv[0] + n[1] * hv[1] + n[2] * hv[2]).max(0.0).powf(1.0 / rough) * specular * li; - let shade = 1.0 + (ambient + diffuse * nl - diffuse * l[2]) * li; - let a = p[3]; - *px = [p[0] * shade + sp * lc[0] * a, p[1] * shade + sp * lc[1] * a, p[2] * shade + sp * lc[2] * a, a]; - } - }); - b -} - -// ------------------------------------------------------------------ CC Star Burst - -fn star_burst(ctx: &EffectCtx, b: Buf) -> Buf { - let mut plan = star_burst_plan(ctx, &b); - plan.resolve(&b.img); - plan.finish(b) -} - -fn star_burst_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan { - let pr = ctx.params; - let scatter = pr.f("scatter") as f32; - let speed = pr.f("speed") as f32; - let phase = pr.f("phase") as f32 / 360.0; - let spacing = pr.f("gridSpacing").max(1.0) as f32; - let size = pr.f("size") as f32 / 100.0; - let blend = pr.f("blendWithOriginal") as f32 / 100.0; - let seed = ctx.seed.wrapping_mul(0x3c6e); - let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); - let s = b.scale as f32; - let t = ctx.time as f32; - let nx = (lw / spacing).ceil().max(1.0) as u32; - let ny = (lh / spacing).ceil().max(1.0) as u32; - let (cx, cy) = (lw * 0.5, lh * 0.5); - let mut list: Vec<(f32, Sprite, Tint)> = (0..nx * ny) - .into_par_iter() - .map(|i| { - let (gx, gy) = ((i % nx) as f32 * spacing + spacing * 0.5, (i / nx) as f32 * spacing + spacing * 0.5); - let (sx, sy) = b.to_px([gx as f64, gy as f64]); - // Depth cycles towards the viewer: z in [0, 1), perspective 1 / (1 - 0.9 z). - let z = (h(i, 1, seed) + t * speed * 0.25 + phase).rem_euclid(1.0); - let persp = 1.0 / (1.0 - 0.9 * z); - let jx = hs(i, 2, seed) * scatter * 0.01 * lw * 0.5; - let jy = hs(i, 3, seed) * scatter * 0.01 * lh * 0.5; - let x = cx + (gx - cx + jx) * persp; - let y = cy + (gy - cy + jy) * persp; - let (bx, by) = b.to_px([x as f64, y as f64]); - let r = spacing * 0.5 * size * persp * s; - let fade = (z * 8.0).min(1.0) * ((1.0 - z) * 6.0).min(1.0); - // The layer's colour at the grid point (none where it is clear). - (z, Sprite::new(bx as f32, by as f32, r, [0.0, 0.0, 0.0, fade], Shape::Disc), Tint::Source { x: sx, y: sy }) - }) - .collect(); - list.sort_by(|a, b| a.0.total_cmp(&b.0)); - let (sprites, tints) = list.into_iter().map(|(_, s, t)| (s, t)).unzip(); - SpritePlan { sprites, tints, acc: Acc::Over, post: if blend > 0.0 { Post::Lerp(blend) } else { Post::Replace } } -} - -// ------------------------------------------------------------------ particle engine - -/// Steps per second of the stepped particle simulations. -pub(crate) const SPS: f64 = 60.0; - -/// Number of whole steps reached at layer time `t`. -pub(crate) fn steps_at(t: f64) -> u64 { - if t <= 0.0 { 0 } else { (t * SPS + 1e-6).floor() as u64 } -} - -#[derive(Clone, Debug)] -pub(crate) struct Particle { - pub p: [f32; 3], - pub v: [f32; 3], - pub age: f32, - pub life: f32, - /// Per-particle random in [0, 1) (size variation, colour jitter…). - pub rnd: f32, - pub id: u32, -} - -#[derive(Clone, Debug, Default)] -pub(crate) struct PState { - pub parts: Vec, - pub carry: f64, - pub next_id: u32, -} - -/// Physics configuration (world units; y down). -#[derive(Clone, Copy, Debug)] -pub(crate) struct Phys { - /// Particles per second. - pub rate: f64, - pub life: f32, - pub producer: [f32; 3], - pub radius: [f32; 3], - pub anim: u32, - pub speed: f32, - pub gravity: [f32; 3], - pub resistance: f32, - pub axis: [f32; 3], - pub extra: f32, - pub extra_angle: f32, - pub seed: u32, - pub max: usize, - /// 2D systems keep z = 0. - pub flat: bool, -} - -pub(crate) const PW_ANIMS: &[&str] = - &["Explosive", "Direction Axis", "Cone Axis", "Viscouse", "Twirly", "Vortex", "Fire", "Jet Sideways", "Fractal Omni", "Fractal Uni"]; - -fn norm3(v: [f32; 3]) -> [f32; 3] { - let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); - if l > 1e-9 { [v[0] / l, v[1] / l, v[2] / l] } else { [0.0, -1.0, 0.0] } -} - -fn rand_dir(i: u32, k: u32, seed: u32, flat: bool) -> [f32; 3] { - if flat { - let a = h(i, k, seed) * std::f32::consts::TAU; - return [a.cos(), a.sin(), 0.0]; - } - let z = hs(i, k, seed); - let a = h(i, k + 1, seed) * std::f32::consts::TAU; - let r = (1.0 - z * z).max(0.0).sqrt(); - [r * a.cos(), r * a.sin(), z] -} - -impl Phys { - fn spawn(&self, id: u32) -> Particle { - let seed = self.seed; - let mut p = [0.0f32; 3]; - // Uniform in the producer ellipsoid. - let u = rand_dir(id, 1, seed, self.flat); - let rr = h(id, 3, seed).powf(if self.flat { 0.5 } else { 1.0 / 3.0 }); - for k in 0..3 { - p[k] = self.producer[k] + u[k] * self.radius[k] * rr; - } - if self.flat { - p[2] = 0.0; - } - let ax = norm3(self.axis); - let jitter = rand_dir(id, 5, seed, self.flat); - let dir = match self.anim { - 1 | 9 => norm3([ax[0] + jitter[0] * 0.08, ax[1] + jitter[1] * 0.08, ax[2] + jitter[2] * 0.08]), - 2 => { - let k = (self.extra_angle.to_radians() * 0.5).tan().abs().min(20.0); - norm3([ax[0] + jitter[0] * k * h(id, 8, seed), ax[1] + jitter[1] * k * h(id, 8, seed), ax[2] + jitter[2] * k * h(id, 8, seed)]) - } - 5 => [jitter[0], 0.0, if self.flat { 0.0 } else { jitter[2] }], - 6 => norm3([jitter[0] * 0.25, -1.0, jitter[2] * 0.25]), - 7 => norm3([1.0, jitter[1] * 0.05, jitter[2] * 0.05]), - _ => jitter, - }; - let sp = self.speed * (0.5 + 0.5 * h(id, 9, seed)); - let mut v = [dir[0] * sp, dir[1] * sp, dir[2] * sp]; - if self.flat { - v[2] = 0.0; - } - let life = self.life * (0.8 + 0.4 * h(id, 10, seed)); - Particle { p, v, age: 0.0, life, rnd: h(id, 11, seed), id } - } - - pub(crate) fn step(&self, st: &mut PState, step: u64) { - let dt = (1.0 / SPS) as f32; - let t = step as f32 * dt; - let mut drag = self.resistance; - if self.anim == 3 { - drag += 3.0 * self.extra.max(0.1); - } - let damp = (-drag * dt).exp(); - let seed = self.seed; - st.parts.par_iter_mut().for_each(|q| { - let mut a = self.gravity; - match self.anim { - 4 => { - // Twirly: swirl around the vertical axis through the producer. - let (dx, dz) = (q.p[0] - self.producer[0], q.p[2] - self.producer[2]); - let k = 4.0 * self.extra; - a[0] += -dz * k; - a[2] += dx * k; - if self.flat { - let dy = q.p[1] - self.producer[1]; - a[0] += -dy * k; - a[1] += dx * k; - } - } - 5 => { - // Vortex: strong tangential + inward pull. - let (dx, dy) = (q.p[0] - self.producer[0], q.p[1] - self.producer[1]); - let k = 6.0 * self.extra; - a[0] += -dy * k - dx * 2.0; - a[1] += dx * k - dy * 2.0; - } - 6 => { - // Fire: buoyant, flickering turbulence. - let n = value_noise(q.p[0] * 8.0, q.p[1] * 8.0, t * 3.0, seed) - 0.5; - a[0] += n * 4.0 * self.extra; - a[1] -= self.gravity[1] * 1.5 + 0.3 * self.extra; - } - 8 | 9 => { - let f = 6.0 * self.extra; - a[0] += (value_noise(q.p[0] * 5.0, q.p[1] * 5.0, q.p[2] * 5.0 + t, seed) - 0.5) * f; - a[1] += (value_noise(q.p[0] * 5.0 + 17.0, q.p[1] * 5.0, q.p[2] * 5.0 + t, seed) - 0.5) * f; - if !self.flat { - a[2] += (value_noise(q.p[0] * 5.0, q.p[1] * 5.0 + 31.0, q.p[2] * 5.0 + t, seed) - 0.5) * f; - } - } - _ => {} - } - for k in 0..3 { - q.v[k] = (q.v[k] + a[k] * dt) * damp; - q.p[k] += q.v[k] * dt; - } - if self.flat { - q.p[2] = 0.0; - q.v[2] = 0.0; - } - q.age += dt; - }); - st.parts.retain(|q| q.age < q.life); - st.carry += self.rate / SPS; - let n = st.carry.floor() as u32; - st.carry -= n as f64; - for _ in 0..n { - if st.parts.len() >= self.max { - break; - } - let id = st.next_id; - st.next_id = st.next_id.wrapping_add(1); - // Sub-step birth offset keeps continuous streams smooth. - let mut q = self.spawn(id); - let frac = h(id, 12, seed) * dt; - for k in 0..3 { - q.p[k] += q.v[k] * frac; - } - q.age = frac; - st.parts.push(q); - } - } -} - -impl Phys { - /// The backend description (per-step and per-spawn constants evaluated here, in f32). - pub(crate) fn desc(&self) -> crate::psim::EngineDesc { - let mut drag = self.resistance; - if self.anim == 3 { - drag += 3.0 * self.extra.max(0.1); - } - let dt = (1.0 / SPS) as f32; - crate::psim::EngineDesc { - life: self.life, - producer: self.producer, - radius: self.radius, - anim: self.anim, - speed: self.speed, - gravity: self.gravity, - axis: norm3(self.axis), - extra: self.extra, - cone: (self.extra_angle.to_radians() * 0.5).tan().abs().min(20.0), - damp: (-drag * dt).exp(), - seed: self.seed, - flat: self.flat, - } - } -} - -/// The state at layer time `ctx.time` from the host's particle backend (GPU particles), or -/// `None` (simulate on the CPU). -fn accelerated(ctx: &EffectCtx, key: u64, phys: &Phys) -> Option> { - let backend = ctx.env.host?.particles()?; - let steps = steps_at(ctx.time); - let births = 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, - })) -} - -/// The live particles of a stepped particle effect (`ec.sim.ccparticleworld`, -/// `ec.sim.ccparticlesystems2`) at `ctx.time`: from the host's particle backend when it has -/// one (GPU particles), else from the CPU simulation. For comparing the two. -pub fn particle_state(id: &str, ctx: &EffectCtx) -> Option> { - let (phys, cache, salt) = match id { - "ec.sim.ccparticleworld" => (pw_phys(ctx), &PW_CACHE, 1), - "ec.sim.ccparticlesystems2" => (ps2_phys(ctx), &PS2_CACHE, 2), - _ => 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). -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`. -pub(crate) fn simulate(cache: &SimCache, key: u64, t: f64, phys: &Phys) -> std::sync::Arc { - cache.run(key, steps_at(t), PState::default, |st, i| phys.step(st, i)) -} - -pub(crate) const PARTICLE_TYPES: &[&str] = - &["Line", "Shaded Sphere", "Faded Sphere", "Bubble", "Star", "Motion Polygon", "Tri-Polygon", "Cube", "Textured Square"]; - -/// Colour/size/opacity of a particle at its age. -fn particle_look(q: &Particle, birth: [f32; 4], death: [f32; 4], bsize: f32, dsize: f32, svar: f32, max_op: f32) -> ([f32; 4], f32) { - let u = (q.age / q.life.max(1e-4)).clamp(0.0, 1.0); - let size = (bsize + (dsize - bsize) * u) * (1.0 + svar * (q.rnd * 2.0 - 1.0)); - let fade = (u * 20.0).min(1.0) * ((1.0 - u) * 5.0).min(1.0); - let c = [birth[0] + (death[0] - birth[0]) * u, birth[1] + (death[1] - birth[1]) * u, birth[2] + (death[2] - birth[2]) * u, max_op * fade]; - (c, size.max(0.0)) -} - -fn type_sprite(kind: u32, x: f32, y: f32, r: f32, c: [f32; 4], vx: f32, vy: f32, rot: f32) -> Sprite { - 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 => Shape::Sphere, - 2 => Shape::Faded, - 3 => Shape::Bubble, - 4 => Shape::Star, - 5 => Shape::Line { dx: -vx * 0.05, dy: -vy * 0.05 }, - 6 => Shape::Tri, - _ => Shape::Square, - }; - let rr = match kind { - 0 => (r * 0.08).max(0.5), - 5 => r * 0.5, - _ => r, - }; - Sprite { x, y, r: rr, c, shape, rot } -} - -// ------------------------------------------------------------------ CC Particle World - -static PW_CACHE: SimCache = SimCache::new(6); - -/// CC Particle World's physics. -fn pw_phys(ctx: &EffectCtx) -> Phys { - let pr = ctx.params; - Phys { - rate: pr.f("birthRate").max(0.0) * 120.0, - 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], - anim: pr.e("physics/animation"), - speed: pr.f("physics/velocity") as f32 * 0.5, - gravity: { - let g = pr.f("physics/gravity") as f32; - let gv = - [pr.f("physics/gravityVector/gravityX") as f32, pr.f("physics/gravityVector/gravityY") as f32, pr.f("physics/gravityVector/gravityZ") as f32]; - [gv[0] * g, gv[1] * g, gv[2] * g] - }, - resistance: pr.f("physics/resistance").max(0.0) as f32, - axis: [pr.f("physics/directionAxis/axisX") as f32, pr.f("physics/directionAxis/axisY") as f32, pr.f("physics/directionAxis/axisZ") as f32], - extra: pr.f("physics/extra") as f32, - extra_angle: pr.f("physics/extraAngle") as f32, - seed: (pr.f("extras/randomSeed") as u32).wrapping_mul(0x9e3779b9) ^ ctx.seed, - max: 40_000, - flat: false, - } -} - -fn particle_world(ctx: &EffectCtx, b: Buf) -> Buf { - // #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 post: Option = 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); - 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); - } - } - acc.data.iter_mut().for_each(|p| p.iter_mut().for_each(|v| *v /= n as f32)); - return match post { - Some(p) => p.apply(b, acc), - None => b, - }; - } - match particle_world_plan(ctx, &b) { - Some(plan) => plan.finish(b), - None => b, - } -} - -/// CC Particle World's Extras ▸ Effect Camera parameters (id prefix). -const PW_EFFECT_CAMERA: &str = "extras/effectCamera/"; -/// Effect Camera ▸ FOV's default (degrees). -const PW_FOV: f64 = 50.0; - -/// What CC Particle World sees its particles through. -enum PwCamera { - /// Extras ▸ Effect Camera, in world units (layer widths): `dist` from the world origin (the - /// layer centre) along −z, the world turned by `rot` (`None` = not turned) about the origin, - /// `focal` the scale at unit depth; particles nearer than `near` aren't drawn. - Effect { rot: Option<[[f32; 3]; 3]>, dist: f32, focal: f32, near: f32 }, - /// The comp's active camera layer, on buffer-world points (After Effects then ignores the - /// Effect Camera). - Comp(crate::card3d::Proj), -} - -/// A particle as the camera sees it: depth (larger is farther), buffer pixel, size scale `k` -/// (buffer pixels per world unit = `k · layer width · buffer scale`) and velocity in world units -/// on screen (× the same factor for buffer pixels). -struct PwSeen { - depth: f32, - x: f32, - y: f32, - k: f32, - v: [f32; 2], -} - -impl PwCamera { - fn new(ctx: &EffectCtx, b: &Buf) -> PwCamera { - let [lw, lh] = ctx.layer_size; - if let Some(cam) = ctx.env.host.and_then(|h| h.comp_scene()).filter(|sc| sc.camera_layer).and_then(|sc| sc.camera) { - let c = [b.offset[0] + lw * 0.5 * b.scale, b.offset[1] + lh * 0.5 * b.scale]; - return PwCamera::Comp(crate::card3d::comp_proj(&cam, c, b)); - } - let f = |id: &str, d: f64| ctx.params.get(&format!("{PW_EFFECT_CAMERA}{id}")).map(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(d); - // The camera keeps the origin's plane at the scale Distance gives it as FOV changes - // (moving in for a wider view), so FOV sets how strong the perspective is. Distance 2 - // shows the origin's plane at the layer's own scale. - let half = |fov: f64| (fov.clamp(1.0, 179.0).to_radians() * 0.5).tan(); - let zoom = half(PW_FOV) / half(f("fov", PW_FOV)); - let dist = f("distance", 2.0).max(0.01) * zoom; - let a = [f("rotationX", 0.0), f("rotationY", 0.0), f("rotationZ", 0.0)]; - let rot = (a != [0.0; 3]).then(|| { - let r = crate::card3d::rot_ordered(a[0].to_radians(), a[1].to_radians(), a[2].to_radians(), 0); - r.map(|row| row.map(|v| v as f32)) - }); - PwCamera::Effect { rot, dist: dist as f32, focal: (2.0 * zoom) as f32, near: (0.025 * dist) as f32 } - } - - /// How the camera sees world point `p` moving at `v`; `None` behind or too close to it. - fn see(&self, p: [f32; 3], v: [f32; 3], lw: f32, lh: f32, b: &Buf) -> Option { - match self { - PwCamera::Effect { rot, dist, focal, near } => { - let turn = |r: &[[f32; 3]; 3], q: [f32; 3]| r.map(|row| row[0] * q[0] + row[1] * q[1] + row[2] * q[2]); - let (p, v) = match rot { - Some(r) => (turn(r, p), turn(r, v)), - None => (p, v), - }; - let zc = dist + p[2]; - if zc.is_nan() || zc <= *near { - return None; - } - let k = focal / zc; - let (x, y) = b.to_px([(lw * 0.5 + p[0] * lw * k) as f64, (lh * 0.5 + p[1] * lw * k) as f64]); - Some(PwSeen { depth: zc, x: x as f32, y: y as f32, k, v: [v[0], v[1]] }) - } - PwCamera::Comp(proj) => { - let (s, lw, lh) = (b.scale, lw as f64, lh as f64); - let u = lw * s; - let at = [b.offset[0] + lw * 0.5 * s + p[0] as f64 * u, b.offset[1] + lh * 0.5 * s + p[1] as f64 * u, p[2] as f64 * u]; - let seen = proj.view(at, [v[0] as f64, v[1] as f64, v[2] as f64])?; - let k = seen.scale; - Some(PwSeen { - depth: seen.depth as f32, - x: seen.at[0] as f32, - y: seen.at[1] as f32, - k: k as f32, - v: [(seen.vel[0] / k) as f32, (seen.vel[1] / k) as f32], - }) - } - } - } -} - -/// CC Particle World's sprites (far to near), `None` before anything is born. -fn particle_world_plan(ctx: &EffectCtx, b: &Buf) -> Option { - let pr = ctx.params; - let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); - let phys = pw_phys(ctx); - 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 kind = pr.e("particle/particleType"); - let birth = pr.color("particle/birthColor"); - let death = pr.color("particle/deathColor"); - let (bs, ds) = (pr.f("particle/birthSize") as f32, pr.f("particle/deathSize") as f32); - let svar = pr.f("particle/sizeVariation") as f32 / 100.0; - let max_op = pr.f("particle/maxOpacity") as f32 / 100.0; - let s = b.scale as f32; - let cam = PwCamera::new(ctx, b); - let mut list: Vec<(f32, Sprite)> = st - .parts - .par_iter() - .filter_map(|q| { - 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); - Some((depth, sp)) - }) - .collect(); - list.sort_by(|a, b| b.0.total_cmp(&a.0)); - let sprites: Vec = list.into_iter().map(|(_, s)| s).collect(); - Some(particle_sprites(sprites, pr.e("particle/transferMode"))) -} - -/// The CC particle systems' sprites with their Transfer Mode (Composite, Screen, Add, Black -/// Background). -fn particle_sprites(sprites: Vec, mode: u32) -> SpritePlan { - // The particles replace the layer (After Effects shows no trace of the source in any - // Transfer Mode, which sets how the particles meet each other). - SpritePlan { sprites, tints: vec![], acc: if mode == 2 { Acc::Add } else { Acc::Over }, post: Post::Replace } -} - -// ------------------------------------------------------------------ CC Particle Systems II - -static PS2_CACHE: SimCache = SimCache::new(6); - -/// CC Particle Systems II's physics. -fn ps2_phys(ctx: &EffectCtx) -> Phys { - let pr = ctx.params; - let lh = ctx.layer_size[1] as f32; - let unit = lh.max(1.0); - 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, - 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], - anim: [0u32, 1, 2, 3, 4, 5, 6, 7, 8, 9][pr.e("physics/animation").min(9) as usize], - speed: pr.f("physics/velocity") as f32 * 0.25, - gravity: [0.0, pr.f("physics/gravity") as f32 * 0.5, 0.0], - resistance: pr.f("physics/resistance").max(0.0) as f32, - // AE angles: 0° = up, clockwise. - axis: [dir.sin(), -dir.cos(), 0.0], - extra: pr.f("physics/extra") as f32, - extra_angle: 45.0 * pr.f("physics/extra") as f32, - seed: (pr.f("randomSeed") as u32).wrapping_mul(0x85ebca6b) ^ ctx.seed, - max: 40_000, - flat: true, - } -} - -fn particle_systems2(ctx: &EffectCtx, b: Buf) -> Buf { - particle_systems2_plan(ctx, &b).finish(b) -} - -/// CC Particle Systems II's sprites. -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 kind = pr.e("particle/particleType"); - let birth = pr.color("particle/birthColor"); - let death = pr.color("particle/deathColor"); - let (bs, ds) = (pr.f("particle/birthSize") as f32, pr.f("particle/deathSize") as f32); - let svar = pr.f("particle/sizeVariation") as f32 / 100.0; - let max_op = pr.f("particle/maxOpacity") as f32 / 100.0; - let s = b.scale as f32; - let sprites: Vec = st - .parts - .iter() - .map(|q| { - 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) - }) - .collect(); - particle_sprites(sprites, pr.e("particle/transferMode")) -} - -// ------------------------------------------------------------------ CC Mr. Mercury - -static MERCURY_CACHE: SimCache = SimCache::new(6); - -/// A CC Mr. Mercury blob (buffer px). -#[derive(Clone, Copy, Debug)] -pub struct Blob { - pub x: f32, - pub y: f32, - 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 { - let pr = ctx.params; - let lh = ctx.layer_size[1] as f32; - let unit = lh.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, - 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], - anim: [0u32, 1, 2, 3, 4, 5, 6, 7, 8, 9][pr.e("animation").min(9) as usize], - speed: pr.f("velocity") as f32 * 0.3, - gravity: [0.0, pr.f("gravity") as f32 * 0.5, 0.0], - resistance: pr.f("resistance").max(0.0) as f32, - axis: [dir.sin(), -dir.cos(), 0.0], - extra: pr.f("extra") as f32, - extra_angle: 45.0 * pr.f("extra") as f32, - 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); - let (bs, ds) = (pr.f("blobBirthSize") as f32, pr.f("blobDeathSize") as f32); - let s = b.scale as f32; - st.parts - .iter() - .map(|q| { - let u = (q.age / q.life.max(1e-4)).clamp(0.0, 1.0); - let size = bs + (ds - bs) * u; - let (bx, by) = b.to_px([(q.p[0] * unit) as f64, (q.p[1] * unit) as f64]); - Blob { x: bx as f32, y: by as f32, r: (size * unit * 0.08 * s).max(0.0) } - }) - .filter(|q| q.r > 0.1) - .collect() -} - -fn mr_mercury(ctx: &EffectCtx, mut b: Buf) -> Buf { - let pr = ctx.params; - let blobs = mercury_blobs(ctx, &b); - let influence = pr.f("blobInfluence") as f32 / 100.0; - let s = b.scale as f32; - let (w, hh) = (b.img.width, b.img.height); - // Field and gradient accumulate additively: [f, gx, gy, _]. - let mut field = Image::new(w, hh); - let infl = 1.0 + influence; - raster( - &mut field, - &blobs, - |q| { - let e = q.r * 2.0 * infl; - Some([q.x - e, q.y - e, q.x + e, q.y + e]) - }, - |q, x, y| { - let (dx, dy) = (x - q.x, y - q.y); - let rr = q.r * 2.0 * infl; - let d2 = (dx * dx + dy * dy) / (rr * rr); - if d2 >= 1.0 { - return None; - } - let k = 1.0 - d2; - let f = k * k * k; - let g = -6.0 * k * k / (rr * rr); - Some([f, g * dx, g * dy, 0.0]) - }, - Acc::Add, - ); - let thr = 0.35f32; - let ambient = pr.f("shading/ambient") as f32 / 100.0; - let diffuse = pr.f("shading/diffuse") as f32 / 100.0; - let specular = pr.f("shading/specular") as f32 / 100.0; - let rough = pr.f("shading/roughness").max(0.001) as f32; - let metal = pr.f("shading/metal") as f32 / 100.0; - let li = pr.f("light/lightIntensity") as f32 / 100.0; - let lc = pr.color("light/lightColor"); - let ldir = (pr.f("light/lightDirection") as f32).to_radians(); - let lheight = pr.f("light/lightHeight") as f32 / 100.0; - let l = norm3([ldir.sin(), -ldir.cos(), lheight.max(0.05)]); - let src = b.img.clone(); - let mut out = Image::new(w, hh); - out.rows_mut().for_each(|(y, row)| { - for (x, px) in row.iter_mut().enumerate() { - let fv = field.data[y * w as usize + x]; - let f = fv[0]; - if f <= thr * 0.8 { - continue; - } - let cov = ((f - thr * 0.8) / (thr * 0.4)).clamp(0.0, 1.0); - let n = norm3([fv[1] * 3.0, fv[2] * 3.0, 1.0 / (1.0 + f)]); - // Refract the layer through the blob. - let sp = src.sample_bilinear_clamped(x as f64 + 0.5 - n[0] as f64 * 12.0 * s as f64, y as f64 + 0.5 - n[1] as f64 * 12.0 * s as f64); - let (c, sa) = unpremul(sp); - let base = if sa > 0.0 { c } else { [0.7, 0.7, 0.75] }; - let nl = (n[0] * l[0] + n[1] * l[1] + n[2] * l[2]).max(0.0); - let hv = norm3([l[0], l[1], l[2] + 1.0]); - let spec = (n[0] * hv[0] + n[1] * hv[1] + n[2] * hv[2]).max(0.0).powf(1.0 / rough) * specular * li; - let shade = ambient + diffuse * nl * li; - let cc: [f32; 3] = std::array::from_fn(|k| base[k] * shade + spec * (lc[k] * (1.0 - metal) + base[k] * metal)); - *px = [cc[0] * cov, cc[1] * cov, cc[2] * cov, cov]; - } - }); - b.img = out; - b -} - -// ------------------------------------------------------------------ specs - -pub fn specs() -> Vec { - let transfer = || popup(&["Composite", "Lighten"]); - vec![ - spec( - "ec.sim.ccrainfall", - "CC Rainfall", - vec![ - p("drops", "Drops", num(5000.0), slider(0.0, 100_000.0, 0.0, 20_000.0, 0)), - p("size", "Size", num(1.0), slider(0.0, 50.0, 0.0, 10.0, 2)), - p("sceneDepth", "Scene Depth", num(5000.0), slider(0.0, 20_000.0, 0.0, 10_000.0, 0)), - p("speed", "Speed", num(4000.0), slider(0.0, 20_000.0, 0.0, 10_000.0, 0)), - p("wind", "Wind", num(0.0), slider(-10_000.0, 10_000.0, -2000.0, 2000.0, 1)), - p("windVariation", "Variation % (Wind)", num(0.0), pct()), - p("spread", "Spread", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), - p("color", "Color", col(0.8, 0.8, 0.8), ParamUi::Color), - p("opacity", "Opacity", num(25.0), pct()), - p("transferMode", "Transfer Mode", Value::Enum(1), transfer()), - p("compositeWithOriginal", "Composite With Original", Value::Bool(true), ParamUi::Checkbox), - p("extras/appearance", "Appearance", Value::Enum(0), popup(&["Refracting", "Soft Solid"])), - p("extras/offset", "Offset", pt(0.5, 0.5), ParamUi::Point), - p("extras/randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)), - ], - rainfall, - ), - spec( - "ec.sim.ccsnowfall", - "CC Snowfall", - vec![ - p("flakes", "Flakes", num(10_000.0), slider(0.0, 200_000.0, 0.0, 50_000.0, 0)), - p("size", "Size", num(3.0), slider(0.0, 50.0, 0.0, 20.0, 2)), - p("sizeVariation", "Variation % (Size)", num(50.0), pct()), - p("sceneDepth", "Scene Depth", num(5000.0), slider(0.0, 20_000.0, 0.0, 10_000.0, 0)), - p("speed", "Speed", num(100.0), slider(0.0, 5000.0, 0.0, 1000.0, 1)), - p("speedVariation", "Variation % (Speed)", num(50.0), pct()), - p("wind", "Wind", num(25.0), slider(-5000.0, 5000.0, -500.0, 500.0, 1)), - p("windVariation", "Variation % (Wind)", num(50.0), pct()), - p("spread", "Spread", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), - p("wiggle/wiggleAmount", "Amount", num(2.0), slider(0.0, 100.0, 0.0, 20.0, 2)), - p("wiggle/wiggleAmountVariation", "Variation % (Amount)", num(50.0), pct()), - p("wiggle/wiggleFrequency", "Frequency", num(1.0), slider(0.0, 20.0, 0.0, 5.0, 2)), - p("wiggle/wiggleFrequencyVariation", "Variation % (Frequency)", num(50.0), pct()), - p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color), - p("opacity", "Opacity", num(100.0), pct()), - p("transferMode", "Transfer Mode", Value::Enum(0), transfer()), - p("compositeWithOriginal", "Composite With Original", Value::Bool(true), ParamUi::Checkbox), - p("extras/offset", "Offset", pt(0.5, 0.5), ParamUi::Point), - p("extras/randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)), - ], - snowfall, - ), - spec( - "ec.sim.ccbubbles", - "CC Bubbles", - vec![ - p("bubbleAmount", "Bubble Amount", num(100.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)), - p("bubbleSpeed", "Bubble Speed", num(0.5), slider(-10.0, 10.0, -2.0, 2.0, 2)), - p("wobbleAmplitude", "Wobble Amplitude", num(5.0), slider(0.0, 500.0, 0.0, 100.0, 1)), - p("wobbleFrequency", "Wobble Frequency", num(1.0), slider(0.0, 50.0, 0.0, 10.0, 2)), - p("bubbleSize", "Bubble Size", num(1.0), slider(0.0, 20.0, 0.0, 5.0, 2)), - p("reflectionType", "Reflection Type", Value::Enum(0), popup(&["Liquid", "Metal"])), - p("shadingType", "Shading Type", Value::Enum(0), popup(&["None", "Lighten", "Darken", "Fade Inwards", "Fade Outwards"])), - ], - bubbles, - ), - spec( - "ec.sim.ccdrizzle", - "CC Drizzle", - vec![ - p("dripRate", "Drip Rate", num(10.0), slider(0.0, 200.0, 0.0, 50.0, 2)), - p("longevity", "Longevity (sec)", num(1.0), slider(0.0, 30.0, 0.0, 5.0, 2)), - p("rippling", "Rippling", num(360.0), ParamUi::Angle), - p("displacement", "Displacement", num(5.0), slider(0.0, 100.0, 0.0, 30.0, 2)), - p("rippleHeight", "Ripple Height", num(30.0), slider(0.0, 200.0, 0.0, 100.0, 1)), - p("spreading", "Spreading", num(100.0), slider(0.0, 1000.0, 0.0, 300.0, 1)), - p("light/lightIntensity", "Light Intensity", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), - p("light/lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color), - p("light/lightHeight", "Light Height", num(25.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), - p("light/lightDirection", "Light Direction", num(-45.0), ParamUi::Angle), - p("shading/ambient", "Ambient", num(0.0), slider(-200.0, 200.0, -100.0, 100.0, 1)), - p("shading/diffuse", "Diffuse", num(0.0), slider(0.0, 200.0, 0.0, 100.0, 1)), - p("shading/specular", "Specular", num(20.0), slider(0.0, 200.0, 0.0, 100.0, 1)), - p("shading/roughness", "Roughness", num(0.05), slider(0.001, 1.0, 0.001, 0.5, 3)), - ], - drizzle, - ), - spec( - "ec.sim.ccstarburst", - "CC Star Burst", - vec![ - p("scatter", "Scatter", num(100.0), slider(0.0, 1000.0, 0.0, 300.0, 1)), - p("speed", "Speed", num(1.0), slider(-10.0, 10.0, -4.0, 4.0, 2)), - p("phase", "Phase", num(0.0), ParamUi::Angle), - p("gridSpacing", "Grid Spacing", num(2.0), slider(1.0, 100.0, 1.0, 20.0, 0)), - p("size", "Size", num(100.0), slider(0.0, 1000.0, 0.0, 300.0, 1)), - p("blendWithOriginal", "Blend w. Original", num(0.0), pct()), - ], - star_burst, - ), - spec( - "ec.sim.ccparticleworld", - "CC Particle World", - vec![ - p("birthRate", "Birth Rate", num(0.5), slider(0.0, 100.0, 0.0, 10.0, 2)), - p("longevity", "Longevity (sec)", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)), - p("producer/producerX", "Position X", num(0.0), slider(-10.0, 10.0, -1.0, 1.0, 2)), - p("producer/producerY", "Position Y", num(0.0), slider(-10.0, 10.0, -1.0, 1.0, 2)), - p("producer/producerZ", "Position Z", num(0.0), slider(-10.0, 10.0, -1.0, 1.0, 2)), - p("producer/radiusX", "Radius X", num(0.025), slider(0.0, 10.0, 0.0, 1.0, 3)), - p("producer/radiusY", "Radius Y", num(0.025), slider(0.0, 10.0, 0.0, 1.0, 3)), - p("producer/radiusZ", "Radius Z", num(0.025), slider(0.0, 10.0, 0.0, 1.0, 3)), - p("physics/animation", "Animation", Value::Enum(0), popup(PW_ANIMS)), - p("physics/velocity", "Velocity", num(1.0), slider(-10.0, 10.0, 0.0, 5.0, 2)), - p("physics/inheritVelocity", "Inherit Velocity %", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), - p("physics/gravity", "Gravity", num(0.5), slider(-10.0, 10.0, -2.0, 2.0, 3)), - p("physics/resistance", "Resistance", num(0.0), slider(0.0, 20.0, 0.0, 5.0, 2)), - p("physics/extra", "Extra", num(1.0), slider(0.0, 10.0, 0.0, 2.0, 2)), - p("physics/extraAngle", "Extra Angle", num(360.0), ParamUi::Angle), - p("physics/directionAxis/axisX", "Axis X", num(0.0), slider(-1.0, 1.0, -1.0, 1.0, 2)), - p("physics/directionAxis/axisY", "Axis Y", num(-1.0), slider(-1.0, 1.0, -1.0, 1.0, 2)), - p("physics/directionAxis/axisZ", "Axis Z", num(0.0), slider(-1.0, 1.0, -1.0, 1.0, 2)), - p("physics/gravityVector/gravityX", "Gravity X", num(0.0), slider(-1.0, 1.0, -1.0, 1.0, 2)), - 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("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()), - p("particle/maxOpacity", "Max Opacity", num(75.0), pct()), - p("particle/birthColor", "Birth Color", col(1.0, 1.0, 0.0), ParamUi::Color), - p("particle/deathColor", "Death Color", col(0.6, 0.0, 0.0), ParamUi::Color), - p("particle/transferMode", "Transfer Mode", Value::Enum(0), popup(&["Composite", "Screen", "Add", "Black Matte"])), - p("extras/effectCamera/distance", "Distance", num(2.0), slider(0.0, 100.0, 0.0, 10.0, 2)), - p("extras/effectCamera/rotationX", "Rotation X", num(0.0), ParamUi::Angle), - p("extras/effectCamera/rotationY", "Rotation Y", num(0.0), ParamUi::Angle), - p("extras/effectCamera/rotationZ", "Rotation Z", num(0.0), ParamUi::Angle), - p("extras/effectCamera/fov", "FOV", num(PW_FOV), slider(1.0, 179.0, 10.0, 120.0, 1)), - p("extras/randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)), - ], - particle_world, - ), - spec( - "ec.sim.ccparticlesystems2", - "CC Particle Systems II", - vec![ - p("birthRate", "Birth Rate", num(2.0), slider(0.0, 100.0, 0.0, 20.0, 2)), - p("longevity", "Longevity (sec)", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)), - p("producer/position", "Position", pt(0.5, 0.5), ParamUi::Point), - p("producer/radiusX", "Radius X", num(0.0), slider(0.0, 2000.0, 0.0, 200.0, 1)), - p("producer/radiusY", "Radius Y", num(0.0), slider(0.0, 2000.0, 0.0, 200.0, 1)), - p("physics/animation", "Animation", Value::Enum(0), popup(PW_ANIMS)), - p("physics/velocity", "Velocity", num(2.0), slider(-20.0, 20.0, 0.0, 10.0, 2)), - p("physics/inheritVelocity", "Inherit Velocity %", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), - p("physics/gravity", "Gravity", num(1.0), slider(-20.0, 20.0, -5.0, 5.0, 2)), - p("physics/resistance", "Resistance", num(0.0), slider(0.0, 20.0, 0.0, 5.0, 2)), - p("physics/direction", "Direction", num(0.0), ParamUi::Angle), - p("physics/extra", "Extra", num(1.0), slider(0.0, 10.0, 0.0, 2.0, 2)), - p("particle/particleType", "Particle Type", Value::Enum(0), popup(PARTICLE_TYPES)), - p("particle/birthSize", "Birth Size", num(0.5), slider(0.0, 20.0, 0.0, 5.0, 2)), - p("particle/deathSize", "Death Size", num(1.5), slider(0.0, 20.0, 0.0, 5.0, 2)), - p("particle/sizeVariation", "Size Variation", num(0.0), pct()), - p("particle/maxOpacity", "Max Opacity", num(75.0), pct()), - p("particle/birthColor", "Birth Color", col(1.0, 1.0, 0.0), ParamUi::Color), - p("particle/deathColor", "Death Color", col(0.6, 0.0, 0.0), ParamUi::Color), - p("particle/transferMode", "Transfer Mode", Value::Enum(0), popup(&["Composite", "Screen", "Add", "Black Matte"])), - p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)), - ], - particle_systems2, - ), - spec( - "ec.sim.ccmrmercury", - "CC Mr. Mercury", - vec![ - p("radiusX", "Radius X", num(50.0), slider(0.0, 2000.0, 0.0, 300.0, 1)), - p("radiusY", "Radius Y", num(50.0), slider(0.0, 2000.0, 0.0, 300.0, 1)), - p("producer", "Producer", pt(0.5, 0.5), ParamUi::Point), - p("direction", "Direction", num(0.0), ParamUi::Angle), - p("velocity", "Velocity", num(1.0), slider(-20.0, 20.0, 0.0, 5.0, 2)), - p("birthRate", "Birth Rate", num(1.0), slider(0.0, 20.0, 0.0, 5.0, 2)), - p("longevity", "Longevity (sec)", num(2.0), slider(0.0, 100.0, 0.0, 10.0, 2)), - p("gravity", "Gravity", num(0.5), slider(-20.0, 20.0, -5.0, 5.0, 2)), - p("resistance", "Resistance", num(0.0), slider(0.0, 20.0, 0.0, 5.0, 2)), - p("extra", "Extra", num(1.0), slider(0.0, 10.0, 0.0, 2.0, 2)), - p("animation", "Animation", Value::Enum(0), popup(PW_ANIMS)), - p("blobInfluence", "Blob Influence", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), - p("blobBirthSize", "Blob Birth Size", num(0.5), slider(0.0, 10.0, 0.0, 2.0, 2)), - p("blobDeathSize", "Blob Death Size", num(0.25), slider(0.0, 10.0, 0.0, 2.0, 2)), - p("light/lightIntensity", "Light Intensity", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), - p("light/lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color), - p("light/lightHeight", "Light Height", num(50.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), - p("light/lightDirection", "Light Direction", num(-45.0), ParamUi::Angle), - p("shading/ambient", "Ambient", num(0.0), slider(-200.0, 200.0, -100.0, 100.0, 1)), - p("shading/diffuse", "Diffuse", num(75.0), slider(0.0, 200.0, 0.0, 100.0, 1)), - p("shading/specular", "Specular", num(50.0), slider(0.0, 200.0, 0.0, 100.0, 1)), - p("shading/roughness", "Roughness", num(0.05), slider(0.001, 1.0, 0.001, 0.5, 3)), - p("shading/metal", "Metal", num(100.0), pct()), - ], - mr_mercury, - ), - ] -} +mod common; +mod engine; +mod particle_fx; +mod registry; +mod sprites; +mod weather; + +pub(crate) use common::{h, hs, pct, pt, spec}; +pub use engine::particle_state; +#[cfg(test)] +use engine::{MERCURY, PS2, PW_EFFECT_CAMERA, Stepped, rate_history, sim_key, simulate_rates}; +#[cfg(test)] +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, bubble_sample_bounds, drizzle_drops}; #[cfg(test)] -mod tests { - use super::*; - use crate::{EffectEnv, run_fx}; - - fn grey(w: u32, hh: u32) -> Image { - Image::filled(w, hh, [0.2, 0.2, 0.2, 1.0]) - } - - fn ramp(w: u32, hh: u32) -> Image { - let mut img = Image::new(w, hh); - for y in 0..hh { - for x in 0..w { - img.set(x, y, [x as f32 / w as f32, y as f32 / hh as f32, 0.4, 1.0]); - } - } - img - } - - fn run(id: &str, vals: &[(&str, Value)], img: Image, t: f64) -> Image { - run_fx(id, vals, img, t, EffectEnv::default()).img - } - - fn diff(a: &Image, b: &Image) -> f32 { - a.data.iter().zip(&b.data).map(|(p, q)| (0..4).map(|k| (p[k] - q[k]).abs()).sum::()).sum() - } - - /// Centre of mass (luminance-weighted difference from `base`), `[-1, -1]` when equal. - fn mass(img: &Image, base: &Image) -> [f32; 2] { - let (mut m, mut sx, mut sy) = (0.0, 0.0, 0.0); - for y in 0..img.height { - for x in 0..img.width { - let i = img.idx(x, y); - let d: f32 = (0..3).map(|k| (img.data[i][k] - base.data[i][k]).abs()).sum(); - m += d; - sx += d * x as f32; - sy += d * y as f32; - } - } - if m > 0.0 { [sx / m, sy / m] } else { [-1.0; 2] } - } - - /// [`mass`] in y. - fn mass_y(img: &Image, base: &Image) -> f32 { - mass(img, base)[1] - } - - #[test] - fn rasteriser_over_and_add() { - let s = [Sprite::new(5.0, 5.0, 2.0, [1.0, 0.0, 0.0, 1.0], Shape::Disc)]; - let img = splat(10, 40, &s, Acc::Over); - assert!(img.get(5, 5)[0] > 0.99 && img.get(9, 30)[3] == 0.0); - let two = [s[0], s[0]]; - let add = splat(10, 40, &two, Acc::Add); - assert!(add.get(5, 5)[0] > 1.9); - // Items spanning band boundaries are drawn in every band. - let tall = [Sprite::new(5.0, 16.0, 6.0, [0.0, 1.0, 0.0, 1.0], Shape::Square)]; - let t = splat(10, 40, &tall, Acc::Over); - assert!(t.get(5, 13)[1] > 0.9 && t.get(5, 18)[1] > 0.9); - } - - #[test] - fn rainfall_deterministic_and_falls() { - let vals = [("drops", num(200.0)), ("opacity", num(100.0)), ("extras/appearance", Value::Enum(1)), ("color", col(1.0, 1.0, 1.0))]; - let a = run("ec.sim.ccrainfall", &vals, grey(40, 40), 0.5); - let b = run("ec.sim.ccrainfall", &vals, grey(40, 40), 0.5); - assert_eq!(a, b); - assert!(diff(&a, &grey(40, 40)) > 1.0, "drops visible"); - let none = run("ec.sim.ccrainfall", &[("drops", num(0.0))], grey(40, 40), 0.5); - assert_eq!(none, grey(40, 40)); - let later = run("ec.sim.ccrainfall", &vals, grey(40, 40), 0.52); - assert!(diff(&a, &later) > 0.1, "rain moves"); - } - - #[test] - fn snowfall_deterministic_and_moves() { - let vals = [("flakes", num(300.0))]; - let a = run("ec.sim.ccsnowfall", &vals, grey(40, 40), 1.0); - assert_eq!(a, run("ec.sim.ccsnowfall", &vals, grey(40, 40), 1.0)); - assert!(diff(&a, &grey(40, 40)) > 1.0); - assert!(diff(&a, &run("ec.sim.ccsnowfall", &vals, grey(40, 40), 1.2)) > 0.1); - let off = run("ec.sim.ccsnowfall", &[("flakes", num(300.0)), ("compositeWithOriginal", Value::Bool(false))], grey(40, 40), 1.0); - assert!(off.data.iter().any(|p| p[3] == 0.0), "without original the background is transparent"); - } - - #[test] - fn bubbles_rise() { - let vals = [("bubbleAmount", num(30.0)), ("bubbleSize", num(3.0))]; - let a = run("ec.sim.ccbubbles", &vals, ramp(40, 40), 0.3); - assert_eq!(a, run("ec.sim.ccbubbles", &vals, ramp(40, 40), 0.3)); - assert!(a.data.iter().any(|p| p[3] > 0.5)); - let none = run("ec.sim.ccbubbles", &[("bubbleAmount", num(0.0))], ramp(40, 40), 0.3); - assert!(none.data.iter().all(|p| p[3] == 0.0), "bubbles replace the layer"); - // A single slow bubble moves up over a short time. - let one = [("bubbleAmount", num(1.0)), ("bubbleSize", num(4.0)), ("wobbleAmplitude", num(0.0)), ("bubbleSpeed", num(0.2))]; - let blank = Image::new(40, 40); - let y0 = mass_y(&run("ec.sim.ccbubbles", &one, ramp(40, 40), 0.0), &blank); - let y1 = mass_y(&run("ec.sim.ccbubbles", &one, ramp(40, 40), 0.1), &blank); - assert!(y0 >= 0.0 && y1 >= 0.0 && (y1 < y0 || y0 < 8.0), "{y0} -> {y1}"); - } - - #[test] - fn drizzle_ripples() { - let a = run("ec.sim.ccdrizzle", &[], ramp(48, 48), 0.6); - assert_eq!(a, run("ec.sim.ccdrizzle", &[], ramp(48, 48), 0.6)); - assert!(diff(&a, &ramp(48, 48)) > 0.1); - let none = run("ec.sim.ccdrizzle", &[("dripRate", num(0.0))], ramp(48, 48), 0.6); - assert_eq!(none, ramp(48, 48)); - } - - #[test] - fn star_burst_moves_stars() { - let a = run("ec.sim.ccstarburst", &[("gridSpacing", num(4.0))], ramp(40, 40), 0.5); - assert_eq!(a, run("ec.sim.ccstarburst", &[("gridSpacing", num(4.0))], ramp(40, 40), 0.5)); - assert!(a.data.iter().any(|p| p[3] > 0.0)); - let b = run("ec.sim.ccstarburst", &[("gridSpacing", num(4.0))], ramp(40, 40), 0.9); - assert!(diff(&a, &b) > 0.5); - let full = run("ec.sim.ccstarburst", &[("blendWithOriginal", num(100.0))], ramp(40, 40), 0.5); - assert!(diff(&full, &ramp(40, 40)) < 1e-3); - } - - fn pw_vals(seed: f64) -> Vec<(&'static str, Value)> { - vec![ - ("birthRate", num(1.0)), - ("extras/randomSeed", num(seed)), - ("particle/particleType", Value::Enum(2)), - ("particle/birthSize", num(0.3)), - ("particle/deathSize", num(0.3)), - ] - } - - /// #1195: with the comp's shutter open, the particles smear across it: the average of the - /// sampled sprite fields differs from the sharp frame, touches more pixels and keeps the - /// alpha energy. - #[test] - fn particle_world_motion_blur_trails() { - let v = pw_vals(21.0); - let blank = Image::new(40, 40); - let sharp = run("ec.sim.ccparticleworld", &v, blank.clone(), 2.0); - let blurred = crate::run_fx( - "ec.sim.ccparticleworld", - &v, - blank.clone(), - 2.0, - EffectEnv { frame_rate: 10.0, shutter: Some((360.0, -180.0, 8)), ..Default::default() }, - ) - .img; - assert!(sharp.data.iter().any(|p| p[3] > 0.0), "sharp frame has particles"); - assert_ne!(sharp.data, blurred.data, "the shutter smears the particles"); - let lit = |img: &Image| img.data.iter().filter(|p| p[3] > 0.005).count(); - let sum = |img: &Image| img.data.iter().map(|p| p[3]).sum::(); - assert!(lit(&blurred) > lit(&sharp), "trails light more pixels: {} vs {}", lit(&blurred), lit(&sharp)); - let (sa, ba) = (sum(&sharp), sum(&blurred)); - assert!((ba - sa).abs() < sa * 0.6 + 1.0, "alpha energy roughly kept: {ba} vs {sa}"); - } - - #[test] - fn particle_world_seek_consistent() { - let v = pw_vals(11.0); - let direct = run("ec.sim.ccparticleworld", &v, Image::new(40, 40), 2.0); - let v2 = pw_vals(12.0); - let _ = run("ec.sim.ccparticleworld", &v2, Image::new(40, 40), 1.0); - let stepped = run("ec.sim.ccparticleworld", &v2, Image::new(40, 40), 2.0); - let direct2 = run("ec.sim.ccparticleworld", &v2, Image::new(40, 40), 2.0); - assert_eq!(stepped, direct2); - assert!(direct.data.iter().any(|p| p[3] > 0.0), "particles alive"); - // Before birth nothing is drawn. - let t0 = run("ec.sim.ccparticleworld", &v, Image::new(40, 40), 0.0); - assert!(t0.data.iter().all(|p| p[3] == 0.0)); - } - - #[test] - fn engine_resume_equals_from_scratch() { - for anim in 0..PW_ANIMS.len() as u32 { - let phys = Phys { - rate: 90.0, - life: 1.5, - producer: [0.0; 3], - radius: [0.05; 3], - anim, - speed: 0.5, - gravity: [0.0, 0.5, 0.0], - resistance: 0.2, - axis: [0.0, -1.0, 0.0], - extra: 1.0, - extra_angle: 60.0, - seed: 9, - max: 10_000, - flat: anim % 2 == 0, - }; - let a: SimCache = SimCache::new(2); - let b: SimCache = SimCache::new(2); - let direct = simulate(&a, 1, 2.0, &phys); - let _ = simulate(&b, 1, 1.0, &phys); - let _ = simulate(&b, 1, 0.37, &phys); - let resumed = simulate(&b, 1, 2.0, &phys); - assert_eq!(direct.parts.len(), resumed.parts.len(), "anim {anim}"); - assert!(direct.parts.iter().zip(&resumed.parts).all(|(p, q)| p.p == q.p && p.v == q.v && p.id == q.id), "anim {anim}"); - assert!(!direct.parts.is_empty()); - } - } - - #[test] - fn particle_world_gravity_pulls_down() { - let base = |g: f64| { - vec![ - ("birthRate", num(2.0)), - ("physics/velocity", num(0.2)), - ("physics/gravity", num(g)), - ("particle/particleType", Value::Enum(2)), - ("particle/birthSize", num(0.2)), - ("particle/deathSize", num(0.2)), - ("longevity", num(3.0)), - ] - }; - let blank = Image::new(48, 48); - let up = mass_y(&run("ec.sim.ccparticleworld", &base(0.0), blank.clone(), 1.0), &blank); - let down = mass_y(&run("ec.sim.ccparticleworld", &base(2.0), blank.clone(), 1.0), &blank); - assert!(down > up + 2.0, "{up} {down}"); - } - - /// A comp whose camera is `camera` ([`crate::CompScene::camera`]); `layer`: a camera layer - /// (else the comp's default view). - struct Comp { - camera: [[f64; 4]; 3], - layer: bool, - } - impl crate::EffectHost for Comp { - fn layer(&self, _: u64, _: bool) -> Option { - None - } - fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { - None - } - fn comp_scene(&self) -> Option { - Some(crate::CompScene { camera: Some(self.camera), camera_layer: self.layer, light: None }) - } - } - - /// A camera `d` px in front of layer point `c` looking at it, moved right by `dx` px and - /// zoomed by `zoom` (1 = identity on the layer plane): `c + zoom·d·(p − c − dx) / (z + d)`. - fn cam(c: [f64; 2], d: f64, dx: f64, zoom: f64) -> [[f64; 4]; 3] { - let k = zoom * d; - [[k, 0.0, c[0], d * c[0] - k * (c[0] + dx)], [0.0, k, c[1], d * c[1] - k * c[1]], [0.0, 0.0, 1.0, d]] - } - - fn pw_in(comp: &Comp, vals: &[(&str, Value)]) -> Image { - run_fx("ec.sim.ccparticleworld", vals, Image::new(48, 48), 1.0, EffectEnv { host: Some(comp), ..Default::default() }).img - } - - fn finite(img: &Image) -> bool { - img.data.iter().all(|p| p.iter().all(|c| c.is_finite())) - } - - /// #397: with a camera layer in the comp, the particles are seen through it. - #[test] - fn particle_world_follows_the_comp_camera() { - let v = pw_vals(3.0); - let blank = Image::new(48, 48); - let here = Comp { camera: cam([24.0, 24.0], 96.0, 0.0, 1.0), layer: true }; - let base = pw_in(&here, &v); - assert!(base.data.iter().any(|p| p[3] > 0.0), "particles alive"); - // Moving the camera right moves the particles left. - let moved = pw_in(&Comp { camera: cam([24.0, 24.0], 96.0, 8.0, 1.0), ..here }, &v); - let (a, b) = (mass(&base, &blank), mass(&moved, &blank)); - assert!(b[0] < a[0] - 4.0 && (b[1] - a[1]).abs() < 2.0, "{a:?} -> {b:?}"); - // Zooming in spreads them out. - let zoomed = pw_in(&Comp { camera: cam([24.0, 24.0], 96.0, 0.0, 2.0), ..here }, &v); - let cover = |i: &Image| i.data.iter().filter(|p| p[3] > 0.01).count(); - assert!(cover(&zoomed) > cover(&base), "{} {}", cover(&zoomed), cover(&base)); - // The Effect Camera is ignored then. - let mut turned = v.clone(); - turned.push(("extras/effectCamera/rotationY", num(60.0))); - assert_eq!(pw_in(&here, &turned), base); - // The comp's default view (no camera layer) leaves it to the Effect Camera. - let default_view = Comp { layer: false, ..here }; - assert_eq!(pw_in(&default_view, &v), run("ec.sim.ccparticleworld", &v, blank.clone(), 1.0)); - // A camera facing away, or a degenerate one, draws nothing and never NaN. - for camera in [[[96.0, 0.0, 24.0, 0.0], [0.0, 96.0, 24.0, 0.0], [0.0, 0.0, -1.0, -96.0]], [[0.0; 4]; 3], [[f64::NAN; 4]; 3]] { - assert_eq!(pw_in(&Comp { camera, layer: true }, &v), blank); - } - // A camera among the particles draws the ones in front of it. - let inside = pw_in(&Comp { camera: cam([24.0, 24.0], 1.0, 0.0, 1.0), ..here }, &v); - assert!(finite(&inside)); - } - - /// Extras ▸ Effect Camera: the defaults are the fixed camera Particle World always had. - #[test] - fn particle_world_effect_camera() { - let v = pw_vals(5.0); - let base = run("ec.sim.ccparticleworld", &v, Image::new(48, 48), 1.0); - let with = |extra: &[(&'static str, Value)]| { - let mut w = v.clone(); - w.extend_from_slice(extra); - run("ec.sim.ccparticleworld", &w, Image::new(48, 48), 1.0) - }; - // Missing parameters (projects saved before) read as the defaults. - let spec = crate::find("ec.sim.ccparticleworld").unwrap(); - let mut params = crate::Params { values: spec.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; - params.values.retain(|k, _| !k.starts_with(PW_EFFECT_CAMERA)); - for (k, val) in &v { - params.values.insert(k.to_string(), val.clone()); - } - let ctx = EffectCtx { params: ¶ms, time: 1.0, layer_size: [48.0, 48.0], seed: 1, adjustment: false, env: EffectEnv::default() }; - assert_eq!(crate::apply(spec, &ctx, Buf { img: Image::new(48, 48), offset: [0.0; 2], scale: 1.0 }).img, base); - assert_eq!(with(&[("extras/effectCamera/fov", num(f64::NAN))]), base); - // Turning or pulling back the camera changes the view. - let turned = with(&[("extras/effectCamera/rotationY", num(60.0))]); - assert!(diff(&turned, &base) > 1.0); - let cover = |i: &Image| i.data.iter().filter(|p| p[3] > 0.01).count(); - let far = with(&[("extras/effectCamera/distance", num(4.0))]); - assert!(cover(&far) < cover(&base), "{} {}", cover(&far), cover(&base)); - assert!(diff(&with(&[("extras/effectCamera/fov", num(120.0))]), &base) > 0.1); - // Extremes never panic or produce NaN. - for extra in [ - ("extras/effectCamera/distance", num(0.0)), - ("extras/effectCamera/distance", num(-5.0)), - ("extras/effectCamera/fov", num(0.0)), - ("extras/effectCamera/fov", num(180.0)), - ("extras/effectCamera/rotationX", num(1e12)), - ] { - assert!(finite(&with(std::slice::from_ref(&extra))), "{extra:?}"); - } - // Moving the camera doesn't restart the simulation. - let key = |pr: &crate::Params| { - sim_key(&EffectCtx { params: pr, time: 1.0, layer_size: [48.0, 48.0], seed: 1, adjustment: false, env: EffectEnv::default() }, 1) - }; - let mut moved = params.clone(); - moved.values.insert("extras/effectCamera/rotationZ".into(), num(30.0)); - assert_eq!(key(¶ms), key(&moved)); - moved.values.insert("extras/randomSeed".into(), num(9.0)); - assert_ne!(key(¶ms), key(&moved)); - } - - #[test] - fn particle_systems2_seek_and_direction() { - let v = vec![ - ("producer/position", pt(24.0, 24.0)), - ("birthRate", num(3.0)), - ("physics/gravity", num(0.0)), - ("physics/velocity", num(1.0)), - ("physics/animation", Value::Enum(1)), - ]; - let _ = run("ec.sim.ccparticlesystems2", &v, Image::new(48, 48), 0.5); - let a = run("ec.sim.ccparticlesystems2", &v, Image::new(48, 48), 1.0); - let mut v2 = v.clone(); - v2.push(("randomSeed", num(0.0))); - assert_eq!(a, run("ec.sim.ccparticlesystems2", &v2, Image::new(48, 48), 1.0)); - // Direction 0° (up): particles above the producer. - let blank = Image::new(48, 48); - let y = mass_y(&a, &blank); - assert!((0.0..24.0).contains(&y), "{y}"); - // The particles replace the layer: with none born it is empty. - let none = run("ec.sim.ccparticlesystems2", &[("birthRate", num(0.0))], grey(20, 20), 1.0); - assert_eq!(none, Image::new(20, 20)); - } - - /// CC Particle World and CC Particle Systems II replace their layer with the particles, in - /// every Transfer Mode (which only sets how particles meet each other). SceneYard frames from - /// After Effects 2026 show no trace of the source solid: "Task Preparation UI" (black solids - /// under fireworks hid a white page), "Burnout matchstick" (a red rectangle around the - /// flame), "Documentary Man Falling" (a black block over the light). - #[test] - fn cc_particles_replace_their_layer() { - let red = || { - let mut img = Image::new(48, 48); - img.data.iter_mut().for_each(|p| *p = [0.7, 0.1, 0.1, 1.0]); - img - }; - for id in ["ec.sim.ccparticlesystems2", "ec.sim.ccparticleworld"] { - for mode in 0..4 { - let v = vec![("birthRate", num(3.0)), ("particle/transferMode", Value::Enum(mode))]; - let _ = run(id, &v, red(), 0.5); - let out = run(id, &v, red(), 1.0); - // No source left: the corners, far from the producer, are clear. - for (x, y) in [(0, 0), (47, 0), (0, 47), (47, 47)] { - assert!(out.get(x, y)[3] < 1e-6, "{id} mode {mode} ({x},{y}): {:?}", out.get(x, y)); - } - assert!(out.data.iter().any(|p| p[3] > 0.05), "{id} mode {mode}: particles drawn"); - } - } - } - - #[test] - fn mercury_blobs_and_seek() { - let v = vec![("producer", pt(24.0, 16.0)), ("radiusX", num(5.0)), ("radiusY", num(5.0)), ("birthRate", num(3.0))]; - let _ = run("ec.sim.ccmrmercury", &v, ramp(48, 48), 0.7); - let a = run("ec.sim.ccmrmercury", &v, ramp(48, 48), 1.4); - let mut v2 = v.clone(); - v2.push(("shading/metal", num(100.0))); - let b = run("ec.sim.ccmrmercury", &v2, ramp(48, 48), 1.4); - assert_eq!(a, b); - assert!(a.data.iter().any(|p| p[3] > 0.5), "blobs visible"); - let none = run("ec.sim.ccmrmercury", &[("birthRate", num(0.0))], ramp(48, 48), 1.4); - assert!(none.data.iter().all(|p| p[3] == 0.0)); - } - - #[test] - fn half_resolution_scales_sprites() { - // Downsampled buffers render the same layout at half size. - let s = crate::find("ec.sim.ccsnowfall").unwrap(); - let mut params = crate::Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; - params.values.insert("flakes".into(), num(50.0)); - params.values.insert("offset".into(), pt(20.0, 20.0)); - let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: [40.0, 40.0], seed: 1, adjustment: false, env: EffectEnv::default() }; - let full = crate::apply(s, &ctx, Buf { img: grey(40, 40), offset: [0.0; 2], scale: 1.0 }); - let half = crate::apply(s, &ctx, Buf { img: grey(20, 20), offset: [0.0; 2], scale: 0.5 }); - let sum = |i: &Image| i.data.iter().map(|p| p[0]).sum::() / i.data.len() as f32; - assert!((sum(&full.img) - sum(&half.img)).abs() < 0.05, "{} {}", sum(&full.img), sum(&half.img)); - } -} +mod tests; diff --git a/crates/effects/src/sim/common.rs b/crates/effects/src/sim/common.rs new file mode 100644 index 000000000..294d787f1 --- /dev/null +++ b/crates/effects/src/sim/common.rs @@ -0,0 +1,25 @@ +use super::{EffectSpec, ParamUi, Value, hash1, slider}; + +pub(crate) fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Simulation", params, render, gpu: false, float: true } +} + +pub(crate) fn pt(x: f64, y: f64) -> Value { + Value::Vec2([x, y]) +} + +pub(crate) fn pct() -> ParamUi { + slider(0.0, 100.0, 0.0, 100.0, 1) +} + +/// Deterministic hash → [0, 1). +#[inline] +pub(crate) fn h(i: u32, k: u32, seed: u32) -> f32 { + hash1(i, k, seed) +} + +/// Signed hash → [-1, 1). +#[inline] +pub(crate) fn hs(i: u32, k: u32, seed: u32) -> f32 { + hash1(i, k, seed) * 2.0 - 1.0 +} diff --git a/crates/effects/src/sim/engine.rs b/crates/effects/src/sim/engine.rs new file mode 100644 index 000000000..beda8c318 --- /dev/null +++ b/crates/effects/src/sim/engine.rs @@ -0,0 +1,374 @@ +use super::common::{h, hs}; +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::{Buf, EffectCtx, Image, IntoParallelRefMutIterator, ParallelIterator, SimCache, params_key_except, value_noise}; + +// ------------------------------------------------------------------ particle engine + +/// Steps per second of the stepped particle simulations. +pub(crate) const SPS: f64 = 60.0; + +/// Number of whole steps reached at layer time `t`. +pub(crate) fn steps_at(t: f64) -> u64 { + if t <= 0.0 { 0 } else { (t * SPS + 1e-6).floor() as u64 } +} + +#[derive(Clone, Debug)] +pub(crate) struct Particle { + pub p: [f32; 3], + pub v: [f32; 3], + pub age: f32, + pub life: f32, + /// Per-particle random in [0, 1) (size variation, colour jitter…). + pub rnd: f32, + pub id: u32, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct PState { + pub parts: Vec, + pub carry: f64, + pub next_id: u32, + /// The birth rates of the steps so far (see [`rates_hash`]): a checkpoint is only resumed + /// from with the same history. + pub hist: u64, +} + +/// `hist` after one more step at `rate` particles per second. +fn rates_hash(hist: u64, rate: f64) -> u64 { + (hist ^ rate.to_bits()).wrapping_mul(0x0100_0000_01b3).rotate_left(5) +} + +/// Physics configuration (world units; y down). +#[derive(Clone, Copy, Debug)] +pub(crate) struct Phys { + /// Particles per second. + pub rate: f64, + pub life: f32, + pub producer: [f32; 3], + pub radius: [f32; 3], + pub anim: u32, + pub speed: f32, + pub gravity: [f32; 3], + pub resistance: f32, + pub axis: [f32; 3], + pub extra: f32, + pub extra_angle: f32, + pub seed: u32, + pub max: usize, + /// 2D systems keep z = 0. + pub flat: bool, +} + +pub(crate) const PW_ANIMS: &[&str] = + &["Explosive", "Direction Axis", "Cone Axis", "Viscouse", "Twirly", "Vortex", "Fire", "Jet Sideways", "Fractal Omni", "Fractal Uni"]; + +pub(super) fn norm3(v: [f32; 3]) -> [f32; 3] { + let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); + if l > 1e-9 { [v[0] / l, v[1] / l, v[2] / l] } else { [0.0, -1.0, 0.0] } +} + +fn rand_dir(i: u32, k: u32, seed: u32, flat: bool) -> [f32; 3] { + if flat { + let a = h(i, k, seed) * std::f32::consts::TAU; + return [a.cos(), a.sin(), 0.0]; + } + let z = hs(i, k, seed); + let a = h(i, k + 1, seed) * std::f32::consts::TAU; + let r = (1.0 - z * z).max(0.0).sqrt(); + [r * a.cos(), r * a.sin(), z] +} + +impl Phys { + fn spawn(&self, id: u32) -> Particle { + let seed = self.seed; + let mut p = [0.0f32; 3]; + // Uniform in the producer ellipsoid. + let u = rand_dir(id, 1, seed, self.flat); + let rr = h(id, 3, seed).powf(if self.flat { 0.5 } else { 1.0 / 3.0 }); + for k in 0..3 { + p[k] = self.producer[k] + u[k] * self.radius[k] * rr; + } + if self.flat { + p[2] = 0.0; + } + let ax = norm3(self.axis); + let jitter = rand_dir(id, 5, seed, self.flat); + let dir = match self.anim { + 1 | 9 => norm3([ax[0] + jitter[0] * 0.08, ax[1] + jitter[1] * 0.08, ax[2] + jitter[2] * 0.08]), + 2 => { + let k = (self.extra_angle.to_radians() * 0.5).tan().abs().min(20.0); + norm3([ax[0] + jitter[0] * k * h(id, 8, seed), ax[1] + jitter[1] * k * h(id, 8, seed), ax[2] + jitter[2] * k * h(id, 8, seed)]) + } + 5 => [jitter[0], 0.0, if self.flat { 0.0 } else { jitter[2] }], + 6 => norm3([jitter[0] * 0.25, -1.0, jitter[2] * 0.25]), + 7 => norm3([1.0, jitter[1] * 0.05, jitter[2] * 0.05]), + _ => jitter, + }; + let sp = self.speed * (0.5 + 0.5 * h(id, 9, seed)); + let mut v = [dir[0] * sp, dir[1] * sp, dir[2] * sp]; + if self.flat { + v[2] = 0.0; + } + let life = self.life * (0.8 + 0.4 * h(id, 10, seed)); + 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; + if self.anim == 3 { + drag += 3.0 * self.extra.max(0.1); + } + let damp = (-drag * dt).exp(); + let seed = self.seed; + st.parts.par_iter_mut().for_each(|q| { + let mut a = self.gravity; + match self.anim { + 4 => { + // Twirly: swirl around the vertical axis through the producer. + let (dx, dz) = (q.p[0] - self.producer[0], q.p[2] - self.producer[2]); + let k = 4.0 * self.extra; + a[0] += -dz * k; + a[2] += dx * k; + if self.flat { + let dy = q.p[1] - self.producer[1]; + a[0] += -dy * k; + a[1] += dx * k; + } + } + 5 => { + // Vortex: strong tangential + inward pull. + let (dx, dy) = (q.p[0] - self.producer[0], q.p[1] - self.producer[1]); + let k = 6.0 * self.extra; + a[0] += -dy * k - dx * 2.0; + a[1] += dx * k - dy * 2.0; + } + 6 => { + // Fire: buoyant, flickering turbulence. + let n = value_noise(q.p[0] * 8.0, q.p[1] * 8.0, t * 3.0, seed) - 0.5; + a[0] += n * 4.0 * self.extra; + a[1] -= self.gravity[1] * 1.5 + 0.3 * self.extra; + } + 8 | 9 => { + let f = 6.0 * self.extra; + a[0] += (value_noise(q.p[0] * 5.0, q.p[1] * 5.0, q.p[2] * 5.0 + t, seed) - 0.5) * f; + a[1] += (value_noise(q.p[0] * 5.0 + 17.0, q.p[1] * 5.0, q.p[2] * 5.0 + t, seed) - 0.5) * f; + if !self.flat { + a[2] += (value_noise(q.p[0] * 5.0, q.p[1] * 5.0 + 31.0, q.p[2] * 5.0 + t, seed) - 0.5) * f; + } + } + _ => {} + } + for k in 0..3 { + q.v[k] = (q.v[k] + a[k] * dt) * damp; + q.p[k] += q.v[k] * dt; + } + if self.flat { + q.p[2] = 0.0; + q.v[2] = 0.0; + } + q.age += dt; + }); + st.parts.retain(|q| q.age < q.life); + 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 { + if st.parts.len() >= self.max { + break; + } + let id = st.next_id; + st.next_id = st.next_id.wrapping_add(1); + // Sub-step birth offset keeps continuous streams smooth. + let mut q = self.spawn(id); + let frac = h(id, 12, seed) * dt; + for k in 0..3 { + q.p[k] += q.v[k] * frac; + } + q.age = frac; + st.parts.push(q); + } + } +} + +impl Phys { + /// The backend description (per-step and per-spawn constants evaluated here, in f32). + pub(crate) fn desc(&self) -> crate::psim::EngineDesc { + let mut drag = self.resistance; + if self.anim == 3 { + drag += 3.0 * self.extra.max(0.1); + } + let dt = (1.0 / SPS) as f32; + crate::psim::EngineDesc { + life: self.life, + producer: self.producer, + radius: self.radius, + anim: self.anim, + speed: self.speed, + gravity: self.gravity, + axis: norm3(self.axis), + extra: self.extra, + cone: (self.extra_angle.to_radians() * 0.5).tan().abs().min(20.0), + damp: (-drag * dt).exp(), + seed: self.seed, + flat: self.flat, + } + } +} + +/// The state at layer time `ctx.time` from the host's particle backend (GPU particles), or +/// `None` (simulate on the CPU). `rates`: the birth rate of each step, when it changes over time +/// (else [`Phys::rate`]). +pub(super) fn accelerated(ctx: &EffectCtx, key: u64, phys: &Phys, rates: Option<&[f64]>) -> Option> { + let backend = ctx.env.host?.particles()?; + let steps = steps_at(ctx.time); + let births = match rates { + Some(r) => crate::psim::births_at(steps, phys.max, |i| r.get(i as usize).copied().unwrap_or(0.0))?, + None => crate::psim::births(phys.rate, steps, phys.max)?, + }; + let req = crate::psim::SimRequest { key, steps, births: &births, system: crate::psim::ParticleSystem::Engine(phys.desc()) }; + let parts = backend.simulate(&req)?; + Some(std::sync::Arc::new(PState { + parts: parts.into_iter().map(|q| Particle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect(), + carry: 0.0, + next_id: births.len() as u32, + hist: 0, + })) +} + +/// The birth rate (particles per second) of each step up to layer time `ctx.time`: parameter +/// `id` × `k`, sampled where the step starts (a keyframed or expression-driven Birth Rate only +/// decides the births of its own time, #1373). Without a host that evaluates the effect at +/// other times, the current value for every step. +pub(super) fn rate_history(ctx: &EffectCtx, id: &str, k: f64) -> Vec { + let now = ctx.params.f(id); + // A rate without keyframes or an expression is the same at every step. + let host = ctx.env.host.filter(|h| !h.param_is_static(ctx.env.effect_index, id)); + (0..steps_at(ctx.time)) + .map(|i| { + let v = host.and_then(|h| h.param_at(ctx.env.effect_index, id, i as f64 / SPS)).map_or(now, |v| v.as_f64()); + if v.is_finite() { v.max(0.0) * k } else { 0.0 } + }) + .collect() +} + +/// Run a particle system through `rates` (one birth rate per step, see [`rate_history`]) with +/// `cache`, resuming only from checkpoints reached with the same rates. +pub(super) fn simulate_rates(cache: &SimCache, key: u64, phys: &Phys, rates: &[f64]) -> std::sync::Arc { + // `hist` of the state after each number of steps. + let mut prefix = Vec::with_capacity(rates.len() + 1); + prefix.push(0u64); + for r in rates { + prefix.push(rates_hash(prefix.last().copied().unwrap_or(0), *r)); + } + let rate = |i: u64| rates.get(i as usize).copied().unwrap_or(0.0); + cache.run_where(key, rates.len() as u64, PState::default, |st, i| phys.step_at_rate(st, i, rate(i)), |s, st| prefix.get(s as usize) == Some(&st.hist)) +} + +/// A stepped particle system's particles at `ctx.time`, born at the Birth Rate of their own +/// time (`birthRate` × `k` particles per second, see [`rate_history`]). The cache key is every +/// parameter but Birth Rate (its history is checked through `PState::hist`) and those under +/// `skip`, with `salt`. `gpu`: the host's particle backend may simulate it. +pub(super) struct Stepped<'a> { + pub(super) cache: &'a SimCache, + pub(super) salt: u64, + pub(super) skip: &'a [&'a str], + pub(super) k: f64, + pub(super) gpu: bool, +} + +impl Stepped<'_> { + pub(super) fn state(&self, ctx: &EffectCtx, phys: &Phys) -> std::sync::Arc { + let rates = rate_history(ctx, "birthRate", self.k); + let mut skip = self.skip.to_vec(); + skip.push("birthRate"); + let key = params_key_except(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, self.salt, &skip); + let gpu = if self.gpu { accelerated(ctx, key, phys, Some(&rates)) } else { None }; + gpu.unwrap_or_else(|| simulate_rates(self.cache, key, phys, &rates)) + } +} + +/// CC Particle World. +const PW: Stepped = Stepped { cache: &PW_CACHE, salt: 1, skip: &[PW_EFFECT_CAMERA], k: PW_RATE, gpu: true }; +/// CC Particle Systems II. +pub(super) const PS2: Stepped = Stepped { cache: &PS2_CACHE, salt: 2, skip: &[], k: PS2_RATE, gpu: true }; +/// CC Mr. Mercury (its blobs are simulated on the CPU). +pub(super) const MERCURY: Stepped = Stepped { cache: &MERCURY_CACHE, salt: 3, skip: &[], k: MERCURY_RATE, gpu: false }; + +/// CC Particle World's particles at `ctx.time`, born at the Birth Rate of their own time. +pub(super) fn pw_state(ctx: &EffectCtx, phys: &Phys) -> std::sync::Arc { + PW.state(ctx, phys) +} + +/// The live particles of a stepped particle effect (`ec.sim.ccparticleworld`, +/// `ec.sim.ccparticlesystems2`) at `ctx.time`: from the host's particle backend when it has +/// one (GPU particles), else from the CPU simulation. For comparing the two. +pub fn particle_state(id: &str, ctx: &EffectCtx) -> Option> { + let st = match id { + "ec.sim.ccparticleworld" => pw_state(ctx, &pw_phys(ctx)), + "ec.sim.ccparticlesystems2" => PS2.state(ctx, &ps2_phys(ctx)), + _ => return None, + }; + 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 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 at its constant [`Phys::rate`] to layer time `t` through `cache` (the +/// tests' reference). +#[cfg(test)] +pub(crate) fn simulate(cache: &SimCache, key: u64, t: f64, phys: &Phys) -> std::sync::Arc { + cache.run(key, steps_at(t), PState::default, |st, i| phys.step(st, i)) +} + +pub(crate) const PARTICLE_TYPES: &[&str] = + &["Line", "Shaded Sphere", "Faded Sphere", "Bubble", "Star", "Motion Polygon", "Tri-Polygon", "Cube", "Textured Square"]; + +/// Colour/size/opacity of a particle at its age. +pub(super) fn particle_look(q: &Particle, birth: [f32; 4], death: [f32; 4], bsize: f32, dsize: f32, svar: f32, max_op: f32) -> ([f32; 4], f32) { + let u = (q.age / q.life.max(1e-4)).clamp(0.0, 1.0); + let size = (bsize + (dsize - bsize) * u) * (1.0 + svar * (q.rnd * 2.0 - 1.0)); + let fade = (u * 20.0).min(1.0) * ((1.0 - u) * 5.0).min(1.0); + let c = [birth[0] + (death[0] - birth[0]) * u, birth[1] + (death[1] - birth[1]) * u, birth[2] + (death[2] - birth[2]) * u, max_op * fade]; + (c, size.max(0.0)) +} + +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 => Shape::Sphere, + 2 => Shape::Faded, + 3 => Shape::Bubble, + 4 => Shape::Star, + 5 => Shape::Line { dx: -vx * 0.05, dy: -vy * 0.05 }, + 6 => Shape::Tri, + _ => Shape::Square, + }; + let rr = match kind { + 0 => (r * 0.08).max(0.5), + 5 => r * 0.5, + _ => r, + }; + Sprite { x, y, r: rr, c, shape, rot } +} + +/// CC Particle World's Extras ▸ Effect Camera parameters (id prefix). +pub(super) const PW_EFFECT_CAMERA: &str = "extras/effectCamera/"; diff --git a/crates/effects/src/sim/particle_fx.rs b/crates/effects/src/sim/particle_fx.rs new file mode 100644 index 000000000..c2f493ab5 --- /dev/null +++ b/crates/effects/src/sim/particle_fx.rs @@ -0,0 +1,430 @@ +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 = 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) * 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], + anim: pr.e("physics/animation"), + speed: pr.f("physics/velocity") as f32 * 0.5, + gravity: { + let g = pr.f("physics/gravity") as f32; + let gv = + [pr.f("physics/gravityVector/gravityX") as f32, pr.f("physics/gravityVector/gravityY") as f32, pr.f("physics/gravityVector/gravityZ") as f32]; + [gv[0] * g, gv[1] * g, gv[2] * g] + }, + resistance: pr.f("physics/resistance").max(0.0) as f32, + axis: [pr.f("physics/directionAxis/axisX") as f32, pr.f("physics/directionAxis/axisY") as f32, pr.f("physics/directionAxis/axisZ") as f32], + extra: pr.f("physics/extra") as f32, + extra_angle: pr.f("physics/extraAngle") as f32, + seed: (pr.f("extras/randomSeed") as u32).wrapping_mul(0x9e3779b9) ^ ctx.seed, + max: 40_000, + flat: false, + } +} + +/// 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(w, h); + let mut post: Option = 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 = 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); + } + } + acc.data.iter_mut().for_each(|p| p.iter_mut().for_each(|v| *v /= n as f32)); + return match post { + Some(p) => p.apply(b, acc), + None => b, + }; + } + match particle_world_plan(ctx, &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; + +/// What CC Particle World sees its particles through. +enum PwCamera { + /// Extras ▸ Effect Camera, in world units (layer widths): `dist` from the world origin (the + /// layer centre) along −z, the world turned by `rot` (`None` = not turned) about the origin, + /// `focal` the scale at unit depth; particles nearer than `near` aren't drawn. + Effect { rot: Option<[[f32; 3]; 3]>, dist: f32, focal: f32, near: f32 }, + /// The comp's active camera layer, on buffer-world points (After Effects then ignores the + /// Effect Camera). + Comp(crate::card3d::Proj), +} + +/// A particle as the camera sees it: depth (larger is farther), buffer pixel, size scale `k` +/// (buffer pixels per world unit = `k · layer width · buffer scale`) and velocity in world units +/// on screen (× the same factor for buffer pixels). +struct PwSeen { + depth: f32, + x: f32, + y: f32, + k: f32, + v: [f32; 2], +} + +impl PwCamera { + fn new(ctx: &EffectCtx, b: &Buf) -> PwCamera { + let [lw, lh] = ctx.layer_size; + if let Some(cam) = ctx.env.host.and_then(|h| h.comp_scene()).filter(|sc| sc.camera_layer).and_then(|sc| sc.camera) { + let c = [b.offset[0] + lw * 0.5 * b.scale, b.offset[1] + lh * 0.5 * b.scale]; + return PwCamera::Comp(crate::card3d::comp_proj(&cam, c, b)); + } + let f = |id: &str, d: f64| ctx.params.get(&format!("{PW_EFFECT_CAMERA}{id}")).map(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(d); + // The camera keeps the origin's plane at the scale Distance gives it as FOV changes + // (moving in for a wider view), so FOV sets how strong the perspective is. Distance 2 + // shows the origin's plane at the layer's own scale. + let half = |fov: f64| (fov.clamp(1.0, 179.0).to_radians() * 0.5).tan(); + let zoom = half(PW_FOV) / half(f("fov", PW_FOV)); + let dist = f("distance", 2.0).max(0.01) * zoom; + let a = [f("rotationX", 0.0), f("rotationY", 0.0), f("rotationZ", 0.0)]; + let rot = (a != [0.0; 3]).then(|| { + let r = crate::card3d::rot_ordered(a[0].to_radians(), a[1].to_radians(), a[2].to_radians(), 0); + r.map(|row| row.map(|v| v as f32)) + }); + PwCamera::Effect { rot, dist: dist as f32, focal: (2.0 * zoom) as f32, near: (0.025 * dist) as f32 } + } + + /// How the camera sees world point `p` moving at `v`; `None` behind or too close to it. + fn see(&self, p: [f32; 3], v: [f32; 3], lw: f32, lh: f32, b: &Buf) -> Option { + match self { + PwCamera::Effect { rot, dist, focal, near } => { + let turn = |r: &[[f32; 3]; 3], q: [f32; 3]| r.map(|row| row[0] * q[0] + row[1] * q[1] + row[2] * q[2]); + let (p, v) = match rot { + Some(r) => (turn(r, p), turn(r, v)), + None => (p, v), + }; + let zc = dist + p[2]; + if zc.is_nan() || zc <= *near { + return None; + } + let k = focal / zc; + let (x, y) = b.to_px([(lw * 0.5 + p[0] * lw * k) as f64, (lh * 0.5 + p[1] * lw * k) as f64]); + Some(PwSeen { depth: zc, x: x as f32, y: y as f32, k, v: [v[0], v[1]] }) + } + PwCamera::Comp(proj) => { + let (s, lw, lh) = (b.scale, lw as f64, lh as f64); + let u = lw * s; + let at = [b.offset[0] + lw * 0.5 * s + p[0] as f64 * u, b.offset[1] + lh * 0.5 * s + p[1] as f64 * u, p[2] as f64 * u]; + let seen = proj.view(at, [v[0] as f64, v[1] as f64, v[2] as f64])?; + let k = seen.scale; + Some(PwSeen { + depth: seen.depth as f32, + x: seen.at[0] as f32, + y: seen.at[1] as f32, + k: k as f32, + v: [(seen.vel[0] / k) as f32, (seen.vel[1] / k) as f32], + }) + } + } + } +} + +/// CC Particle World's sprites (far to near), `None` before anything is born. +pub(super) fn particle_world_plan(ctx: &EffectCtx, b: &Buf) -> Option { + let pr = ctx.params; + let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); + let phys = pw_phys(ctx); + if phys.rate <= 0.0 && ctx.time <= 0.0 { + return None; + } + let st = pw_state(ctx, &phys); + let kind = pr.e("particle/particleType"); + let birth = pr.color("particle/birthColor"); + let death = pr.color("particle/deathColor"); + let (bs, ds) = (pr.f("particle/birthSize") as f32, pr.f("particle/deathSize") as f32); + let svar = pr.f("particle/sizeVariation") as f32 / 100.0; + let max_op = pr.f("particle/maxOpacity") as f32 / 100.0; + let s = b.scale as f32; + let cam = PwCamera::new(ctx, b); + let mut list: Vec<(f32, Sprite)> = st + .parts + .par_iter() + .filter_map(|q| { + 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); + Some((depth, sp)) + }) + .collect(); + list.sort_by(|a, b| b.0.total_cmp(&a.0)); + let sprites: Vec = list.into_iter().map(|(_, s)| s).collect(); + Some(particle_sprites(sprites, pr.e("particle/transferMode"))) +} + +/// The CC particle systems' sprites with their Transfer Mode (Composite, Screen, Add, Black +/// Background). +fn particle_sprites(sprites: Vec, mode: u32) -> SpritePlan { + // The particles replace the layer (After Effects shows no trace of the source in any + // Transfer Mode, which sets how the particles meet each other). + SpritePlan { sprites, tints: vec![], acc: if mode == 2 { Acc::Add } else { Acc::Over }, post: Post::Replace } +} + +// ------------------------------------------------------------------ CC Particle Systems II + +pub(super) static PS2_CACHE: SimCache = SimCache::new(6); + +/// Particles per second per unit of CC Particle Systems II's Birth Rate. +pub(super) const PS2_RATE: f64 = 60.0; + +/// CC Particle Systems II's physics. +pub(super) fn ps2_phys(ctx: &EffectCtx) -> Phys { + let pr = ctx.params; + let lh = ctx.layer_size[1] as f32; + let unit = lh.max(1.0); + let pos = pr.v2("producer/position"); + let dir = (pr.f("physics/direction") as f32).to_radians(); + Phys { + 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], + anim: [0u32, 1, 2, 3, 4, 5, 6, 7, 8, 9][pr.e("physics/animation").min(9) as usize], + speed: pr.f("physics/velocity") as f32 * 0.25, + gravity: [0.0, pr.f("physics/gravity") as f32 * 0.5, 0.0], + resistance: pr.f("physics/resistance").max(0.0) as f32, + // AE angles: 0° = up, clockwise. + axis: [dir.sin(), -dir.cos(), 0.0], + extra: pr.f("physics/extra") as f32, + extra_angle: 45.0 * pr.f("physics/extra") as f32, + seed: (pr.f("randomSeed") as u32).wrapping_mul(0x85ebca6b) ^ ctx.seed, + max: 40_000, + flat: true, + } +} + +pub(super) fn particle_systems2(ctx: &EffectCtx, b: Buf) -> Buf { + particle_systems2_plan(ctx, &b).finish(b) +} + +/// CC Particle Systems II's sprites. +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 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"); + let (bs, ds) = (pr.f("particle/birthSize") as f32, pr.f("particle/deathSize") as f32); + let svar = pr.f("particle/sizeVariation") as f32 / 100.0; + let max_op = pr.f("particle/maxOpacity") as f32 / 100.0; + let s = b.scale as f32; + let sprites: Vec = st + .parts + .iter() + .map(|q| { + 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) + }) + .collect(); + particle_sprites(sprites, pr.e("particle/transferMode")) +} + +// ------------------------------------------------------------------ CC Mr. Mercury + +pub(super) static MERCURY_CACHE: SimCache = SimCache::new(6); + +/// Blobs per second per unit of CC Mr. Mercury's Birth Rate. +pub(super) const MERCURY_RATE: f64 = 10.0; + +/// A CC Mr. Mercury blob (buffer px). +#[derive(Clone, Copy, Debug)] +pub struct Blob { + pub x: f32, + pub y: f32, + pub r: f32, +} + +/// CC Mr. Mercury's physics. +pub(super) fn mercury_phys(ctx: &EffectCtx) -> Phys { + let pr = ctx.params; + 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(); + 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], + anim: [0u32, 1, 2, 3, 4, 5, 6, 7, 8, 9][pr.e("animation").min(9) as usize], + speed: pr.f("velocity") as f32 * 0.3, + gravity: [0.0, pr.f("gravity") as f32 * 0.5, 0.0], + resistance: pr.f("resistance").max(0.0) as f32, + axis: [dir.sin(), -dir.cos(), 0.0], + extra: pr.f("extra") as f32, + extra_angle: 45.0 * pr.f("extra") as f32, + seed: ctx.seed.wrapping_mul(0xc2b2ae35), + max: 4_000, + flat: true, + } +} + +/// CC Mr. Mercury's blobs this frame, from its particle simulation (for buffer geometry `b`). +pub fn mercury_blobs(ctx: &EffectCtx, b: &Buf) -> Vec { + let pr = ctx.params; + let unit = (ctx.layer_size[1] as f32).max(1.0); + let phys = mercury_phys(ctx); + let st = MERCURY.state(ctx, &phys); + let (bs, ds) = (pr.f("blobBirthSize") as f32, pr.f("blobDeathSize") as f32); + let s = b.scale as f32; + st.parts + .iter() + .map(|q| { + let u = (q.age / q.life.max(1e-4)).clamp(0.0, 1.0); + let size = bs + (ds - bs) * u; + let (bx, by) = b.to_px([(q.p[0] * unit) as f64, (q.p[1] * unit) as f64]); + Blob { x: bx as f32, y: by as f32, r: (size * unit * 0.08 * s).max(0.0) } + }) + .filter(|q| q.r > 0.1) + .collect() +} + +pub(super) fn mr_mercury(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let blobs = mercury_blobs(ctx, &b); + let influence = pr.f("blobInfluence") as f32 / 100.0; + let s = b.scale as f32; + let (w, hh) = (b.img.width, b.img.height); + // Field and gradient accumulate additively: [f, gx, gy, _]. + let mut field = Image::new(w, hh); + let infl = 1.0 + influence; + raster( + &mut field, + &blobs, + |q| { + let e = q.r * 2.0 * infl; + Some([q.x - e, q.y - e, q.x + e, q.y + e]) + }, + |q, x, y| { + let (dx, dy) = (x - q.x, y - q.y); + let rr = q.r * 2.0 * infl; + let d2 = (dx * dx + dy * dy) / (rr * rr); + if d2 >= 1.0 { + return None; + } + let k = 1.0 - d2; + let f = k * k * k; + let g = -6.0 * k * k / (rr * rr); + Some([f, g * dx, g * dy, 0.0]) + }, + Acc::Add, + ); + let thr = 0.35f32; + let ambient = pr.f("shading/ambient") as f32 / 100.0; + let diffuse = pr.f("shading/diffuse") as f32 / 100.0; + let specular = pr.f("shading/specular") as f32 / 100.0; + let rough = pr.f("shading/roughness").max(0.001) as f32; + let metal = pr.f("shading/metal") as f32 / 100.0; + let li = pr.f("light/lightIntensity") as f32 / 100.0; + let lc = pr.color("light/lightColor"); + let ldir = (pr.f("light/lightDirection") as f32).to_radians(); + let lheight = pr.f("light/lightHeight") as f32 / 100.0; + let l = norm3([ldir.sin(), -ldir.cos(), lheight.max(0.05)]); + let src = b.img.clone(); + let mut out = Image::new(w, hh); + out.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let fv = field.data[y * w as usize + x]; + let f = fv[0]; + if f <= thr * 0.8 { + continue; + } + let cov = ((f - thr * 0.8) / (thr * 0.4)).clamp(0.0, 1.0); + let n = norm3([fv[1] * 3.0, fv[2] * 3.0, 1.0 / (1.0 + f)]); + // Refract the layer through the blob. + let sp = src.sample_bilinear_clamped(x as f64 + 0.5 - n[0] as f64 * 12.0 * s as f64, y as f64 + 0.5 - n[1] as f64 * 12.0 * s as f64); + let (c, sa) = unpremul(sp); + let base = if sa > 0.0 { c } else { [0.7, 0.7, 0.75] }; + let nl = (n[0] * l[0] + n[1] * l[1] + n[2] * l[2]).max(0.0); + let hv = norm3([l[0], l[1], l[2] + 1.0]); + let spec = (n[0] * hv[0] + n[1] * hv[1] + n[2] * hv[2]).max(0.0).powf(1.0 / rough) * specular * li; + let shade = ambient + diffuse * nl * li; + let cc: [f32; 3] = std::array::from_fn(|k| base[k] * shade + spec * (lc[k] * (1.0 - metal) + base[k] * metal)); + *px = [cc[0] * cov, cc[1] * cov, cc[2] * cov, cov]; + } + }); + b.img = out; + b +} diff --git a/crates/effects/src/sim/registry.rs b/crates/effects/src/sim/registry.rs new file mode 100644 index 000000000..f55b99e27 --- /dev/null +++ b/crates/effects/src/sim/registry.rs @@ -0,0 +1,230 @@ +use super::common::{pct, pt, spec}; +use super::engine::{PARTICLE_TYPES, PW_ANIMS}; +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::{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 +/// render pass). +pub fn sprite_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option { + match id { + "ec.sim.ccrainfall" => Some(rainfall_plan(ctx, b)), + "ec.sim.ccsnowfall" => Some(snowfall_plan(ctx, b)), + "ec.sim.ccstarburst" => Some(star_burst_plan(ctx, b)), + "ec.sim.cchair" => crate::sim3::hair_plan(ctx, b), + "ec.sim.waveworld" => crate::sim2::wave_world_plan(ctx, b).and_then(|p| match p { + crate::sim2::WavePlan::Wire(sp) => Some(sp), + crate::sim2::WavePlan::Height { .. } => None, + }), + "ec.sim.foam" => crate::sim2::foam_plan(ctx, b), + "ec.sim.ccparticleworld" => particle_world_plan(ctx, b), + "ec.sim.ccparticlesystems2" => Some(particle_systems2_plan(ctx, b)), + _ => None, + } +} + +// ------------------------------------------------------------------ specs + +pub fn specs() -> Vec { + let transfer = || popup(&["Composite", "Lighten"]); + vec![ + spec( + "ec.sim.ccrainfall", + "CC Rainfall", + vec![ + p("drops", "Drops", num(5000.0), slider(0.0, 100_000.0, 0.0, 20_000.0, 0)), + p("size", "Size", num(1.0), slider(0.0, 50.0, 0.0, 10.0, 2)), + p("sceneDepth", "Scene Depth", num(5000.0), slider(0.0, 20_000.0, 0.0, 10_000.0, 0)), + p("speed", "Speed", num(4000.0), slider(0.0, 20_000.0, 0.0, 10_000.0, 0)), + p("wind", "Wind", num(0.0), slider(-10_000.0, 10_000.0, -2000.0, 2000.0, 1)), + p("windVariation", "Variation % (Wind)", num(0.0), pct()), + p("spread", "Spread", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("color", "Color", col(0.8, 0.8, 0.8), ParamUi::Color), + p("opacity", "Opacity", num(25.0), pct()), + p("transferMode", "Transfer Mode", Value::Enum(1), transfer()), + p("compositeWithOriginal", "Composite With Original", Value::Bool(true), ParamUi::Checkbox), + p("extras/appearance", "Appearance", Value::Enum(0), popup(&["Refracting", "Soft Solid"])), + p("extras/offset", "Offset", pt(0.5, 0.5), ParamUi::Point), + p("extras/randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)), + ], + rainfall, + ), + spec( + "ec.sim.ccsnowfall", + "CC Snowfall", + vec![ + p("flakes", "Flakes", num(10_000.0), slider(0.0, 200_000.0, 0.0, 50_000.0, 0)), + p("size", "Size", num(3.0), slider(0.0, 50.0, 0.0, 20.0, 2)), + p("sizeVariation", "Variation % (Size)", num(50.0), pct()), + p("sceneDepth", "Scene Depth", num(5000.0), slider(0.0, 20_000.0, 0.0, 10_000.0, 0)), + p("speed", "Speed", num(100.0), slider(0.0, 5000.0, 0.0, 1000.0, 1)), + p("speedVariation", "Variation % (Speed)", num(50.0), pct()), + p("wind", "Wind", num(25.0), slider(-5000.0, 5000.0, -500.0, 500.0, 1)), + p("windVariation", "Variation % (Wind)", num(50.0), pct()), + p("spread", "Spread", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("wiggle/wiggleAmount", "Amount", num(2.0), slider(0.0, 100.0, 0.0, 20.0, 2)), + p("wiggle/wiggleAmountVariation", "Variation % (Amount)", num(50.0), pct()), + p("wiggle/wiggleFrequency", "Frequency", num(1.0), slider(0.0, 20.0, 0.0, 5.0, 2)), + p("wiggle/wiggleFrequencyVariation", "Variation % (Frequency)", num(50.0), pct()), + p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("opacity", "Opacity", num(100.0), pct()), + p("transferMode", "Transfer Mode", Value::Enum(0), transfer()), + p("compositeWithOriginal", "Composite With Original", Value::Bool(true), ParamUi::Checkbox), + p("extras/offset", "Offset", pt(0.5, 0.5), ParamUi::Point), + p("extras/randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)), + ], + snowfall, + ), + spec( + "ec.sim.ccbubbles", + "CC Bubbles", + vec![ + p("bubbleAmount", "Bubble Amount", num(100.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)), + p("bubbleSpeed", "Bubble Speed", num(0.5), slider(-10.0, 10.0, -2.0, 2.0, 2)), + p("wobbleAmplitude", "Wobble Amplitude", num(5.0), slider(0.0, 500.0, 0.0, 100.0, 1)), + p("wobbleFrequency", "Wobble Frequency", num(1.0), slider(0.0, 50.0, 0.0, 10.0, 2)), + p("bubbleSize", "Bubble Size", num(1.0), slider(0.0, 20.0, 0.0, 5.0, 2)), + p("reflectionType", "Reflection Type", Value::Enum(0), popup(&["Liquid", "Metal"])), + p("shadingType", "Shading Type", Value::Enum(0), popup(&["None", "Lighten", "Darken", "Fade Inwards", "Fade Outwards"])), + ], + bubbles, + ), + spec( + "ec.sim.ccdrizzle", + "CC Drizzle", + vec![ + p("dripRate", "Drip Rate", num(10.0), slider(0.0, 200.0, 0.0, 50.0, 2)), + p("longevity", "Longevity (sec)", num(1.0), slider(0.0, 30.0, 0.0, 5.0, 2)), + p("rippling", "Rippling", num(360.0), ParamUi::Angle), + p("displacement", "Displacement", num(5.0), slider(0.0, 100.0, 0.0, 30.0, 2)), + p("rippleHeight", "Ripple Height", num(30.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("spreading", "Spreading", num(100.0), slider(0.0, 1000.0, 0.0, 300.0, 1)), + p("light/lightIntensity", "Light Intensity", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), + p("light/lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("light/lightHeight", "Light Height", num(25.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("light/lightDirection", "Light Direction", num(-45.0), ParamUi::Angle), + p("shading/ambient", "Ambient", num(0.0), slider(-200.0, 200.0, -100.0, 100.0, 1)), + p("shading/diffuse", "Diffuse", num(0.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("shading/specular", "Specular", num(20.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("shading/roughness", "Roughness", num(0.05), slider(0.001, 1.0, 0.001, 0.5, 3)), + ], + drizzle, + ), + spec( + "ec.sim.ccstarburst", + "CC Star Burst", + vec![ + p("scatter", "Scatter", num(100.0), slider(0.0, 1000.0, 0.0, 300.0, 1)), + p("speed", "Speed", num(1.0), slider(-10.0, 10.0, -4.0, 4.0, 2)), + p("phase", "Phase", num(0.0), ParamUi::Angle), + p("gridSpacing", "Grid Spacing", num(2.0), slider(1.0, 100.0, 1.0, 20.0, 0)), + p("size", "Size", num(100.0), slider(0.0, 1000.0, 0.0, 300.0, 1)), + p("blendWithOriginal", "Blend w. Original", num(0.0), pct()), + ], + star_burst, + ), + spec( + "ec.sim.ccparticleworld", + "CC Particle World", + vec![ + p("birthRate", "Birth Rate", num(0.5), slider(0.0, 100.0, 0.0, 10.0, 2)), + p("longevity", "Longevity (sec)", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)), + p("producer/producerX", "Position X", num(0.0), slider(-10.0, 10.0, -1.0, 1.0, 2)), + p("producer/producerY", "Position Y", num(0.0), slider(-10.0, 10.0, -1.0, 1.0, 2)), + p("producer/producerZ", "Position Z", num(0.0), slider(-10.0, 10.0, -1.0, 1.0, 2)), + p("producer/radiusX", "Radius X", num(0.025), slider(0.0, 10.0, 0.0, 1.0, 3)), + p("producer/radiusY", "Radius Y", num(0.025), slider(0.0, 10.0, 0.0, 1.0, 3)), + p("producer/radiusZ", "Radius Z", num(0.025), slider(0.0, 10.0, 0.0, 1.0, 3)), + p("physics/animation", "Animation", Value::Enum(0), popup(PW_ANIMS)), + p("physics/velocity", "Velocity", num(1.0), slider(-10.0, 10.0, 0.0, 5.0, 2)), + p("physics/inheritVelocity", "Inherit Velocity %", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("physics/gravity", "Gravity", num(0.5), slider(-10.0, 10.0, -2.0, 2.0, 3)), + p("physics/resistance", "Resistance", num(0.0), slider(0.0, 20.0, 0.0, 5.0, 2)), + p("physics/extra", "Extra", num(1.0), slider(0.0, 10.0, 0.0, 2.0, 2)), + p("physics/extraAngle", "Extra Angle", num(360.0), ParamUi::Angle), + p("physics/directionAxis/axisX", "Axis X", num(0.0), slider(-1.0, 1.0, -1.0, 1.0, 2)), + p("physics/directionAxis/axisY", "Axis Y", num(-1.0), slider(-1.0, 1.0, -1.0, 1.0, 2)), + p("physics/directionAxis/axisZ", "Axis Z", num(0.0), slider(-1.0, 1.0, -1.0, 1.0, 2)), + p("physics/gravityVector/gravityX", "Gravity X", num(0.0), slider(-1.0, 1.0, -1.0, 1.0, 2)), + 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()), + p("particle/maxOpacity", "Max Opacity", num(75.0), pct()), + p("particle/birthColor", "Birth Color", col(1.0, 1.0, 0.0), ParamUi::Color), + p("particle/deathColor", "Death Color", col(0.6, 0.0, 0.0), ParamUi::Color), + p("particle/transferMode", "Transfer Mode", Value::Enum(0), popup(&["Composite", "Screen", "Add", "Black Matte"])), + p("extras/effectCamera/distance", "Distance", num(2.0), slider(0.0, 100.0, 0.0, 10.0, 2)), + p("extras/effectCamera/rotationX", "Rotation X", num(0.0), ParamUi::Angle), + p("extras/effectCamera/rotationY", "Rotation Y", num(0.0), ParamUi::Angle), + p("extras/effectCamera/rotationZ", "Rotation Z", num(0.0), ParamUi::Angle), + p("extras/effectCamera/fov", "FOV", num(PW_FOV), slider(1.0, 179.0, 10.0, 120.0, 1)), + p("extras/randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)), + ], + particle_world, + ), + spec( + "ec.sim.ccparticlesystems2", + "CC Particle Systems II", + vec![ + p("birthRate", "Birth Rate", num(2.0), slider(0.0, 100.0, 0.0, 20.0, 2)), + p("longevity", "Longevity (sec)", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)), + p("producer/position", "Position", pt(0.5, 0.5), ParamUi::Point), + p("producer/radiusX", "Radius X", num(0.0), slider(0.0, 2000.0, 0.0, 200.0, 1)), + p("producer/radiusY", "Radius Y", num(0.0), slider(0.0, 2000.0, 0.0, 200.0, 1)), + p("physics/animation", "Animation", Value::Enum(0), popup(PW_ANIMS)), + p("physics/velocity", "Velocity", num(2.0), slider(-20.0, 20.0, 0.0, 10.0, 2)), + p("physics/inheritVelocity", "Inherit Velocity %", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("physics/gravity", "Gravity", num(1.0), slider(-20.0, 20.0, -5.0, 5.0, 2)), + p("physics/resistance", "Resistance", num(0.0), slider(0.0, 20.0, 0.0, 5.0, 2)), + p("physics/direction", "Direction", num(0.0), ParamUi::Angle), + p("physics/extra", "Extra", num(1.0), slider(0.0, 10.0, 0.0, 2.0, 2)), + p("particle/particleType", "Particle Type", Value::Enum(0), popup(PARTICLE_TYPES)), + p("particle/birthSize", "Birth Size", num(0.5), slider(0.0, 20.0, 0.0, 5.0, 2)), + p("particle/deathSize", "Death Size", num(1.5), slider(0.0, 20.0, 0.0, 5.0, 2)), + p("particle/sizeVariation", "Size Variation", num(0.0), pct()), + p("particle/maxOpacity", "Max Opacity", num(75.0), pct()), + p("particle/birthColor", "Birth Color", col(1.0, 1.0, 0.0), ParamUi::Color), + p("particle/deathColor", "Death Color", col(0.6, 0.0, 0.0), ParamUi::Color), + p("particle/transferMode", "Transfer Mode", Value::Enum(0), popup(&["Composite", "Screen", "Add", "Black Matte"])), + p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)), + ], + particle_systems2, + ), + spec( + "ec.sim.ccmrmercury", + "CC Mr. Mercury", + vec![ + p("radiusX", "Radius X", num(50.0), slider(0.0, 2000.0, 0.0, 300.0, 1)), + p("radiusY", "Radius Y", num(50.0), slider(0.0, 2000.0, 0.0, 300.0, 1)), + p("producer", "Producer", pt(0.5, 0.5), ParamUi::Point), + p("direction", "Direction", num(0.0), ParamUi::Angle), + p("velocity", "Velocity", num(1.0), slider(-20.0, 20.0, 0.0, 5.0, 2)), + p("birthRate", "Birth Rate", num(1.0), slider(0.0, 20.0, 0.0, 5.0, 2)), + p("longevity", "Longevity (sec)", num(2.0), slider(0.0, 100.0, 0.0, 10.0, 2)), + p("gravity", "Gravity", num(0.5), slider(-20.0, 20.0, -5.0, 5.0, 2)), + p("resistance", "Resistance", num(0.0), slider(0.0, 20.0, 0.0, 5.0, 2)), + p("extra", "Extra", num(1.0), slider(0.0, 10.0, 0.0, 2.0, 2)), + p("animation", "Animation", Value::Enum(0), popup(PW_ANIMS)), + p("blobInfluence", "Blob Influence", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), + p("blobBirthSize", "Blob Birth Size", num(0.5), slider(0.0, 10.0, 0.0, 2.0, 2)), + p("blobDeathSize", "Blob Death Size", num(0.25), slider(0.0, 10.0, 0.0, 2.0, 2)), + p("light/lightIntensity", "Light Intensity", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), + p("light/lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("light/lightHeight", "Light Height", num(50.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("light/lightDirection", "Light Direction", num(-45.0), ParamUi::Angle), + p("shading/ambient", "Ambient", num(0.0), slider(-200.0, 200.0, -100.0, 100.0, 1)), + p("shading/diffuse", "Diffuse", num(75.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("shading/specular", "Specular", num(50.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("shading/roughness", "Roughness", num(0.05), slider(0.001, 1.0, 0.001, 0.5, 3)), + p("shading/metal", "Metal", num(100.0), pct()), + ], + mr_mercury, + ), + ] +} diff --git a/crates/effects/src/sim/sprites.rs b/crates/effects/src/sim/sprites.rs new file mode 100644 index 000000000..55ba594b4 --- /dev/null +++ b/crates/effects/src/sim/sprites.rs @@ -0,0 +1,361 @@ +use super::{Buf, Image, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, ParallelIterator, ParallelSliceMut, Px, unpremul}; + +// ------------------------------------------------------------------ band rasteriser + +const BAND: usize = 16; + +/// How rasterised items combine with what is already in the target. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Acc { + /// Premultiplied "over" (later items on top). + Over, + /// Additive (glows, light). + Add, +} + +/// Rasterise many small items into `img` (buffer pixels). `bbox` gives an item's pixel bounds +/// `[x0, y0, x1, y1]` (or `None` to skip), `shade` its premultiplied colour at a pixel centre. +/// Items are bucketed into horizontal bands rendered in parallel; within a band they are drawn +/// in slice order. +pub(crate) fn raster( + img: &mut Image, + items: &[T], + bbox: impl Fn(&T) -> Option<[f32; 4]> + Sync, + shade: impl Fn(&T, f32, f32) -> Option + Sync, + acc: Acc, +) { + let (w, hgt) = (img.width as usize, img.height as usize); + if w == 0 || hgt == 0 || items.is_empty() { + return; + } + let nb = hgt.div_ceil(BAND); + let boxes: Vec> = items + .par_iter() + .map(|it| { + let b = bbox(it)?; + if !(b[0].is_finite() && b[1].is_finite() && b[2].is_finite() && b[3].is_finite()) { + return None; + } + let x0 = b[0].floor().max(0.0) as i32; + let y0 = b[1].floor().max(0.0) as i32; + let x1 = (b[2].ceil() as i32).min(w as i32 - 1); + let y1 = (b[3].ceil() as i32).min(hgt as i32 - 1); + if x1 < x0 || y1 < y0 || b[2] < 0.0 || b[3] < 0.0 { + return None; + } + Some([x0, y0, x1, y1]) + }) + .collect(); + let mut buckets: Vec> = vec![Vec::new(); nb]; + for (i, b) in boxes.iter().enumerate() { + if let Some(b) = b { + for band in (b[1] as usize / BAND)..=(b[3] as usize / BAND).min(nb - 1) { + buckets[band].push(i as u32); + } + } + } + img.data.par_chunks_mut(w * BAND).enumerate().for_each(|(band, chunk)| { + let by0 = band * BAND; + let rows = chunk.len() / w; + for &i in &buckets[band] { + let Some(b) = boxes[i as usize] else { continue }; + let it = &items[i as usize]; + let ya = (b[1] as usize).max(by0); + let yb = (b[3] as usize).min(by0 + rows - 1); + for y in ya..=yb { + let row = &mut chunk[(y - by0) * w..(y - by0 + 1) * w]; + for x in b[0] as usize..=b[2] as usize { + if let Some(c) = shade(it, x as f32 + 0.5, y as f32 + 0.5) { + let d = &mut row[x]; + match acc { + Acc::Over => { + let k = 1.0 - c[3]; + *d = [c[0] + d[0] * k, c[1] + d[1] * k, c[2] + d[2] * k, c[3] + d[3] * k]; + } + Acc::Add => { + *d = [d[0] + c[0], d[1] + c[1], d[2] + c[2], (d[3] + c[3]).min(1.0)]; + } + } + } + } + } + } + }); +} + +// ------------------------------------------------------------------ sprites + +#[derive(Clone, Copy, PartialEq, Debug)] +pub enum Shape { + /// Soft round dot (Gaussian-like falloff). + Soft, + /// Hard anti-aliased disc. + Disc, + /// Lit sphere (upper-left light, specular). + Sphere, + /// Disc fading linearly to its rim. + Faded, + /// Thin ring with a highlight. + Bubble, + /// Four-point star. + Star, + /// Rotated square (angle in `rot`). + Square, + /// Triangle (angle in `rot`). + Tri, + /// Segment from (x, y) by (dx, dy), half-width `r`. + Line { dx: f32, dy: f32 }, +} + +/// A particle sprite in buffer pixels; `c` is straight colour + opacity. +#[derive(Clone, Copy, Debug)] +pub struct Sprite { + pub x: f32, + pub y: f32, + pub r: f32, + pub c: [f32; 4], + pub shape: Shape, + pub rot: f32, +} + +impl Sprite { + pub(crate) fn new(x: f32, y: f32, r: f32, c: [f32; 4], shape: Shape) -> Sprite { + Sprite { x, y, r, c, shape, rot: 0.0 } + } +} + +/// Where a sprite's colour comes from when it depends on the layer's pixels (resolved by +/// [`SpritePlan::resolve`] on the CPU, per pixel by the GPU compositor). Positions are buffer +/// pixels. +#[derive(Clone, Copy, Debug)] +pub enum Tint { + /// The sprite's own colour. + Fixed, + /// CC Rainfall's refracting drops: where the layer shows (alpha > 0), colour × (layer + /// colour × 0.6 + 0.5) sampled at (x, y). + Refract { x: f64, y: f64 }, + /// CC Star Burst: the layer's colour at (x, y), opacity × its alpha (none where clear). + Source { x: f64, y: f64 }, + /// CC Hair: the root's colour at (x, y) mixed with the hair colour (`inherit` = 1 keeps the + /// root's), × `shade`, + `spec`; no strand where the root's alpha is below ½. + Root { x: f64, y: f64, hair: [f32; 3], inherit: f32, shade: f32, spec: f32 }, +} + +/// How a sprite layer meets the original layer. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Post { + /// `combine` with this mode (0 Composite … 5 sprites only). + Combine(u32), + /// The sprites alone. + Replace, + /// The sprites, mixed toward the original by this fraction. + Lerp(f32), + /// The sprites over opaque black. + OverBlack, +} + +/// What a sprite-drawing effect draws this frame: built on the CPU (closed-form or from the +/// simulation state), rasterised by [`SpritePlan::finish`] or the GPU compositor. +#[derive(Clone, Debug)] +pub struct SpritePlan { + pub sprites: Vec, + /// Per sprite (empty = all [`Tint::Fixed`]). + pub tints: Vec, + pub acc: Acc, + pub post: Post, +} + +impl SpritePlan { + /// Resolve layer-dependent colours against the layer `src`. + pub fn resolve(&mut self, src: &Image) { + let tints = std::mem::take(&mut self.tints); + self.sprites.par_iter_mut().zip(tints.par_iter()).for_each(|(sp, t)| match *t { + Tint::Fixed => {} + Tint::Refract { x, y } => { + let (sc, sa) = unpremul(src.sample_bilinear(x, y)); + if sa > 0.0 { + let c = sp.c; + sp.c = [(sc[0] * 0.6 + 0.5) * c[0], (sc[1] * 0.6 + 0.5) * c[1], (sc[2] * 0.6 + 0.5) * c[2], c[3]]; + } + } + Tint::Source { x, y } => { + let (c, a) = unpremul(src.sample_bilinear(x, y)); + sp.c = if a <= 0.0 { [0.0; 4] } else { [c[0], c[1], c[2], a * sp.c[3]] }; + } + Tint::Root { x, y, hair, inherit, shade, spec } => { + let root = src.sample_bilinear(x, y); + if root[3] < 0.5 { + sp.c = [0.0; 4]; + return; + } + let (rc, _) = unpremul(root); + let base: [f32; 3] = std::array::from_fn(|k| rc[k] + (hair[k] - rc[k]) * (1.0 - inherit)); + sp.c = [base[0] * shade + spec, base[1] * shade + spec, base[2] * shade + spec, sp.c[3]]; + } + }); + } + + /// Rasterise (after [`SpritePlan::resolve`]) and meet the original layer `b`. + pub fn finish(&self, b: Buf) -> Buf { + let fx = splat(b.img.width, b.img.height, &self.sprites, self.acc); + self.finish_with(b, fx) + } + + /// [`finish`](Self::finish) with a prepared sprite image (the motion-blurred average of + /// several frames, #1195). + pub fn finish_with(&self, b: Buf, fx: Image) -> Buf { + self.post.apply(b, fx) + } +} + +impl Post { + /// Meet the sprite image `fx` with the original layer `b` (see [`SpritePlan::finish`]). + pub(super) fn apply(self, mut b: Buf, fx: Image) -> Buf { + b.img = match self { + Post::Combine(m) => combine(&b.img, &fx, m), + Post::Replace => fx, + Post::Lerp(t) => { + let mut o = fx; + o.data.par_iter_mut().zip(b.img.data.par_iter()).for_each(|(o, s0)| *o = crate::util::lerp4(*o, *s0, t)); + o + } + Post::OverBlack => combine(&Image::filled(b.img.width, b.img.height, [0.0, 0.0, 0.0, 1.0]), &fx, 0), + }; + b + } +} + +pub(super) fn sprite_bbox(s: &Sprite) -> Option<[f32; 4]> { + if s.c[3] <= 1e-5 || s.r <= 0.0 { + return None; + } + let r = s.r.max(0.5) + 1.0; + Some(match s.shape { + Shape::Line { dx, dy } => [s.x.min(s.x + dx) - r, s.y.min(s.y + dy) - r, s.x.max(s.x + dx) + r, s.y.max(s.y + dy) + r], + Shape::Star => [s.x - r * 2.0, s.y - r * 2.0, s.x + r * 2.0, s.y + r * 2.0], + _ => [s.x - r, s.y - r, s.x + r, s.y + r], + }) +} + +#[inline] +pub(super) fn aa(edge: f32) -> f32 { + (edge + 0.5).clamp(0.0, 1.0) +} + +fn sprite_shade(s: &Sprite, x: f32, y: f32) -> Option { + // Sub-pixel sprites keep their energy: draw at radius 0.5 with reduced opacity. + let (r, fade) = if s.r < 0.5 { (0.5, s.r / 0.5) } else { (s.r, 1.0) }; + let (dx, dy) = (x - s.x, y - s.y); + let mut rgb = [s.c[0], s.c[1], s.c[2]]; + let cov = match s.shape { + Shape::Soft => { + let d2 = (dx * dx + dy * dy) / (r * r); + if d2 >= 1.0 { + return None; + } + let q = 1.0 - d2; + q * q + } + Shape::Disc => aa(r - (dx * dx + dy * dy).sqrt()), + Shape::Faded => { + let d = (dx * dx + dy * dy).sqrt() / r; + (1.0 - d).max(0.0).powf(1.5) + } + Shape::Sphere => { + let d = (dx * dx + dy * dy).sqrt(); + let cv = aa(r - d); + if cv <= 0.0 { + return None; + } + let (nx, ny) = (dx / r, dy / r); + let nz = (1.0 - nx * nx - ny * ny).max(0.0).sqrt(); + let (lx, ly, lz) = (-0.48, -0.58, 0.66); + let diff = (nx * lx + ny * ly + nz * lz).max(0.0); + let rz = 2.0 * diff * nz - lz; + let spec = rz.max(0.0).powi(20) * 0.6; + let k = 0.2 + 0.8 * diff; + rgb = [rgb[0] * k + spec, rgb[1] * k + spec, rgb[2] * k + spec]; + cv + } + Shape::Bubble => { + let d = (dx * dx + dy * dy).sqrt() / r; + if d > 1.0 + 1.0 / r { + return None; + } + let ring = (1.0 - ((d - 0.9) / 0.12).powi(2)).max(0.0); + let hl = (1.0 - (((dx / r) + 0.4).powi(2) + ((dy / r) + 0.4).powi(2)) / 0.04).max(0.0); + (0.12 * (d <= 1.0) as u8 as f32 + ring * 0.8 + hl).min(1.0) + } + Shape::Star => { + let (sn, cs) = s.rot.sin_cos(); + let (ux, uy) = ((dx * cs + dy * sn) / r, (-dx * sn + dy * cs) / r); + let d = (ux * ux + uy * uy).sqrt(); + let core = (1.0 - d).max(0.0).powi(2); + let sx = (1.0 - ux.abs() / 2.0).max(0.0) * (1.0 - (uy.abs() * 6.0)).max(0.0); + let sy = (1.0 - uy.abs() / 2.0).max(0.0) * (1.0 - (ux.abs() * 6.0)).max(0.0); + (core + sx + sy).min(1.0) + } + Shape::Square => { + let (sn, cs) = s.rot.sin_cos(); + let (ux, uy) = (dx * cs + dy * sn, -dx * sn + dy * cs); + let e = r * 0.75; + aa(e - ux.abs()) * aa(e - uy.abs()) + } + Shape::Tri => { + let (sn, cs) = s.rot.sin_cos(); + let (ux, uy) = (dx * cs + dy * sn, -dx * sn + dy * cs); + // Equilateral triangle with circumradius r: three half-planes. + let e = r * 0.5; + let mut m = f32::INFINITY; + for k in 0..3 { + let a = std::f32::consts::FRAC_PI_2 + k as f32 * std::f32::consts::TAU / 3.0; + let (ns, nc) = a.sin_cos(); + m = m.min(e - (ux * nc + uy * ns)); + } + aa(m) + } + Shape::Line { dx: lx, dy: ly } => { + let l2 = lx * lx + ly * ly; + let t = if l2 > 0.0 { ((dx * lx + dy * ly) / l2).clamp(0.0, 1.0) } else { 0.0 }; + let (px, py) = (dx - lx * t, dy - ly * t); + aa(r - (px * px + py * py).sqrt()) + } + }; + let a = cov * s.c[3] * fade; + if a <= 1e-6 { + return None; + } + Some([rgb[0] * a, rgb[1] * a, rgb[2] * a, a]) +} + +/// Draw sprites into a new transparent image of `w`×`h`. +pub(crate) fn splat(w: u32, hgt: u32, sprites: &[Sprite], acc: Acc) -> Image { + let mut img = Image::new(w, hgt); + raster(&mut img, sprites, sprite_bbox, sprite_shade, acc); + img +} + +/// Combine an effect layer `fx` with the original `src` (both premultiplied, same size): +/// 0 Composite (fx over src), 1 Screen, 2 Add, 3 Lighten, 4 Darken, 5 fx only. +pub(crate) fn combine(src: &Image, fx: &Image, mode: u32) -> Image { + let mut out = fx.clone(); + out.data.par_iter_mut().zip(src.data.par_iter()).for_each(|(o, s)| { + let f = *o; + *o = match mode { + 0 => { + let k = 1.0 - f[3]; + [f[0] + s[0] * k, f[1] + s[1] * k, f[2] + s[2] * k, f[3] + s[3] * k] + } + 1 => [s[0] + f[0] - s[0] * f[0], s[1] + f[1] - s[1] * f[1], s[2] + f[2] - s[2] * f[2], s[3] + f[3] - s[3] * f[3]], + 2 => [s[0] + f[0], s[1] + f[1], s[2] + f[2], (s[3] + f[3]).min(1.0)], + 3 => [s[0].max(f[0]), s[1].max(f[1]), s[2].max(f[2]), s[3] + f[3] - s[3] * f[3]], + 4 => { + let (fc, fa) = unpremul(f); + let m = |c: f32, k: usize| c - (c - c.min(fc[k] * s[3])) * fa; + [m(s[0], 0), m(s[1], 1), m(s[2], 2), s[3]] + } + _ => f, + }; + }); + out +} diff --git a/crates/effects/src/sim/tests.rs b/crates/effects/src/sim/tests.rs new file mode 100644 index 000000000..4c708dae4 --- /dev/null +++ b/crates/effects/src/sim/tests.rs @@ -0,0 +1,828 @@ +use super::*; +use crate::{EffectEnv, run_fx}; + +fn grey(w: u32, hh: u32) -> Image { + Image::filled(w, hh, [0.2, 0.2, 0.2, 1.0]) +} + +fn ramp(w: u32, hh: u32) -> Image { + let mut img = Image::new(w, hh); + for y in 0..hh { + for x in 0..w { + img.set(x, y, [x as f32 / w as f32, y as f32 / hh as f32, 0.4, 1.0]); + } + } + img +} + +fn run(id: &str, vals: &[(&str, Value)], img: Image, t: f64) -> Image { + run_fx(id, vals, img, t, EffectEnv::default()).img +} + +fn diff(a: &Image, b: &Image) -> f32 { + a.data.iter().zip(&b.data).map(|(p, q)| (0..4).map(|k| (p[k] - q[k]).abs()).sum::()).sum() +} + +/// Centre of mass (luminance-weighted difference from `base`), `[-1, -1]` when equal. +fn mass(img: &Image, base: &Image) -> [f32; 2] { + let (mut m, mut sx, mut sy) = (0.0, 0.0, 0.0); + for y in 0..img.height { + for x in 0..img.width { + let i = img.idx(x, y); + let d: f32 = (0..3).map(|k| (img.data[i][k] - base.data[i][k]).abs()).sum(); + m += d; + sx += d * x as f32; + sy += d * y as f32; + } + } + if m > 0.0 { [sx / m, sy / m] } else { [-1.0; 2] } +} + +/// [`mass`] in y. +fn mass_y(img: &Image, base: &Image) -> f32 { + mass(img, base)[1] +} + +#[test] +fn rasteriser_over_and_add() { + let s = [Sprite::new(5.0, 5.0, 2.0, [1.0, 0.0, 0.0, 1.0], Shape::Disc)]; + let img = splat(10, 40, &s, Acc::Over); + assert!(img.get(5, 5)[0] > 0.99 && img.get(9, 30)[3] == 0.0); + let two = [s[0], s[0]]; + let add = splat(10, 40, &two, Acc::Add); + assert!(add.get(5, 5)[0] > 1.9); + // Items spanning band boundaries are drawn in every band. + let tall = [Sprite::new(5.0, 16.0, 6.0, [0.0, 1.0, 0.0, 1.0], Shape::Square)]; + let t = splat(10, 40, &tall, Acc::Over); + assert!(t.get(5, 13)[1] > 0.9 && t.get(5, 18)[1] > 0.9); +} + +#[test] +fn rainfall_deterministic_and_falls() { + let vals = [("drops", num(200.0)), ("opacity", num(100.0)), ("extras/appearance", Value::Enum(1)), ("color", col(1.0, 1.0, 1.0))]; + let a = run("ec.sim.ccrainfall", &vals, grey(40, 40), 0.5); + let b = run("ec.sim.ccrainfall", &vals, grey(40, 40), 0.5); + assert_eq!(a, b); + assert!(diff(&a, &grey(40, 40)) > 1.0, "drops visible"); + let none = run("ec.sim.ccrainfall", &[("drops", num(0.0))], grey(40, 40), 0.5); + assert_eq!(none, grey(40, 40)); + let later = run("ec.sim.ccrainfall", &vals, grey(40, 40), 0.52); + assert!(diff(&a, &later) > 0.1, "rain moves"); +} + +#[test] +fn snowfall_deterministic_and_moves() { + let vals = [("flakes", num(300.0))]; + let a = run("ec.sim.ccsnowfall", &vals, grey(40, 40), 1.0); + assert_eq!(a, run("ec.sim.ccsnowfall", &vals, grey(40, 40), 1.0)); + assert!(diff(&a, &grey(40, 40)) > 1.0); + assert!(diff(&a, &run("ec.sim.ccsnowfall", &vals, grey(40, 40), 1.2)) > 0.1); + let off = run("ec.sim.ccsnowfall", &[("flakes", num(300.0)), ("compositeWithOriginal", Value::Bool(false))], grey(40, 40), 1.0); + assert!(off.data.iter().any(|p| p[3] == 0.0), "without original the background is transparent"); +} + +#[test] +fn bubbles_rise() { + let vals = [("bubbleAmount", num(30.0)), ("bubbleSize", num(3.0))]; + let a = run("ec.sim.ccbubbles", &vals, ramp(40, 40), 0.3); + assert_eq!(a, run("ec.sim.ccbubbles", &vals, ramp(40, 40), 0.3)); + assert!(a.data.iter().any(|p| p[3] > 0.5)); + let none = run("ec.sim.ccbubbles", &[("bubbleAmount", num(0.0))], ramp(40, 40), 0.3); + assert!(none.data.iter().all(|p| p[3] == 0.0), "bubbles replace the layer"); + // A single slow bubble moves up over a short time. + let one = [("bubbleAmount", num(1.0)), ("bubbleSize", num(4.0)), ("wobbleAmplitude", num(0.0)), ("bubbleSpeed", num(0.2))]; + let blank = Image::new(40, 40); + let y0 = mass_y(&run("ec.sim.ccbubbles", &one, ramp(40, 40), 0.0), &blank); + let y1 = mass_y(&run("ec.sim.ccbubbles", &one, ramp(40, 40), 0.1), &blank); + 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); + assert_eq!(a, run("ec.sim.ccdrizzle", &[], ramp(48, 48), 0.6)); + assert!(diff(&a, &ramp(48, 48)) > 0.1); + let none = run("ec.sim.ccdrizzle", &[("dripRate", num(0.0))], ramp(48, 48), 0.6); + assert_eq!(none, ramp(48, 48)); +} + +#[test] +fn star_burst_moves_stars() { + let a = run("ec.sim.ccstarburst", &[("gridSpacing", num(4.0))], ramp(40, 40), 0.5); + assert_eq!(a, run("ec.sim.ccstarburst", &[("gridSpacing", num(4.0))], ramp(40, 40), 0.5)); + assert!(a.data.iter().any(|p| p[3] > 0.0)); + let b = run("ec.sim.ccstarburst", &[("gridSpacing", num(4.0))], ramp(40, 40), 0.9); + assert!(diff(&a, &b) > 0.5); + let full = run("ec.sim.ccstarburst", &[("blendWithOriginal", num(100.0))], ramp(40, 40), 0.5); + assert!(diff(&full, &ramp(40, 40)) < 1e-3); +} + +fn pw_vals(seed: f64) -> Vec<(&'static str, Value)> { + vec![ + ("birthRate", num(1.0)), + ("extras/randomSeed", num(seed)), + ("particle/particleType", Value::Enum(2)), + ("particle/birthSize", num(0.3)), + ("particle/deathSize", num(0.3)), + ] +} + +/// #1195: with the comp's shutter open, the particles smear across it: the average of the +/// sampled sprite fields differs from the sharp frame, touches more pixels and keeps the +/// alpha energy. +#[test] +fn particle_world_motion_blur_trails() { + let v = pw_vals(21.0); + let blank = Image::new(40, 40); + let sharp = run("ec.sim.ccparticleworld", &v, blank.clone(), 2.0); + let blurred = crate::run_fx( + "ec.sim.ccparticleworld", + &v, + blank.clone(), + 2.0, + EffectEnv { frame_rate: 10.0, shutter: Some((360.0, -180.0, 8)), ..Default::default() }, + ) + .img; + assert!(sharp.data.iter().any(|p| p[3] > 0.0), "sharp frame has particles"); + assert_ne!(sharp.data, blurred.data, "the shutter smears the particles"); + let lit = |img: &Image| img.data.iter().filter(|p| p[3] > 0.005).count(); + let sum = |img: &Image| img.data.iter().map(|p| p[3]).sum::(); + assert!(lit(&blurred) > lit(&sharp), "trails light more pixels: {} vs {}", lit(&blurred), lit(&sharp)); + let (sa, ba) = (sum(&sharp), sum(&blurred)); + assert!((ba - sa).abs() < sa * 0.6 + 1.0, "alpha energy roughly kept: {ba} vs {sa}"); +} + +#[test] +fn particle_world_seek_consistent() { + let v = pw_vals(11.0); + let direct = run("ec.sim.ccparticleworld", &v, Image::new(40, 40), 2.0); + let v2 = pw_vals(12.0); + let _ = run("ec.sim.ccparticleworld", &v2, Image::new(40, 40), 1.0); + let stepped = run("ec.sim.ccparticleworld", &v2, Image::new(40, 40), 2.0); + let direct2 = run("ec.sim.ccparticleworld", &v2, Image::new(40, 40), 2.0); + assert_eq!(stepped, direct2); + assert!(direct.data.iter().any(|p| p[3] > 0.0), "particles alive"); + // Before birth nothing is drawn. + let t0 = run("ec.sim.ccparticleworld", &v, Image::new(40, 40), 0.0); + assert!(t0.data.iter().all(|p| p[3] == 0.0)); +} + +#[test] +fn engine_resume_equals_from_scratch() { + for anim in 0..PW_ANIMS.len() as u32 { + let phys = Phys { + rate: 90.0, + life: 1.5, + producer: [0.0; 3], + radius: [0.05; 3], + anim, + speed: 0.5, + gravity: [0.0, 0.5, 0.0], + resistance: 0.2, + axis: [0.0, -1.0, 0.0], + extra: 1.0, + extra_angle: 60.0, + seed: 9, + max: 10_000, + flat: anim % 2 == 0, + }; + let a: SimCache = SimCache::new(2); + let b: SimCache = SimCache::new(2); + let direct = simulate(&a, 1, 2.0, &phys); + let _ = simulate(&b, 1, 1.0, &phys); + let _ = simulate(&b, 1, 0.37, &phys); + let resumed = simulate(&b, 1, 2.0, &phys); + assert_eq!(direct.parts.len(), resumed.parts.len(), "anim {anim}"); + assert!(direct.parts.iter().zip(&resumed.parts).all(|(p, q)| p.p == q.p && p.v == q.v && p.id == q.id), "anim {anim}"); + assert!(!direct.parts.is_empty()); + } +} + +#[test] +fn particle_world_gravity_pulls_down() { + let base = |g: f64| { + vec![ + ("birthRate", num(2.0)), + ("physics/velocity", num(0.2)), + ("physics/gravity", num(g)), + ("particle/particleType", Value::Enum(2)), + ("particle/birthSize", num(0.2)), + ("particle/deathSize", num(0.2)), + ("longevity", num(3.0)), + ] + }; + let blank = Image::new(48, 48); + let up = mass_y(&run("ec.sim.ccparticleworld", &base(0.0), blank.clone(), 1.0), &blank); + let down = mass_y(&run("ec.sim.ccparticleworld", &base(2.0), blank.clone(), 1.0), &blank); + assert!(down > up + 2.0, "{up} {down}"); +} + +/// A comp whose camera is `camera` ([`crate::CompScene::camera`]); `layer`: a camera layer +/// (else the comp's default view). +struct Comp { + camera: [[f64; 4]; 3], + layer: bool, +} +impl crate::EffectHost for Comp { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn comp_scene(&self) -> Option { + Some(crate::CompScene { camera: Some(self.camera), camera_layer: self.layer, light: None }) + } +} + +/// A camera `d` px in front of layer point `c` looking at it, moved right by `dx` px and +/// zoomed by `zoom` (1 = identity on the layer plane): `c + zoom·d·(p − c − dx) / (z + d)`. +fn cam(c: [f64; 2], d: f64, dx: f64, zoom: f64) -> [[f64; 4]; 3] { + let k = zoom * d; + [[k, 0.0, c[0], d * c[0] - k * (c[0] + dx)], [0.0, k, c[1], d * c[1] - k * c[1]], [0.0, 0.0, 1.0, d]] +} + +fn pw_in(comp: &Comp, vals: &[(&str, Value)]) -> Image { + run_fx("ec.sim.ccparticleworld", vals, Image::new(48, 48), 1.0, EffectEnv { host: Some(comp), ..Default::default() }).img +} + +fn finite(img: &Image) -> bool { + img.data.iter().all(|p| p.iter().all(|c| c.is_finite())) +} + +/// #397: with a camera layer in the comp, the particles are seen through it. +#[test] +fn particle_world_follows_the_comp_camera() { + let v = pw_vals(3.0); + let blank = Image::new(48, 48); + let here = Comp { camera: cam([24.0, 24.0], 96.0, 0.0, 1.0), layer: true }; + let base = pw_in(&here, &v); + assert!(base.data.iter().any(|p| p[3] > 0.0), "particles alive"); + // Moving the camera right moves the particles left. + let moved = pw_in(&Comp { camera: cam([24.0, 24.0], 96.0, 8.0, 1.0), ..here }, &v); + let (a, b) = (mass(&base, &blank), mass(&moved, &blank)); + assert!(b[0] < a[0] - 4.0 && (b[1] - a[1]).abs() < 2.0, "{a:?} -> {b:?}"); + // Zooming in spreads them out. + let zoomed = pw_in(&Comp { camera: cam([24.0, 24.0], 96.0, 0.0, 2.0), ..here }, &v); + let cover = |i: &Image| i.data.iter().filter(|p| p[3] > 0.01).count(); + assert!(cover(&zoomed) > cover(&base), "{} {}", cover(&zoomed), cover(&base)); + // The Effect Camera is ignored then. + let mut turned = v.clone(); + turned.push(("extras/effectCamera/rotationY", num(60.0))); + assert_eq!(pw_in(&here, &turned), base); + // The comp's default view (no camera layer) leaves it to the Effect Camera. + let default_view = Comp { layer: false, ..here }; + assert_eq!(pw_in(&default_view, &v), run("ec.sim.ccparticleworld", &v, blank.clone(), 1.0)); + // A camera facing away, or a degenerate one, draws nothing and never NaN. + for camera in [[[96.0, 0.0, 24.0, 0.0], [0.0, 96.0, 24.0, 0.0], [0.0, 0.0, -1.0, -96.0]], [[0.0; 4]; 3], [[f64::NAN; 4]; 3]] { + assert_eq!(pw_in(&Comp { camera, layer: true }, &v), blank); + } + // A camera among the particles draws the ones in front of it. + let inside = pw_in(&Comp { camera: cam([24.0, 24.0], 1.0, 0.0, 1.0), ..here }, &v); + assert!(finite(&inside)); +} + +/// Extras ▸ Effect Camera: the defaults are the fixed camera Particle World always had. +#[test] +fn particle_world_effect_camera() { + let v = pw_vals(5.0); + let base = run("ec.sim.ccparticleworld", &v, Image::new(48, 48), 1.0); + let with = |extra: &[(&'static str, Value)]| { + let mut w = v.clone(); + w.extend_from_slice(extra); + run("ec.sim.ccparticleworld", &w, Image::new(48, 48), 1.0) + }; + // Missing parameters (projects saved before) read as the defaults. + let spec = crate::find("ec.sim.ccparticleworld").unwrap(); + let mut params = crate::Params { values: spec.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + params.values.retain(|k, _| !k.starts_with(PW_EFFECT_CAMERA)); + for (k, val) in &v { + params.values.insert(k.to_string(), val.clone()); + } + let ctx = EffectCtx { params: ¶ms, time: 1.0, layer_size: [48.0, 48.0], seed: 1, adjustment: false, env: EffectEnv::default() }; + assert_eq!(crate::apply(spec, &ctx, Buf { img: Image::new(48, 48), offset: [0.0; 2], scale: 1.0 }).img, base); + assert_eq!(with(&[("extras/effectCamera/fov", num(f64::NAN))]), base); + // Turning or pulling back the camera changes the view. + let turned = with(&[("extras/effectCamera/rotationY", num(60.0))]); + assert!(diff(&turned, &base) > 1.0); + let cover = |i: &Image| i.data.iter().filter(|p| p[3] > 0.01).count(); + let far = with(&[("extras/effectCamera/distance", num(4.0))]); + assert!(cover(&far) < cover(&base), "{} {}", cover(&far), cover(&base)); + assert!(diff(&with(&[("extras/effectCamera/fov", num(120.0))]), &base) > 0.1); + // Extremes never panic or produce NaN. + for extra in [ + ("extras/effectCamera/distance", num(0.0)), + ("extras/effectCamera/distance", num(-5.0)), + ("extras/effectCamera/fov", num(0.0)), + ("extras/effectCamera/fov", num(180.0)), + ("extras/effectCamera/rotationX", num(1e12)), + ] { + assert!(finite(&with(std::slice::from_ref(&extra))), "{extra:?}"); + } + // Moving the camera doesn't restart the simulation. + let key = + |pr: &crate::Params| sim_key(&EffectCtx { params: pr, time: 1.0, layer_size: [48.0, 48.0], seed: 1, adjustment: false, env: EffectEnv::default() }, 1); + let mut moved = params.clone(); + moved.values.insert("extras/effectCamera/rotationZ".into(), num(30.0)); + assert_eq!(key(¶ms), key(&moved)); + moved.values.insert("extras/randomSeed".into(), num(9.0)); + 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 { + 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> { + 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) -> 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 { + Some(self.0.clone()) + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + 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![ + ("producer/position", pt(24.0, 24.0)), + ("birthRate", num(3.0)), + ("physics/gravity", num(0.0)), + ("physics/velocity", num(1.0)), + ("physics/animation", Value::Enum(1)), + ]; + let _ = run("ec.sim.ccparticlesystems2", &v, Image::new(48, 48), 0.5); + let a = run("ec.sim.ccparticlesystems2", &v, Image::new(48, 48), 1.0); + let mut v2 = v.clone(); + v2.push(("randomSeed", num(0.0))); + assert_eq!(a, run("ec.sim.ccparticlesystems2", &v2, Image::new(48, 48), 1.0)); + // Direction 0° (up): particles above the producer. + let blank = Image::new(48, 48); + let y = mass_y(&a, &blank); + assert!((0.0..24.0).contains(&y), "{y}"); + // The particles replace the layer: with none born it is empty. + let none = run("ec.sim.ccparticlesystems2", &[("birthRate", num(0.0))], grey(20, 20), 1.0); + assert_eq!(none, Image::new(20, 20)); +} + +/// CC Particle World and CC Particle Systems II replace their layer with the particles, in +/// every Transfer Mode (which only sets how particles meet each other). SceneYard frames from +/// After Effects 2026 show no trace of the source solid: "Task Preparation UI" (black solids +/// under fireworks hid a white page), "Burnout matchstick" (a red rectangle around the +/// flame), "Documentary Man Falling" (a black block over the light). +#[test] +fn cc_particles_replace_their_layer() { + let red = || { + let mut img = Image::new(48, 48); + img.data.iter_mut().for_each(|p| *p = [0.7, 0.1, 0.1, 1.0]); + img + }; + for id in ["ec.sim.ccparticlesystems2", "ec.sim.ccparticleworld"] { + for mode in 0..4 { + let v = vec![("birthRate", num(3.0)), ("particle/transferMode", Value::Enum(mode))]; + let _ = run(id, &v, red(), 0.5); + let out = run(id, &v, red(), 1.0); + // No source left: the corners, far from the producer, are clear. + for (x, y) in [(0, 0), (47, 0), (0, 47), (47, 47)] { + assert!(out.get(x, y)[3] < 1e-6, "{id} mode {mode} ({x},{y}): {:?}", out.get(x, y)); + } + assert!(out.data.iter().any(|p| p[3] > 0.05), "{id} mode {mode}: particles drawn"); + } + } +} + +#[test] +fn mercury_blobs_and_seek() { + let v = vec![("producer", pt(24.0, 16.0)), ("radiusX", num(5.0)), ("radiusY", num(5.0)), ("birthRate", num(3.0))]; + let _ = run("ec.sim.ccmrmercury", &v, ramp(48, 48), 0.7); + let a = run("ec.sim.ccmrmercury", &v, ramp(48, 48), 1.4); + let mut v2 = v.clone(); + v2.push(("shading/metal", num(100.0))); + let b = run("ec.sim.ccmrmercury", &v2, ramp(48, 48), 1.4); + assert_eq!(a, b); + assert!(a.data.iter().any(|p| p[3] > 0.5), "blobs visible"); + let none = run("ec.sim.ccmrmercury", &[("birthRate", num(0.0))], ramp(48, 48), 1.4); + assert!(none.data.iter().all(|p| p[3] == 0.0)); +} + +#[test] +fn half_resolution_scales_sprites() { + // Downsampled buffers render the same layout at half size. + let s = crate::find("ec.sim.ccsnowfall").unwrap(); + let mut params = crate::Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + params.values.insert("flakes".into(), num(50.0)); + params.values.insert("offset".into(), pt(20.0, 20.0)); + let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: [40.0, 40.0], seed: 1, adjustment: false, env: EffectEnv::default() }; + let full = crate::apply(s, &ctx, Buf { img: grey(40, 40), offset: [0.0; 2], scale: 1.0 }); + let half = crate::apply(s, &ctx, Buf { img: grey(20, 20), offset: [0.0; 2], scale: 0.5 }); + let sum = |i: &Image| i.data.iter().map(|p| p[0]).sum::() / i.data.len() as f32; + assert!((sum(&full.img) - sum(&half.img)).abs() < 0.05, "{} {}", sum(&full.img), sum(&half.img)); +} + +// ---------------------------------------------------------- Birth Rate over time (#1373) + +/// A host that evaluates effect `index`'s Birth Rate at other layer times as `rate(t)`. +struct Rate { + index: usize, + rate: fn(f64) -> f64, +} +impl crate::EffectHost for Rate { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn param_at(&self, effect_index: usize, id: &str, t: f64) -> Option { + (effect_index == self.index && id == "birthRate").then(|| num((self.rate)(t))) + } +} + +/// Birth Rate 3 until 0.5 s, then 0 (the issue's one-shot burst). +fn burst(t: f64) -> f64 { + if t < 0.5 { 3.0 } else { 0.0 } +} + +/// The same burst, cut at 0.25 s (an earlier key moved). +fn short_burst(t: f64) -> f64 { + if t < 0.25 { 3.0 } else { 0.0 } +} + +/// Birth Rate falling linearly from 3 at 0 s to 0 at 0.5 s. +fn falling(t: f64) -> f64 { + (3.0 * (1.0 - t / 0.5)).max(0.0) +} + +/// CC Particle World's parameters: random seed `seed`, Birth Rate `now` (its value at the +/// rendered time), Longevity 1 s. +fn pw_params(seed: f64, now: f64) -> crate::Params { + let spec = crate::find("ec.sim.ccparticleworld").unwrap(); + let mut p = crate::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), crate::default_value(ps, [100.0, 100.0]))).collect() }; + for (k, v) in [("birthRate", num(now)), ("extras/randomSeed", num(seed)), ("longevity", num(1.0))] { + p.values.insert(k.to_string(), v); + } + p +} + +fn pw_ctx<'a>(p: &'a crate::Params, t: f64, host: Option<&'a Rate>, index: usize) -> EffectCtx<'a> { + let env = EffectEnv { host: host.map(|h| h as &dyn crate::EffectHost), effect_index: index, ..Default::default() }; + EffectCtx { params: p, time: t, layer_size: [100.0, 100.0], seed: 1, adjustment: false, env } +} + +/// The live particles at layer time `t` with Birth Rate `now` there and `host`'s history. +fn pw_at(seed: f64, now: f64, t: f64, host: Option<&Rate>) -> Vec { + let p = pw_params(seed, now); + particle_state("ec.sim.ccparticleworld", &pw_ctx(&p, t, host, host.map_or(0, |h| h.index))).unwrap() +} + +/// The same, simulated from layer time 0 with a cache of its own (the reference). +fn pw_fresh(seed: f64, now: f64, t: f64, host: &Rate) -> Vec { + let p = pw_params(seed, now); + let ctx = pw_ctx(&p, t, Some(host), host.index); + let key = params_key_except(&ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 1, &[PW_EFFECT_CAMERA, "birthRate"]); + let st = simulate_rates(&SimCache::new(1), key, &pw_phys(&ctx), &rate_history(&ctx, "birthRate", PW_RATE)); + st.parts.iter().map(|q| crate::psim::SimParticle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect() +} + +/// Particles born in the first `t` seconds of `rate`. +fn born_by(rate: fn(f64) -> f64, t: f64) -> usize { + crate::psim::births_at(steps_at(t), 40_000, |i| rate(i as f64 / SPS) * PW_RATE).unwrap().len() +} + +/// #1373: Birth Rate only decides the births of its own time. After a burst stops, the +/// particles born in it live on (moving and ageing) until their Longevity runs out, rather +/// than vanishing because the rate is now 0. +#[test] +fn a_burst_lives_on_after_birth_rate_drops_to_zero() { + let host = Rate { index: 2, rate: burst }; + // During the burst: as a constant Birth Rate of 3. + assert_eq!(pw_at(101.0, 3.0, 0.4, Some(&host)), pw_at(101.0, 3.0, 0.4, None)); + // After it: the burst's particles, none newer. + let late = pw_at(101.0, 0.0, 0.9, Some(&host)); + let n = born_by(burst, 0.5); + assert!(late.len() > n / 2 && late.iter().all(|q| (q.id as usize) < n), "{} live of {n} born", late.len()); + assert!(pw_at(101.0, 0.0, 0.9, None).is_empty(), "a constant Birth Rate of 0 never bore any"); + // They keep moving. + let early = pw_at(101.0, 0.0, 0.7, Some(&host)); + let q0 = early.iter().find(|q| q.id == late[0].id).unwrap(); + assert!(q0.p != late[0].p && late[0].age > q0.age + 0.19, "{q0:?} -> {:?}", late[0]); + // Longevity 1 s (± 20 %): all gone by 1.8 s. + assert!(pw_at(101.0, 0.0, 1.8, Some(&host)).is_empty()); + // The effect asks for its own index: another effect's history isn't its own. + let p = pw_params(101.0, 0.0); + assert!(particle_state("ec.sim.ccparticleworld", &pw_ctx(&p, 0.9, Some(&host), 0)).unwrap().is_empty()); +} + +/// A Birth Rate ramping down isn't simulated as if its current value had always applied. +#[test] +fn a_ramped_birth_rate_bears_what_each_moment_asked_for() { + let host = Rate { index: 0, rate: falling }; + let ramped = pw_at(102.0, falling(0.25), 0.25, Some(&host)); + let constant = pw_at(102.0, falling(0.25), 0.25, None); + assert_eq!(ramped.len(), born_by(falling, 0.25), "every particle born so far is alive"); + assert_eq!(constant.len(), born_by(|_| 1.5, 0.25)); + assert!(ramped.len() > constant.len() + 10, "{} vs {}", ramped.len(), constant.len()); +} + +/// Seeking backward, forward and to the same time again gives the state simulated from 0. +#[test] +fn seeking_through_a_birth_rate_history_matches_simulating_from_zero() { + let host = Rate { index: 0, rate: burst }; + for t in [1.4, 0.6, 0.9, 0.3, 0.9, 1.1] { + let now = burst(t); + assert_eq!(pw_at(103.0, now, t, Some(&host)), pw_fresh(103.0, now, t, &host), "at {t}"); + } +} + +/// The simulation cache key leaves Birth Rate out (so an animated rate keeps its checkpoints), +/// but a checkpoint is only resumed with the history that reached it: after an earlier key +/// moves, the same current rate (0) gives the new history's particles. +#[test] +fn an_earlier_birth_rate_change_is_not_served_from_stale_checkpoints() { + let (long, short) = (Rate { index: 0, rate: burst }, Rate { index: 0, rate: short_burst }); + let a = pw_at(104.0, 0.0, 0.9, Some(&long)); + let b = pw_at(104.0, 0.0, 0.9, Some(&short)); + assert_eq!(b, pw_fresh(104.0, 0.0, 0.9, &short)); + assert!(b.len() < a.len() && !b.is_empty(), "{} vs {}", b.len(), a.len()); + assert_eq!(pw_at(104.0, 0.0, 1.0, Some(&long)), pw_fresh(104.0, 0.0, 1.0, &long)); +} + +/// A constant Birth Rate gives exactly the particles it did before the history was sampled. +#[test] +fn a_constant_birth_rate_is_unchanged() { + let host = Rate { index: 0, rate: |_| 2.0 }; + let p = pw_params(105.0, 2.0); + let ctx = pw_ctx(&p, 1.3, None, 0); + let phys = pw_phys(&ctx); + let before = simulate(&SimCache::new(1), sim_key(&ctx, 1), 1.3, &phys); + let now = pw_at(105.0, 2.0, 1.3, Some(&host)); + assert_eq!(now.len(), before.parts.len()); + assert!(now.iter().zip(&before.parts).all(|(a, b)| a.id == b.id && a.p == b.p && a.v == b.v && a.age == b.age)); + assert_eq!(crate::psim::births_at(78, 40_000, |_| phys.rate), crate::psim::births(phys.rate, 78, 40_000)); +} + +/// The GPU's birth schedule for a rate history is the CPU's: the ids born are the ids the +/// CPU simulation numbers. +#[test] +fn the_birth_schedule_follows_the_rate_history() { + let host = Rate { index: 0, rate: falling }; + let p = pw_params(106.0, 0.0); + let ctx = pw_ctx(&p, 0.7, Some(&host), 0); + let rates = rate_history(&ctx, "birthRate", PW_RATE); + let st = simulate_rates(&SimCache::new(1), 9, &pw_phys(&ctx), &rates); + let births = crate::psim::births_at(rates.len() as u64, 40_000, |i| rates[i as usize]).unwrap(); + assert_eq!(st.next_id as usize, births.len()); + assert!(births.iter().all(|&b| f64::from(b) / SPS < 0.5), "nothing born once the ramp reaches 0"); +} + +// ------------------------- CC Particle Systems II and CC Mr. Mercury's Birth Rate over time + +/// A stepped particle effect: its id, physics and simulation. +struct System { + id: &'static str, + phys: fn(&EffectCtx) -> Phys, + stepped: Stepped<'static>, +} + +const SYSTEMS: [System; 2] = + [System { id: "ec.sim.ccparticlesystems2", phys: ps2_phys, stepped: PS2 }, System { id: "ec.sim.ccmrmercury", phys: mercury_phys, stepped: MERCURY }]; + +/// `id`'s parameters with Birth Rate `now` and Longevity 1 s. +fn sys_params(id: &str, now: f64) -> crate::Params { + let spec = crate::find(id).unwrap(); + let mut p = crate::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), crate::default_value(ps, [100.0, 100.0]))).collect() }; + for (k, v) in [("birthRate", num(now)), ("longevity", num(1.0))] { + assert!(p.values.contains_key(k), "{id}: no {k}"); + p.values.insert(k.to_string(), v); + } + p +} + +/// The context at layer time `t` (`seed` keeps each test's simulations apart). +fn sys_ctx<'a>(p: &'a crate::Params, t: f64, host: Option<&'a Rate>, seed: u32) -> EffectCtx<'a> { + let env = EffectEnv { host: host.map(|h| h as &dyn crate::EffectHost), effect_index: host.map_or(0, |h| h.index), ..Default::default() }; + EffectCtx { params: p, time: t, layer_size: [100.0, 100.0], seed, adjustment: false, env } +} + +/// The live particles (id, position, age) of `sys` at `t` through its simulation. +fn sys_at(sys: &System, seed: u32, now: f64, t: f64, host: Option<&Rate>) -> Vec<(u32, [f32; 3], f32)> { + let p = sys_params(sys.id, now); + let ctx = sys_ctx(&p, t, host, seed); + sys.stepped.state(&ctx, &(sys.phys)(&ctx)).parts.iter().map(|q| (q.id, q.p, q.age)).collect() +} + +/// The same, simulated from layer time 0 with a cache of its own (the reference). +fn sys_fresh(sys: &System, seed: u32, now: f64, t: f64, host: &Rate) -> Vec<(u32, [f32; 3], f32)> { + let p = sys_params(sys.id, now); + let ctx = sys_ctx(&p, t, Some(host), seed); + let mut skip = sys.stepped.skip.to_vec(); + skip.push("birthRate"); + let key = params_key_except(&ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, sys.stepped.salt, &skip); + let st = simulate_rates(&SimCache::new(1), key, &(sys.phys)(&ctx), &rate_history(&ctx, "birthRate", sys.stepped.k)); + st.parts.iter().map(|q| (q.id, q.p, q.age)).collect() +} + +/// What `sys` draws at `t`: CC Particle Systems II's particles, CC Mr. Mercury's blobs. +fn sys_drawn(sys: &System, seed: u32, now: f64, t: f64, host: Option<&Rate>) -> usize { + let p = sys_params(sys.id, now); + let ctx = sys_ctx(&p, t, host, seed); + match sys.id { + "ec.sim.ccmrmercury" => mercury_blobs(&ctx, &Buf { img: Image::new(100, 100), offset: [0.0; 2], scale: 1.0 }).len(), + id => particle_state(id, &ctx).unwrap().len(), + } +} + +/// #1373 for CC Particle Systems II and CC Mr. Mercury: a burst lives on after Birth Rate +/// drops to 0 (what they draw too), moving and ageing, and is gone after its Longevity. +#[test] +fn particle_systems_ii_and_mr_mercury_keep_a_burst_after_birth_rate_drops() { + let host = Rate { index: 1, rate: burst }; + for sys in &SYSTEMS { + let born = crate::psim::births_at(steps_at(0.5), 40_000, |i| burst(i as f64 / SPS) * sys.stepped.k).unwrap().len(); + assert_eq!(sys_at(sys, 201, 3.0, 0.4, Some(&host)), sys_at(sys, 201, 3.0, 0.4, None), "{}: during the burst", sys.id); + let late = sys_at(sys, 201, 0.0, 0.9, Some(&host)); + assert!(late.len() > born / 2 && late.iter().all(|q| (q.0 as usize) < born), "{}: {} live of {born}", sys.id, late.len()); + assert!(sys_drawn(sys, 201, 0.0, 0.9, Some(&host)) > 0, "{}: drawn", sys.id); + assert_eq!(sys_drawn(sys, 201, 0.0, 0.9, None), 0, "{}: a constant 0 bore none", sys.id); + let early = sys_at(sys, 201, 0.0, 0.7, Some(&host)); + let q0 = early.iter().find(|q| q.0 == late[0].0).unwrap(); + assert!(q0.1 != late[0].1 && late[0].2 > q0.2 + 0.19, "{}: {q0:?} -> {:?}", sys.id, late[0]); + assert!(sys_at(sys, 201, 0.0, 1.8, Some(&host)).is_empty(), "{}: gone after Longevity", sys.id); + } +} + +/// Seeking matches a simulation from 0, and moving an earlier key (same current rate, same +/// cache key) isn't served from the old history's checkpoints. +#[test] +fn particle_systems_ii_and_mr_mercury_seek_and_drop_stale_checkpoints() { + let (long, short) = (Rate { index: 0, rate: burst }, Rate { index: 0, rate: short_burst }); + for sys in &SYSTEMS { + for t in [1.4, 0.6, 0.9, 0.3, 0.9, 1.1] { + let now = burst(t); + assert_eq!(sys_at(sys, 202, now, t, Some(&long)), sys_fresh(sys, 202, now, t, &long), "{} at {t}", sys.id); + } + let b = sys_at(sys, 202, 0.0, 0.9, Some(&short)); + assert_eq!(b, sys_fresh(sys, 202, 0.0, 0.9, &short), "{}: the moved key", sys.id); + assert!(!b.is_empty() && b.len() < sys_at(sys, 202, 0.0, 0.9, Some(&long)).len(), "{}", sys.id); + } +} + +/// A constant Birth Rate gives exactly the particles the constant-rate simulation did. +#[test] +fn particle_systems_ii_and_mr_mercury_are_unchanged_at_a_constant_rate() { + let host = Rate { index: 0, rate: |_| 2.0 }; + for sys in &SYSTEMS { + let p = sys_params(sys.id, 2.0); + let ctx = sys_ctx(&p, 1.3, None, 203); + let before = simulate(&SimCache::new(1), 1, 1.3, &(sys.phys)(&ctx)); + let before: Vec<_> = before.parts.iter().map(|q| (q.id, q.p, q.age)).collect(); + assert!(!before.is_empty()); + assert_eq!(sys_at(sys, 203, 2.0, 1.3, Some(&host)), before, "{}: keyed to a constant", sys.id); + assert_eq!(sys_at(sys, 203, 2.0, 1.3, None), before, "{}: no host", sys.id); + } +} diff --git a/crates/effects/src/sim/weather.rs b/crates/effects/src/sim/weather.rs new file mode 100644 index 000000000..347ab4939 --- /dev/null +++ b/crates/effects/src/sim/weather.rs @@ -0,0 +1,364 @@ +use super::common::{h, hs}; +use super::sprites::{Acc, Post, Shape, Sprite, SpritePlan, Tint, aa, raster}; +use super::{Buf, EffectCtx, Image, IntoParallelIterator, ParallelIterator, unpremul}; +use crate::util::layer_rect_at; + +// ------------------------------------------------------------------ CC Rainfall / CC Snowfall + +/// Shared weather layout: per-item depth factor `k` in (0, 1] (1 = nearest) from Scene Depth. +#[inline] +fn depth_k(z: f32, scene_depth: f32) -> f32 { + 1.0 / (1.0 + z * scene_depth / 2500.0) +} + +/// Wrap `v` into `[lo, lo + span)`. +#[inline] +fn wrap(v: f32, lo: f32, span: f32) -> f32 { + if span <= 0.0 { v } else { lo + (v - lo).rem_euclid(span) } +} + +pub(super) fn rainfall(ctx: &EffectCtx, b: Buf) -> Buf { + let mut plan = rainfall_plan(ctx, &b); + plan.resolve(&b.img); + plan.finish(b) +} + +pub(super) fn rainfall_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan { + let pr = ctx.params; + let n = pr.f("drops").clamp(0.0, 100_000.0) as u32; + let size = pr.f("size").max(0.0) as f32; + let depth = pr.f("sceneDepth").max(0.0) as f32; + let speed = pr.f("speed") as f32; + let wind = pr.f("wind") as f32; + let wvar = pr.f("windVariation") as f32 / 100.0; + let spread = pr.f("spread") as f32; + let color = pr.color("color"); + let opacity = pr.f("opacity") as f32 / 100.0; + let mode = pr.e("transferMode"); + let with_orig = pr.b("compositeWithOriginal"); + let refract = pr.e("extras/appearance") == 0; + let off = pr.v2("extras/offset"); + let seed = pr.f("extras/randomSeed") as u32 ^ ctx.seed.wrapping_mul(0x9e37); + let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); + let s = b.scale as f32; + let t = ctx.time as f32; + let (ox, oy) = (off[0] as f32 - lw * 0.5, off[1] as f32 - lh * 0.5); + let (sprites, tints): (Vec, Vec) = (0..n) + .into_par_iter() + .map(|i| { + let z = h(i, 1, seed); + let k = depth_k(z, depth); + let vy = speed / 4000.0 * lh * 2.2 * k; + let w = wind / 1000.0 * lh * (1.0 + wvar * hs(i, 2, seed)) * k + spread * hs(i, 7, seed) * k; + let len = (vy * 0.04 * size.max(0.2)).max(1.0); + let lenx = w * 0.04 * size.max(0.2); + let margin = len + lenx.abs() + 4.0; + let y = wrap(h(i, 3, seed) * (lh + 2.0 * margin) + vy * t + oy, -margin, lh + 2.0 * margin); + let x = wrap(h(i, 4, seed) * (lw + 2.0 * margin) + w * t + ox, -margin, lw + 2.0 * margin); + let (bx, by) = b.to_px([x as f64, y as f64]); + let c = [color[0], color[1], color[2], opacity * (0.35 + 0.65 * k)]; + // Refracting drops show the scene behind them, brightened and offset. + let tint = if refract { Tint::Refract { x: bx + 2.0 * s as f64, y: by } } else { Tint::Fixed }; + let r = (size * 0.6 * k * s).max(0.25); + (Sprite::new(bx as f32, by as f32, r, c, Shape::Line { dx: -lenx * s, dy: -len * s }), tint) + }) + .unzip(); + SpritePlan { sprites, tints, acc: Acc::Over, post: weather_post(mode, with_orig) } +} + +fn weather_post(mode: u32, with_orig: bool) -> Post { + if with_orig { Post::Combine(if mode == 1 { 3 } else { 0 }) } else { Post::Replace } +} + +pub(super) fn snowfall(ctx: &EffectCtx, b: Buf) -> Buf { + snowfall_plan(ctx, &b).finish(b) +} + +pub(super) fn snowfall_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan { + let pr = ctx.params; + let n = pr.f("flakes").clamp(0.0, 200_000.0) as u32; + let size = pr.f("size").max(0.0) as f32; + let svar = pr.f("sizeVariation") as f32 / 100.0; + let depth = pr.f("sceneDepth").max(0.0) as f32; + let speed = pr.f("speed") as f32; + let spvar = pr.f("speedVariation") as f32 / 100.0; + let wind = pr.f("wind") as f32; + let wvar = pr.f("windVariation") as f32 / 100.0; + let spread = pr.f("spread") as f32; + let wig = pr.f("wiggle/wiggleAmount") as f32; + let wigvar = pr.f("wiggle/wiggleAmountVariation") as f32 / 100.0; + let wfreq = pr.f("wiggle/wiggleFrequency") as f32; + let wfvar = pr.f("wiggle/wiggleFrequencyVariation") as f32 / 100.0; + let color = pr.color("color"); + let opacity = pr.f("opacity") as f32 / 100.0; + let mode = pr.e("transferMode"); + let with_orig = pr.b("compositeWithOriginal"); + let off = pr.v2("extras/offset"); + let seed = pr.f("extras/randomSeed") as u32 ^ ctx.seed.wrapping_mul(0x85eb); + let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); + let s = b.scale as f32; + let t = ctx.time as f32; + let (ox, oy) = (off[0] as f32 - lw * 0.5, off[1] as f32 - lh * 0.5); + let sprites: Vec = (0..n) + .into_par_iter() + .map(|i| { + let z = h(i, 1, seed); + let k = depth_k(z, depth); + let r = (size * 0.5 * k * (1.0 + svar * hs(i, 2, seed))).max(0.05); + let vy = speed * (lh / 600.0) * k * (1.0 + spvar * hs(i, 3, seed)); + let vx = wind * (lh / 1200.0) * k * (1.0 + wvar * hs(i, 4, seed)) + spread * hs(i, 8, seed) * k; + let amp = wig * 2.0 * k * (1.0 + wigvar * hs(i, 5, seed)); + let fr = wfreq * (1.0 + wfvar * hs(i, 6, seed)); + let ph = h(i, 9, seed) * std::f32::consts::TAU; + let margin = r + amp.abs() + 2.0; + let y = wrap(h(i, 10, seed) * (lh + 2.0 * margin) + vy * t + oy, -margin, lh + 2.0 * margin); + let x = wrap(h(i, 11, seed) * (lw + 2.0 * margin) + vx * t + ox, -margin, lw + 2.0 * margin) + amp * (fr * t * std::f32::consts::TAU + ph).sin(); + let (bx, by) = b.to_px([x as f64, y as f64]); + Sprite::new(bx as f32, by as f32, r * s, [color[0], color[1], color[2], opacity * (0.4 + 0.6 * k)], Shape::Soft) + }) + .collect(); + SpritePlan { sprites, tints: vec![], acc: Acc::Over, post: weather_post(mode, with_orig) } +} + +// ------------------------------------------------------------------ CC Bubbles + +/// A CC Bubbles bubble in buffer pixels. +#[derive(Clone, Copy, Debug)] +pub struct Bubble { + pub x: f32, + pub y: f32, + pub r: f32, + /// Source sample centre (buffer px). + pub sx: f32, + pub sy: f32, +} + +/// CC Bubbles' bubbles this frame (closed-form in time) for buffer geometry `b`. +pub fn bubble_list(ctx: &EffectCtx, b: &Buf) -> Vec { + let pr = ctx.params; + let n = pr.f("bubbleAmount").clamp(0.0, 10_000.0) as u32; + let speed = pr.f("bubbleSpeed") as f32; + let wamp = pr.f("wobbleAmplitude") as f32; + let wfreq = pr.f("wobbleFrequency") as f32; + let bsize = pr.f("bubbleSize").max(0.0) as f32; + let seed = ctx.seed.wrapping_mul(0x27d4); + let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); + let s = b.scale as f32; + let t = ctx.time as f32; + let base = lw.min(lh) * 0.035 * bsize; + (0..n) + .map(|i| { + let r = base * (0.5 + h(i, 1, seed)); + let travel = lh + 2.0 * r; + let v = speed * lh * 0.3 * (0.6 + 0.8 * h(i, 2, seed)); + let y = lh + r - (h(i, 3, seed) * travel + v * t).rem_euclid(travel); + let x0 = h(i, 4, seed) * lw; + let x = x0 + wamp * (r / base.max(1e-3)) * (t * wfreq * std::f32::consts::TAU + h(i, 5, seed) * std::f32::consts::TAU).sin(); + let (bx, by) = b.to_px([x as f64, y as f64]); + let (sx, sy) = b.to_px([x0 as f64, y as f64]); + Bubble { x: bx as f32, y: by as f32, r: r * s, sx: sx as f32, sy: sy as f32 } + }) + .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( + &mut out, + &list, + |q| Some([q.x - q.r - 1.0, q.y - q.r - 1.0, q.x + q.r + 1.0, q.y + q.r + 1.0]), + |q, x, y| { + let (dx, dy) = ((x - q.x) / q.r.max(0.5), (y - q.y) / q.r.max(0.5)); + let d2 = dx * dx + dy * dy; + let d = d2.sqrt(); + let cov = aa((1.0 - d) * q.r); + if cov <= 0.0 { + return None; + } + // 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 (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; + match shading { + 1 => c = c.map(|v| v * 0.7 + 0.3 * (1.0 - nz)), + 2 => c = c.map(|v| v * (0.5 + 0.5 * nz)), + 3 => a *= nz.powf(0.5), + 4 => a *= 1.0 - nz * 0.8, + _ => {} + } + let hl = (1.0 - ((dx + 0.35).powi(2) + (dy + 0.35).powi(2)) / 0.05).max(0.0) * 0.8; + let c = c.map(|v| v + hl); + Some([c[0] * a, c[1] * a, c[2] * a, a]) + }, + Acc::Over, + ); + b.img = out; + b +} + +// ------------------------------------------------------------------ CC Drizzle + +/// CC Drizzle's live drops this frame (buffer px: centre x, y, ring radius, amplitude); +/// `None` = no drops yet (the layer passes through). +pub fn drizzle_drops(ctx: &EffectCtx, b: &Buf) -> Option> { + let pr = ctx.params; + let rate = pr.f("dripRate").max(0.0); + let life = pr.f("longevity").max(0.01); + let spreading = pr.f("spreading") as f32; + let height = pr.f("rippleHeight") as f32 / 100.0; + let seed = ctx.seed.wrapping_mul(0x165667b1); + let t = ctx.time; + let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); + let s = b.scale as f32; + if rate <= 0.0 || t < 0.0 { + return None; + } + // Drops born at k / rate with hashed positions; alive while younger than the longevity. + let k0 = ((t - life) * rate).floor().max(0.0) as u32; + let k1 = (t * rate).floor() as u32; + let mut drops = Vec::new(); + for k in k0..=k1 { + let born = k as f64 / rate; + let age = (t - born) as f32; + if age < 0.0 || age as f64 >= life { + continue; + } + let (x, y) = b.to_px([(h(k, 1, seed) * lw) as f64, (h(k, 2, seed) * lh) as f64]); + let radius = spreading * age * lh / 400.0 * s; + let amp = height * (1.0 - age / life as f32) * (0.6 + 0.4 * h(k, 3, seed)); + drops.push([x as f32, y as f32, radius, amp]); + } + Some(drops) +} + +pub(super) fn drizzle(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let Some(drops) = drizzle_drops(ctx, &b) else { return b }; + let rippling = (pr.f("rippling") / 360.0).max(0.05) as f32; + let disp = pr.f("displacement") as f32; + let li = pr.f("light/lightIntensity") as f32 / 100.0; + let lc = pr.color("light/lightColor"); + let lheight = pr.f("light/lightHeight") as f32 / 100.0; + let ldir = (pr.f("light/lightDirection") as f32).to_radians(); + let ambient = pr.f("shading/ambient") as f32 / 100.0; + let diffuse = pr.f("shading/diffuse") as f32 / 100.0; + let specular = pr.f("shading/specular") as f32 / 100.0; + let rough = pr.f("shading/roughness").max(0.001) as f32; + let lh = ctx.layer_size[1] as f32; + let s = b.scale as f32; + let wavelen = (lh * 0.04 * s).max(2.0) / rippling.clamp(0.25, 4.0); + let width = wavelen * (1.0 + rippling * 2.0); + let field = |x: f32, y: f32| -> f32 { + let mut v = 0.0; + for &[dx, dy, r, a] in &drops { + let d = ((x - dx).powi(2) + (y - dy).powi(2)).sqrt(); + let u = (d - r) / width; + if u.abs() < 1.0 { + let env = 0.5 + 0.5 * (u * std::f32::consts::PI).cos(); + v += a * env * ((d - r) / wavelen * std::f32::consts::TAU).cos(); + } + } + v + }; + let src = b.img.clone(); + let (ls, lcs) = ldir.sin_cos(); + let l = { + let v = [lcs, ls, lheight.max(0.05)]; + let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); + [v[0] / n, v[1] / n, v[2] / n] + }; + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5); + let gx = field(fx + 1.0, fy) - field(fx - 1.0, fy); + let gy = field(fx, fy + 1.0) - field(fx, fy - 1.0); + if gx == 0.0 && gy == 0.0 { + continue; + } + let p = src.sample_bilinear_clamped((fx - gx * disp * s * 4.0) as f64, (fy - gy * disp * s * 4.0) as f64); + let n = { + let v = [-gx * 6.0, -gy * 6.0, 1.0]; + let m = (v[0] * v[0] + v[1] * v[1] + 1.0).sqrt(); + [v[0] / m, v[1] / m, v[2] / m] + }; + let nl = (n[0] * l[0] + n[1] * l[1] + n[2] * l[2]).max(0.0); + let hz = (l[2] + 1.0) * 0.5; + let hv = { + let v = [l[0] * 0.5, l[1] * 0.5, hz]; + let m = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); + [v[0] / m, v[1] / m, v[2] / m] + }; + let sp = (n[0] * hv[0] + n[1] * hv[1] + n[2] * hv[2]).max(0.0).powf(1.0 / rough) * specular * li; + let shade = 1.0 + (ambient + diffuse * nl - diffuse * l[2]) * li; + let a = p[3]; + *px = [p[0] * shade + sp * lc[0] * a, p[1] * shade + sp * lc[1] * a, p[2] * shade + sp * lc[2] * a, a]; + } + }); + b +} + +// ------------------------------------------------------------------ CC Star Burst + +pub(super) fn star_burst(ctx: &EffectCtx, b: Buf) -> Buf { + let mut plan = star_burst_plan(ctx, &b); + plan.resolve(&b.img); + plan.finish(b) +} + +pub(super) fn star_burst_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan { + let pr = ctx.params; + let scatter = pr.f("scatter") as f32; + let speed = pr.f("speed") as f32; + let phase = pr.f("phase") as f32 / 360.0; + let spacing = pr.f("gridSpacing").max(1.0) as f32; + let size = pr.f("size") as f32 / 100.0; + let blend = pr.f("blendWithOriginal") as f32 / 100.0; + let seed = ctx.seed.wrapping_mul(0x3c6e); + let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); + let s = b.scale as f32; + let t = ctx.time as f32; + let nx = (lw / spacing).ceil().max(1.0) as u32; + let ny = (lh / spacing).ceil().max(1.0) as u32; + let (cx, cy) = (lw * 0.5, lh * 0.5); + let mut list: Vec<(f32, Sprite, Tint)> = (0..nx * ny) + .into_par_iter() + .map(|i| { + let (gx, gy) = ((i % nx) as f32 * spacing + spacing * 0.5, (i / nx) as f32 * spacing + spacing * 0.5); + let (sx, sy) = b.to_px([gx as f64, gy as f64]); + // Depth cycles towards the viewer: z in [0, 1), perspective 1 / (1 - 0.9 z). + let z = (h(i, 1, seed) + t * speed * 0.25 + phase).rem_euclid(1.0); + let persp = 1.0 / (1.0 - 0.9 * z); + let jx = hs(i, 2, seed) * scatter * 0.01 * lw * 0.5; + let jy = hs(i, 3, seed) * scatter * 0.01 * lh * 0.5; + let x = cx + (gx - cx + jx) * persp; + let y = cy + (gy - cy + jy) * persp; + let (bx, by) = b.to_px([x as f64, y as f64]); + let r = spacing * 0.5 * size * persp * s; + let fade = (z * 8.0).min(1.0) * ((1.0 - z) * 6.0).min(1.0); + // The layer's colour at the grid point (none where it is clear). + (z, Sprite::new(bx as f32, by as f32, r, [0.0, 0.0, 0.0, fade], Shape::Disc), Tint::Source { x: sx, y: sy }) + }) + .collect(); + list.sort_by(|a, b| a.0.total_cmp(&b.0)); + let (sprites, tints) = list.into_iter().map(|(_, s, t)| (s, t)).unzip(); + SpritePlan { sprites, tints, acc: Acc::Over, post: if blend > 0.0 { Post::Lerp(blend) } else { Post::Replace } } +} diff --git a/crates/effects/src/sim2.rs b/crates/effects/src/sim2.rs index cf7322bda..497b60f16 100644 --- a/crates/effects/src/sim2.rs +++ b/crates/effects/src/sim2.rs @@ -16,2683 +16,28 @@ use crate::sim::{Acc, Post, Shape, Sprite, SpritePlan, h, hs, pct, pt, raster, s use crate::util::{Plane, SimCache, gauss_plane, layer_or_self, params_key, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; -// ------------------------------------------------------------------ 3D pieces - -pub(crate) type M3 = [[f64; 3]; 3]; - -fn mat_mul(a: &M3, b: &M3) -> M3 { - std::array::from_fn(|i| std::array::from_fn(|j| (0..3).map(|k| a[i][k] * b[k][j]).sum())) -} - -/// Rotation from Euler angles (radians), applied X then Y then Z. -fn rot_xyz(x: f64, y: f64, z: f64) -> M3 { - let (sx, cx) = x.sin_cos(); - let (sy, cy) = y.sin_cos(); - let (sz, cz) = z.sin_cos(); - let rx = [[1.0, 0.0, 0.0], [0.0, cx, -sx], [0.0, sx, cx]]; - let ry = [[cy, 0.0, sy], [0.0, 1.0, 0.0], [-sy, 0.0, cy]]; - let rz = [[cz, -sz, 0.0], [sz, cz, 0.0], [0.0, 0.0, 1.0]]; - mat_mul(&rz, &mat_mul(&ry, &rx)) -} - -/// Rotation by `angle` about unit `axis` (Rodrigues). -pub(crate) fn rot_axis(axis: [f64; 3], angle: f64) -> M3 { - let l = (axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]).sqrt(); - if l < 1e-12 || angle == 0.0 { - return [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; - } - let (x, y, z) = (axis[0] / l, axis[1] / l, axis[2] / l); - let (s, c) = angle.sin_cos(); - let t = 1.0 - c; - [ - [t * x * x + c, t * x * y - s * z, t * x * z + s * y], - [t * x * y + s * z, t * y * y + c, t * y * z - s * x], - [t * x * z - s * y, t * y * z + s * x, t * z * z + c], - ] -} - -fn inv3(m: &M3) -> Option { - let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) - + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]); - if det.abs() < 1e-12 { - return None; - } - let d = 1.0 / det; - Some([ - [(m[1][1] * m[2][2] - m[1][2] * m[2][1]) * d, (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * d, (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * d], - [(m[1][2] * m[2][0] - m[1][0] * m[2][2]) * d, (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * d, (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * d], - [(m[1][0] * m[2][1] - m[1][1] * m[2][0]) * d, (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * d, (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * d], - ]) -} - -/// Perspective camera looking down +z at the layer plane z = 0, `d` pixels away, centred on -/// `c` (buffer pixels): screen = c + (P - c) · d / (d + z). -#[derive(Clone, Copy)] -struct Cam { - c: [f64; 2], - d: f64, -} - -impl Cam { - fn proj(self) -> Proj { - Proj::simple(self.c, self.d) - } -} - -/// A planar textured piece ready to draw. -#[derive(Clone, Copy, Debug)] -pub struct Piece { - /// Screen (buffer px, homogeneous) → piece-local (a, b, w). - pub inv: [[f64; 3]; 3], - /// Rest centre of the piece in texture pixels. - pub c0: [f32; 2], - /// Convex polygon (texture pixels), counter-clockwise in y-down space. - pub poly: [[f32; 2]; 6], - pub n: u8, - pub bbox: [f32; 4], - pub shade: f32, - /// Lighting: colour multiplier and additive specular highlight. - pub tint: [f32; 3], - pub spec: [f32; 3], - pub alpha: f32, - /// Flat straight colour instead of the texture. - pub flat: Option<[f32; 3]>, - /// Facing away from the camera (draw the back texture). - pub back: bool, - /// Back faces sample their texture mirrored about the piece centre: 0 no, 1 vertically - /// (flipped about the X axis), 2 horizontally (about the Y axis). - pub back_mirror: u8, - depth: f64, -} - -/// A piece texture: the layer itself or another image (buffer-aligned). -#[derive(Clone, Debug)] -pub enum PieceTex { - Layer, - Image(Image), -} - -/// What a piece-drawing effect (Card Dance, Shatter, Card Wipe) draws this frame: pieces sorted -/// far to near over a transparent frame, textured by `front` (and `back` for pieces facing -/// away; none = they take `front`). -#[derive(Clone, Debug)] -pub struct PiecePlan { - pub pieces: Vec, - pub front: PieceTex, - pub back: Option, -} - -impl PiecePlan { - /// Rasterise over a transparent frame of the layer `b`'s size. - pub fn finish(&self, mut b: Buf) -> Buf { - let mut out = Image::new(b.img.width, b.img.height); - let front = match &self.front { - PieceTex::Layer => &b.img, - PieceTex::Image(i) => i, - }; - let back = self.back.as_ref().map(|t| match t { - PieceTex::Layer => &b.img, - PieceTex::Image(i) => i, - }); - draw_pieces(&mut out, &self.pieces, front, back); - b.img = out; - b - } -} - -/// The piece plan of Card Dance, Shatter (Rendered view) or Card Wipe for layer `b` (its pixels -/// may be read: gradient maps and textures default to the layer), `None` for other effects or -/// views drawn otherwise. -pub fn piece_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option { - match id { - "ec.sim.carddance" => Some(card_dance_plan(ctx, b)), - "ec.sim.shatter" if ctx.params.e("view") == 0 => match shatter_impl(ctx, b) { - ShatterPlan::Pieces(plan) => Some(plan), - ShatterPlan::Wire(_) => None, - }, - "ec.transition.cardwipe" => crate::transition::card_wipe_plan(ctx, b), - _ => None, - } -} - -impl Piece { - /// Place polygon `poly` (texture px, around rest centre `c0`) with rotation `r` and its - /// centre at 3D `pos` (buffer px; z away from the camera), seen through `cam`. - pub(crate) fn new(poly: &[[f32; 2]], c0: [f32; 2], r: &M3, pos: [f64; 3], cam: impl Into) -> Option { - let cam: Proj = cam.into(); - let n = poly.len().min(6); - if n < 3 { - return None; - } - let m = &cam.m; - let row = |i: usize| -> [f64; 3] { - [ - m[i][0] * r[0][0] + m[i][1] * r[1][0] + m[i][2] * r[2][0], - m[i][0] * r[0][1] + m[i][1] * r[1][1] + m[i][2] * r[2][1], - m[i][0] * pos[0] + m[i][1] * pos[1] + m[i][2] * pos[2] + m[i][3], - ] - }; - let hm: M3 = [row(0), row(1), row(2)]; - let mut bb = [f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY]; - for v in &poly[..n] { - let (a, b) = ((v[0] - c0[0]) as f64, (v[1] - c0[1]) as f64); - let w = hm[2][0] * a + hm[2][1] * b + hm[2][2]; - if w < cam.near { - return None; - } - let sx = ((hm[0][0] * a + hm[0][1] * b + hm[0][2]) / w) as f32; - let sy = ((hm[1][0] * a + hm[1][1] * b + hm[1][2]) / w) as f32; - bb = [bb[0].min(sx), bb[1].min(sy), bb[2].max(sx), bb[3].max(sy)]; - } - let inv = inv3(&hm)?; - let mut pp = [[0.0f32; 2]; 6]; - pp[..n].copy_from_slice(&poly[..n]); - // Normalise winding so "inside" is cross >= 0 for every edge. - let area: f32 = (0..n).map(|i| pp[i][0] * pp[(i + 1) % n][1] - pp[(i + 1) % n][0] * pp[i][1]).sum(); - if area < 0.0 { - pp[..n].reverse(); - } - // The front faces -z at rest; it faces the camera when its normal points at the eye. - let eye = cam.eye(); - let nrm = [-r[0][2], -r[1][2], -r[2][2]]; - let to_eye = [eye[0] - pos[0], eye[1] - pos[1], eye[2] - pos[2]]; - let facing = nrm[0] * to_eye[0] + nrm[1] * to_eye[1] + nrm[2] * to_eye[2]; - let depth = hm[2][2]; - Some(Piece { - inv, - c0, - poly: pp, - n: n as u8, - bbox: [bb[0] - 1.0, bb[1] - 1.0, bb[2] + 1.0, bb[3] + 1.0], - shade: 1.0, - tint: [1.0; 3], - spec: [0.0; 3], - alpha: 1.0, - flat: None, - back: facing < 0.0, - back_mirror: 0, - depth, - }) - } - - /// Lambert factor of the piece normal against a light shining along +z. - fn facing(r: &M3) -> f32 { - r[2][2].abs() as f32 - } - - /// Light the piece (rotation `r`, centre `pos`) with Lighting / Material. - pub(crate) fn light(&mut self, l: &Lighting, r: &M3, pos: [f64; 3], eye: [f64; 3]) { - let (t, s) = l.shade([-r[0][2], -r[1][2], -r[2][2]], pos, eye); - self.tint = t; - self.spec = s; - } -} - -impl From for Proj { - fn from(c: Cam) -> Proj { - c.proj() - } -} - -/// Draw pieces (already sorted far → near) over `out`. -pub(crate) fn draw_pieces(out: &mut Image, pieces: &[Piece], front: &Image, back: Option<&Image>) { - raster( - out, - pieces, - |q| Some(q.bbox), - |q, x, y| { - let (xf, yf) = (x as f64, y as f64); - let m = &q.inv; - let w = m[2][0] * xf + m[2][1] * yf + m[2][2]; - if w.abs() < 1e-12 { - return None; - } - let u = ((m[0][0] * xf + m[0][1] * yf + m[0][2]) / w) as f32 + q.c0[0]; - let v = ((m[1][0] * xf + m[1][1] * yf + m[1][2]) / w) as f32 + q.c0[1]; - let n = q.n as usize; - let mut md = f32::INFINITY; - for i in 0..n { - let a = q.poly[i]; - let b = q.poly[(i + 1) % n]; - let (ex, ey) = (b[0] - a[0], b[1] - a[1]); - let l = (ex * ex + ey * ey).sqrt().max(1e-6); - md = md.min((ex * (v - a[1]) - ey * (u - a[0])) / l); - } - // Coverage extends half a pixel past the edge so abutting pieces leave no seams. - let cov = (md + 1.0).clamp(0.0, 1.0); - if cov <= 0.0 { - return None; - } - let (c, a) = match q.flat { - Some(c) => (c, 1.0), - None => { - let tex = if q.back { back.unwrap_or(front) } else { front }; - let (u, v) = match (q.back, q.back_mirror) { - (true, 1) => (u, 2.0 * q.c0[1] - v), - (true, 2) => (2.0 * q.c0[0] - u, v), - _ => (u, v), - }; - unpremul(tex.sample_bilinear(u as f64, v as f64)) - } - }; - let a = a * cov * q.alpha; - if a <= 1e-6 { - return None; - } - let k = q.shade; - let o: [f32; 3] = std::array::from_fn(|i| c[i] * k * q.tint[i] + q.spec[i]); - Some([o[0] * a, o[1] * a, o[2] * a, a]) - }, - Acc::Over, - ); -} - -pub(crate) fn sort_far_first(pieces: &mut [Piece]) { - pieces.sort_by(|a, b| b.depth.total_cmp(&a.depth)); -} - -// ------------------------------------------------------------------ CC Ball Action - -fn ball_action(ctx: &EffectCtx, b: Buf) -> Buf { - ball_action_plan(ctx, &b).finish(b) -} - -/// CC Ball Action's balls (far to near), coloured by the layer's pixels. -fn ball_action_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan { - let pr = ctx.params; - let scatter = pr.f("scatter") as f32; - let axis = pr.e("rotationAxis"); - let rot = pr.f("rotation").to_radians(); - let twist_prop = pr.e("twistProperty"); - let twist = pr.f("twistAngle").to_radians(); - let spacing = pr.f("gridSpacing").max(1.0) as f32; - let size = pr.f("ballSize") as f32 / 100.0; - let instab = pr.f("instabilityState").to_radians() as f32; - let seed = ctx.seed.wrapping_mul(0x2545f491); - let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); - let s = b.scale as f32; - let nx = (lw / spacing).ceil().max(1.0) as u32; - let ny = (lh / spacing).ceil().max(1.0) as u32; - let d = 2.0 * lw.max(lh) as f64; - let (cx, cy) = (lw as f64 * 0.5, lh as f64 * 0.5); - let src = &b.img; - let ax: [f64; 3] = match axis { - 0 => [1.0, 0.0, 0.0], - 1 => [0.0, 1.0, 0.0], - 2 => [0.0, 0.0, 1.0], - 3 => [1.0, 1.0, 0.0], - 4 => [1.0, 0.0, 1.0], - 5 => [0.0, 1.0, 1.0], - _ => [1.0, 1.0, 1.0], - }; - let maxr = ((lw * lw + lh * lh).sqrt() * 0.5).max(1.0); - let mut balls: Vec<(f64, Sprite)> = (0..nx * ny) - .into_par_iter() - .filter_map(|i| { - let gx = (i % nx) as f32 * spacing + spacing * 0.5; - let gy = (i / nx) as f32 * spacing + spacing * 0.5; - let (sx, sy) = b.to_px([gx as f64, gy as f64]); - let (c, a) = unpremul(src.sample_bilinear(sx, sy)); - if a <= 0.0 || size <= 0.0 { - return None; - } - let tp = match twist_prop { - 0 => gx / lw.max(1.0), - 1 => gy / lh.max(1.0), - 2 => ((gx - cx as f32).hypot(gy - cy as f32)) / maxr, - _ => crate::util::rgb3([c[0], c[1], c[2], 0.0]).iter().sum::() / 3.0, - } as f64; - let ang = rot + twist * tp; - // Scatter: random 3D offsets whose direction drifts with the instability state. - let ph = h(i, 1, seed) * std::f32::consts::TAU + instab; - let sc = scatter * h(i, 2, seed); - let off = [(sc * ph.cos()) as f64, (sc * ph.sin()) as f64, (scatter * hs(i, 3, seed)) as f64]; - let p0 = [gx as f64 - cx + off[0], gy as f64 - cy + off[1], off[2]]; - let m = rot_axis(ax, ang); - let q: [f64; 3] = std::array::from_fn(|k| m[k][0] * p0[0] + m[k][1] * p0[1] + m[k][2] * p0[2]); - if d + q[2] <= d * 0.05 { - return None; - } - let persp = d / (d + q[2]); - let (bx, by) = b.to_px([cx + q[0] * persp, cy + q[1] * persp]); - let r = spacing * 0.5 * size * persp as f32 * s; - Some((q[2], Sprite::new(bx as f32, by as f32, r, [c[0], c[1], c[2], a], Shape::Sphere))) - }) - .collect(); - balls.sort_by(|a, b| b.0.total_cmp(&a.0)); - let sprites: Vec = balls.into_iter().map(|(_, s)| s).collect(); - SpritePlan { sprites, tints: vec![], acc: Acc::Over, post: Post::Replace } -} - -// ------------------------------------------------------------------ CC Pixel Polly - -fn pixel_polly(ctx: &EffectCtx, b: Buf) -> Buf { - match pixel_polly_plan(ctx, &b) { - Some(plan) => plan.finish(b), - None => b, - } -} - -/// CC Pixel Polly's pieces, `None` before Start Time (the layer as is). -fn pixel_polly_plan(ctx: &EffectCtx, b: &Buf) -> Option { - let pr = ctx.params; - let t = ctx.time - pr.f("startTime"); - if t <= 0.0 { - return None; - } - let force = pr.f("force"); - let gravity = pr.f("gravity"); - let spin = pr.f("spinning").to_radians(); - let fc = pr.v2("forceCenter"); - let dir_rand = pr.f("directionRandomness") / 100.0; - let spd_rand = pr.f("speedRandomness") / 100.0; - let g = pr.f("gridSpacing").max(1.0); - let object = pr.e("object"); - let depth_sort = pr.b("enableDepthSort"); - let seed = ctx.seed.wrapping_mul(0x632be5ab); - let [lw, lh] = ctx.layer_size; - let s = b.scale; - let cam = Cam { c: [b.offset[0] + lw * 0.5 * s, b.offset[1] + lh * 0.5 * s], d: 2.0 * lw.max(lh) * s }; - let nx = (lw / g).ceil().max(1.0) as u32; - let ny = (lh / g).ceil().max(1.0) as u32; - let tri = object <= 1; - let textured = object == 1 || object == 3; - let maxd = lw.max(lh); - let src = &b.img; - let per = if tri { 2 } else { 1 }; - let mut pieces: Vec = (0..nx * ny * per) - .into_par_iter() - .filter_map(|i| { - let cell = i / per; - let (x0, y0) = ((cell % nx) as f64 * g, (cell / nx) as f64 * g); - let poly: Vec<[f64; 2]> = if !tri { - vec![[x0, y0], [x0 + g, y0], [x0 + g, y0 + g], [x0, y0 + g]] - } else if i % 2 == 0 { - vec![[x0, y0], [x0 + g, y0], [x0, y0 + g]] - } else { - vec![[x0 + g, y0], [x0 + g, y0 + g], [x0, y0 + g]] - }; - let c = [poly.iter().map(|v| v[0]).sum::() / poly.len() as f64, poly.iter().map(|v| v[1]).sum::() / poly.len() as f64]; - let (dx, dy) = (c[0] - fc[0], c[1] - fc[1]); - let dist = (dx * dx + dy * dy).sqrt().max(1e-6); - let ang = dy.atan2(dx) + dir_rand * std::f64::consts::PI * hs(i, 1, seed) as f64; - let speed = force / 100.0 * 0.6 * maxd / (1.0 + dist / (0.5 * maxd)) * (1.0 + spd_rand * hs(i, 2, seed) as f64); - let vz = -speed * 0.5 * h(i, 3, seed) as f64; - let pos = [c[0] + ang.cos() * speed * t, c[1] + ang.sin() * speed * t + 0.5 * gravity * 0.5 * lh * t * t, vz * t]; - let axis = [hs(i, 4, seed) as f64, hs(i, 5, seed) as f64, hs(i, 6, seed) as f64]; - let r = rot_axis(axis, t * (std::f64::consts::PI * h(i, 7, seed) as f64 + spin)); - let tex: Vec<[f32; 2]> = poly.iter().map(|v| [(v[0] * s + b.offset[0]) as f32, (v[1] * s + b.offset[1]) as f32]).collect(); - let c0 = [(c[0] * s + b.offset[0]) as f32, (c[1] * s + b.offset[1]) as f32]; - let posb = [pos[0] * s + b.offset[0], pos[1] * s + b.offset[1], pos[2] * s]; - let mut pc = Piece::new(&tex, c0, &r, posb, cam)?; - pc.shade = 0.35 + 0.65 * Piece::facing(&r); - if !textured { - let (cc, a) = unpremul(src.sample_bilinear(c0[0] as f64, c0[1] as f64)); - if a <= 0.0 { - return None; - } - pc.flat = Some(cc); - pc.alpha = a; - } - Some(pc) - }) - .collect(); - if depth_sort { - sort_far_first(&mut pieces); - } - Some(PiecePlan { pieces, front: PieceTex::Layer, back: None }) -} - -// ------------------------------------------------------------------ CC Scatterize - -fn scatterize(ctx: &EffectCtx, b: Buf) -> Buf { - match scatterize_plan(ctx, &b) { - Some(plan) => plan.finish(b), - None => b, - } -} - -/// CC Scatterize's pixel dots, `None` when nothing moves (the layer as is). -fn scatterize_plan(ctx: &EffectCtx, b: &Buf) -> Option { - let pr = ctx.params; - let scatter = pr.f("scatter") as f32; - let right = pr.f("rightTwist").to_radians(); - let left = pr.f("leftTwist").to_radians(); - if scatter == 0.0 && right == 0.0 && left == 0.0 { - return None; - } - let add = pr.e("transferMode") == 1; - let seed = ctx.seed.wrapping_mul(0x7feb352d); - let s = b.scale; - let (w, hh) = (b.img.width, b.img.height); - let (cx, cy) = (b.offset[0] + ctx.layer_size[0] * 0.5 * s, b.offset[1] + ctx.layer_size[1] * 0.5 * s); - let half = (ctx.layer_size[0] * 0.5 * s).max(1.0); - let d = 2.0 * ctx.layer_size[0].max(ctx.layer_size[1]) * s; - let src = &b.img; - let sprites: Vec = (0..w * hh) - .into_par_iter() - .filter_map(|i| { - let px = src.data[i as usize]; - if px[3] <= 0.0 { - return None; - } - let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5); - // Twist about the horizontal axis through the centre, growing towards each side. - let k = ((x - cx) / half).abs().min(1.0); - let ang = if x < cx { left } else { right } * k; - let (sa, ca) = ang.sin_cos(); - let (yy, zz) = ((y - cy) * ca, (y - cy) * sa); - let persp = d / (d + zz).max(d * 0.05); - let jx = hs(i, 1, seed) * scatter * s as f32; - let jy = hs(i, 2, seed) * scatter * s as f32; - let sx = cx + (x - cx) * persp + jx as f64; - let sy = cy + yy * persp + jy as f64; - let (c, a) = unpremul(px); - Some(Sprite::new(sx as f32, sy as f32, 0.6 * persp as f32, [c[0], c[1], c[2], a], Shape::Disc)) - }) - .collect(); - Some(SpritePlan { sprites, tints: vec![], acc: if add { Acc::Add } else { Acc::Over }, post: Post::Replace }) -} - -/// What a particle effect that reads the layer's pixels draws this frame (CC Ball Action, CC -/// Pixel Polly, CC Scatterize, Shatter's wireframe views), for layer `b`; `None` for other -/// effects or a frame showing the layer as is. -pub enum PixelPlan { - Sprites(SpritePlan), - Pieces(PiecePlan), -} - -/// The [`PixelPlan`] of effect `id` for layer `b` (its pixels are read). -pub fn pixel_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option { - match id { - "ec.sim.ccballaction" => Some(PixelPlan::Sprites(ball_action_plan(ctx, b))), - "ec.sim.ccpixelpolly" => pixel_polly_plan(ctx, b).map(PixelPlan::Pieces), - "ec.sim.ccscatterize" => scatterize_plan(ctx, b).map(PixelPlan::Sprites), - "ec.sim.shatter" => Some(match shatter_impl(ctx, b) { - ShatterPlan::Pieces(plan) => PixelPlan::Pieces(plan), - ShatterPlan::Wire(plan) => PixelPlan::Sprites(plan), - }), - _ => None, - } -} - -// ------------------------------------------------------------------ Card Dance - -const CD_SOURCES: &[&str] = &["None", "Intensity 1", "Red 1", "Green 1", "Blue 1", "Alpha 1", "Intensity 2", "Red 2", "Green 2", "Blue 2", "Alpha 2"]; -/// (param id prefix, twirl-down group match id) per transformed card property. -const CD_PROPS: [(&str, &str); 8] = [ - ("xPos", "xPosition"), - ("yPos", "yPosition"), - ("zPos", "zPosition"), - ("xRot", "xRotation"), - ("yRot", "yRotation"), - ("zRot", "zRotation"), - ("xScale", "xScale"), - ("yScale", "yScale"), -]; - -fn card_source(src: u32, g1: [f32; 4], g2: [f32; 4]) -> f32 { - let (c1, a1) = unpremul(g1); - let (c2, a2) = unpremul(g2); - let lum = |c: [f32; 3]| effectcraft_color::luminance(c[0], c[1], c[2]); - match src { - 1 => lum(c1), - 2 => c1[0], - 3 => c1[1], - 4 => c1[2], - 5 => a1, - 6 => lum(c2), - 7 => c2[0], - 8 => c2[1], - 9 => c2[2], - 10 => a2, - _ => 0.0, - } -} - -fn card_dance(ctx: &EffectCtx, b: Buf) -> Buf { - card_dance_plan(ctx, &b).finish(b) -} - -fn card_dance_plan(ctx: &EffectCtx, b: &Buf) -> PiecePlan { - let pr = ctx.params; - let rows = pr.f("rows").clamp(1.0, 1000.0) as u32; - let cols = if pr.e("rowsColumns") == 1 { rows } else { pr.f("columns").clamp(1.0, 1000.0) as u32 }; - let g1 = layer_or_self(ctx, b, "gradientLayer1", true, true); - let g2 = layer_or_self(ctx, b, "gradientLayer2", true, true); - let back = ctx.layer_param("backLayer", true).map(|o| crate::util::fit_layer(ctx, b, &o, true)); - let [lw, lh] = ctx.layer_size; - let s = b.scale; - let (cw, ch) = (lw / cols as f64, lh / rows as f64); - let cam = crate::card3d::projection(ctx, b); - let eye = cam.eye(); - let light = Lighting::from(ctx, b); - let props: Vec<(u32, f64, f64)> = - CD_PROPS.iter().map(|(id, g)| (pr.e(&format!("{g}/{id}Source")), pr.f(&format!("{g}/{id}Multiplier")), pr.f(&format!("{g}/{id}Offset")))).collect(); - let mut pieces: Vec = (0..rows * cols) - .into_par_iter() - .filter_map(|i| { - let (cx0, cy0) = ((i % cols) as f64 * cw, (i / cols) as f64 * ch); - let c = [cx0 + cw * 0.5, cy0 + ch * 0.5]; - let (bx, by) = (c[0] * s + b.offset[0], c[1] * s + b.offset[1]); - let (gp1, gp2) = (g1.sample_bilinear(bx, by), g2.sample_bilinear(bx, by)); - let val = |k: usize| { - let (sr, m, o) = props[k]; - o + m * card_source(sr, gp1, gp2) as f64 - }; - let (sx, sy) = (val(6), val(7)); - let r = rot_xyz(val(3).to_radians(), val(4).to_radians(), val(5).to_radians()); - let rs: M3 = std::array::from_fn(|k| [r[k][0] * sx, r[k][1] * sy, r[k][2]]); - let pos = [bx + val(0) * cw * s, by + val(1) * ch * s, val(2) * cw.max(ch) * s]; - let (tx0, ty0) = ((cx0 * s + b.offset[0]) as f32, (cy0 * s + b.offset[1]) as f32); - let (tw, th) = ((cw * s) as f32, (ch * s) as f32); - let poly = [[tx0, ty0], [tx0 + tw, ty0], [tx0 + tw, ty0 + th], [tx0, ty0 + th]]; - let mut pc = Piece::new(&poly, [bx as f32, by as f32], &rs, pos, cam)?; - pc.light(&light, &r, pos, eye); - Some(pc) - }) - .collect(); - sort_far_first(&mut pieces); - PiecePlan { pieces, front: PieceTex::Layer, back: back.map(PieceTex::Image) } -} - -// ------------------------------------------------------------------ Shatter - -/// Shatter's Pattern options; the last, Custom, takes its pieces from Custom Shatter Map. -const SHATTER_PATTERNS: &[&str] = &["Bricks", "Glass", "Hexagons", "Squares", "Triangles", "Custom"]; - -/// Pattern cells (polygons in pattern space, before rotation) covering `[x0, x1]×[y0, y1]`. -fn pattern_cells(kind: u32, cell: f64, x0: f64, y0: f64, x1: f64, y1: f64, seed: u32) -> Vec> { - let mut out = Vec::new(); - let i0 = (x0 / cell).floor() as i64 - 1; - let i1 = (x1 / cell).ceil() as i64 + 1; - let j0 = (y0 / cell).floor() as i64 - 1; - let j1 = (y1 / cell).ceil() as i64 + 1; - match kind { - 0 => { - let bh = cell * 0.5; - let r0 = (y0 / bh).floor() as i64 - 1; - let r1 = (y1 / bh).ceil() as i64 + 1; - for r in r0..=r1 { - let sh = if r.rem_euclid(2) == 1 { cell * 0.5 } else { 0.0 }; - for i in i0..=i1 { - let (x, y) = (i as f64 * cell + sh, r as f64 * bh); - out.push(vec![[x, y], [x + cell, y], [x + cell, y + bh], [x, y + bh]]); - } - } - } - 1 => { - // Jittered lattice split into triangles along a random diagonal. - let jit = |i: i64, j: i64| -> [f64; 2] { - let (a, b) = (i as u32, j as u32); - [i as f64 * cell + hs(a, b.wrapping_add(17), seed) as f64 * cell * 0.35, j as f64 * cell + hs(a, b.wrapping_add(91), seed) as f64 * cell * 0.35] - }; - for j in j0..=j1 { - for i in i0..=i1 { - let (p00, p10, p11, p01) = (jit(i, j), jit(i + 1, j), jit(i + 1, j + 1), jit(i, j + 1)); - if h(i as u32, j as u32, seed ^ 0x55) < 0.5 { - out.push(vec![p00, p10, p11]); - out.push(vec![p00, p11, p01]); - } else { - out.push(vec![p00, p10, p01]); - out.push(vec![p10, p11, p01]); - } - } - } - } - 2 => { - let r = cell / 3f64.sqrt(); - let dx = 1.5 * r; - let dy = 3f64.sqrt() * r; - let c0 = (x0 / dx).floor() as i64 - 1; - let c1 = (x1 / dx).ceil() as i64 + 1; - let rr0 = (y0 / dy).floor() as i64 - 1; - let rr1 = (y1 / dy).ceil() as i64 + 1; - for ci in c0..=c1 { - for rj in rr0..=rr1 { - let cx = ci as f64 * dx; - let cy = (rj as f64 + if ci.rem_euclid(2) == 1 { 0.5 } else { 0.0 }) * dy; - out.push( - (0..6) - .map(|k| { - let a = k as f64 * std::f64::consts::PI / 3.0; - [cx + r * a.cos(), cy + r * a.sin()] - }) - .collect(), - ); - } - } - } - 3 => { - for j in j0..=j1 { - for i in i0..=i1 { - let (x, y) = (i as f64 * cell, j as f64 * cell); - out.push(vec![[x, y], [x + cell, y], [x + cell, y + cell], [x, y + cell]]); - } - } - } - _ => { - for j in j0..=j1 { - for i in i0..=i1 { - let (x, y) = (i as f64 * cell, j as f64 * cell); - if (i + j).rem_euclid(2) == 0 { - out.push(vec![[x, y], [x + cell, y], [x, y + cell]]); - out.push(vec![[x + cell, y], [x + cell, y + cell], [x, y + cell]]); - } else { - out.push(vec![[x, y], [x + cell, y], [x + cell, y + cell]]); - out.push(vec![[x, y], [x + cell, y + cell], [x, y + cell]]); - } - } - } - } - } - out -} - -/// `∫0^t e^{-ks} ds` and `∫0^t ∫0^u e^{-ks} ds du` (drag-damped ballistic factors). -fn drag_factors(k: f64, t: f64) -> (f64, f64) { - if k < 1e-6 { - (t, 0.5 * t * t) - } else { - let e = (-k * t).exp(); - ((1.0 - e) / k, t / k - (1.0 - e) / (k * k)) - } -} -/// Shatter's Front / Side / Back Mode options. -const SHATTER_TEXTURE_MODES: [&str; 3] = ["Color", "Layer", "Tinted Layer"]; - -/// One face's texture: a flat colour, a layer, or a layer tinted by the Textures colour. -struct Face { - mode: u32, - img: Option, -} - -impl Face { - fn from(ctx: &EffectCtx, b: &Buf, mode: &str, layer: &str, default_mode: u32) -> Face { - let mode = ctx.params.get(mode).map(|v| v.as_enum()).unwrap_or(default_mode); - // A face layer of None is the effect's own layer. - let img = (mode != 0).then(|| layer_or_self(ctx, b, layer, true, true)); - Face { mode, img } - } -} - -/// Group key of a Custom Shatter Map colour (pieces of one colour break as one). -fn map_key(c: [f32; 4]) -> u32 { - let (c, a) = unpremul(c); - if a < 0.5 { - return u32::MAX; - } - let q = |v: f32| ((v.clamp(0.0, 1.0) * 15.0).round() as u32) & 15; - q(c[0]) << 8 | q(c[1]) << 4 | q(c[2]) -} - -fn shatter(ctx: &EffectCtx, b: Buf) -> Buf { - match shatter_impl(ctx, &b) { - ShatterPlan::Pieces(plan) => plan.finish(b), - ShatterPlan::Wire(plan) => plan.finish(b), - } -} - -/// What Shatter draws: the pieces (Rendered view) or the wireframe lines. -enum ShatterPlan { - Pieces(PiecePlan), - Wire(SpritePlan), -} - -fn shatter_impl(ctx: &EffectCtx, b: &Buf) -> ShatterPlan { - let pr = ctx.params; - let view = pr.e("view"); - let render = pr.e("render"); - let kind = pr.e("shape/pattern"); - let custom = kind as usize == SHATTER_PATTERNS.len() - 1; - let reps = pr.f("shape/repetitions").clamp(1.0, 500.0); - let dir = pr.f("shape/direction").to_radians(); - let origin = pr.v2("shape/origin"); - let forces = [ - (pr.v2("force1/force1Position"), pr.f("force1/force1Depth"), pr.f("force1/force1Radius"), pr.f("force1/force1Strength")), - (pr.v2("force2/force2Position"), pr.f("force2/force2Depth"), pr.f("force2/force2Radius"), pr.f("force2/force2Strength")), - ]; - let rot_speed = pr.f("physics/rotationSpeed"); - let tumble = pr.e("physics/tumbleAxis"); - let randomness = pr.f("physics/randomness"); - let k = pr.f("physics/viscosity").max(0.0) * 5.0; - let mass_var = pr.f("physics/massVariance") / 100.0; - let gravity = pr.f("physics/gravity"); - let gdir = pr.f("physics/gravityDirection").to_radians(); - let ginc = pr.f("physics/gravityInclination").to_radians(); - let seed = (pr.f("randomSeed") as u32).wrapping_mul(0x9e3779b1) ^ ctx.seed; - let [lw, lh] = ctx.layer_size; - let s = b.scale; - let t = ctx.time.max(0.0); - let unit = lw * 0.1; - let cell = lw / reps; - let extrude = pr.f("shape/extrusionDepth").max(0.0) * cell * s; - let cam = crate::card3d::projection(ctx, b); - let eye = cam.eye(); - let light = Lighting::from(ctx, b); - // Gradient: with a gradient layer only pieces at least as bright as 1 − Shatter Threshold - // (Invert Gradient: as dark) break. - let gradient = ctx.layer_param("gradient/gradientLayer", true).map(|o| crate::util::fit_layer(ctx, b, &o, true)); - let threshold = pr.get("gradient/shatterThreshold").map(Value::as_f64).unwrap_or(0.0) / 100.0; - let invert_gradient = pr.b("gradient/invertGradient"); - // Textures. - let tex_color = pr.get("textures/color").map(|v| v.as_color()).unwrap_or([1.0; 4]); - let tex_opacity = pr.get("textures/opacity").map(Value::as_f64).unwrap_or(1.0).clamp(0.0, 1.0) as f32; - let front = Face::from(ctx, b, "textures/frontMode", "textures/frontLayer", 1); - let side = Face::from(ctx, b, "textures/sideMode", "textures/sideLayer", 0); - let backf = Face::from(ctx, b, "textures/backMode", "textures/backLayer", 1); - let custom_map = custom.then(|| layer_or_self(ctx, b, "shape/customShatterMap", true, true)); - let white_fixed = pr.b("shape/whiteTilesFixed"); - let (sd, cd) = dir.sin_cos(); - let to_layer = |q: [f64; 2]| [origin[0] + q[0] * cd - q[1] * sd, origin[1] + q[0] * sd + q[1] * cd]; - // Pattern-space bounds of the layer rectangle. - let corners = [[0.0, 0.0], [lw, 0.0], [lw, lh], [0.0, lh]].map(|c: [f64; 2]| { - let (dx, dy) = (c[0] - origin[0], c[1] - origin[1]); - [dx * cd + dy * sd, -dx * sd + dy * cd] - }); - let (px0, px1) = (corners.iter().map(|c| c[0]).fold(f64::INFINITY, f64::min), corners.iter().map(|c| c[0]).fold(f64::NEG_INFINITY, f64::max)); - let (py0, py1) = (corners.iter().map(|c| c[1]).fold(f64::INFINITY, f64::min), corners.iter().map(|c| c[1]).fold(f64::NEG_INFINITY, f64::max)); - // Custom Shatter Map: square cells grouped by the map's colour. - let cells = pattern_cells(if custom { 3 } else { kind }, cell, px0, py0, px1, py1, seed); - let polys: Vec> = cells.iter().map(|poly| poly.iter().map(|&q| to_layer(q)).collect()).collect(); - let centre = |lp: &[[f64; 2]]| [lp.iter().map(|v| v[0]).sum::() / lp.len() as f64, lp.iter().map(|v| v[1]).sum::() / lp.len() as f64]; - let keys: Vec = match &custom_map { - Some(m) => polys - .iter() - .map(|lp| { - let c = centre(lp); - map_key(m.sample_bilinear(c[0] * s + b.offset[0], c[1] * s + b.offset[1])) - }) - .collect(), - None => (0..polys.len() as u32).collect(), - }; - // Group centres (custom map) — each group moves as one rigid piece. - let mut groups: std::collections::HashMap = std::collections::HashMap::new(); - for (lp, &key) in polys.iter().zip(&keys) { - let c = centre(lp); - let e = groups.entry(key).or_insert(([0.0; 2], 0.0)); - e.0[0] += c[0]; - e.0[1] += c[1]; - e.1 += 1.0; - } - let g3 = [gdir.sin() * ginc.cos() * gravity * unit, -gdir.cos() * ginc.cos() * gravity * unit, ginc.sin() * gravity * unit]; - let mut pieces: Vec = polys - .par_iter() - .zip(keys.par_iter()) - .flat_map_iter(|(lp, &key)| { - let mut out: Vec = vec![]; - let (mnx, mxx) = (lp.iter().map(|v| v[0]).fold(f64::INFINITY, f64::min), lp.iter().map(|v| v[0]).fold(f64::NEG_INFINITY, f64::max)); - let (mny, mxy) = (lp.iter().map(|v| v[1]).fold(f64::INFINITY, f64::min), lp.iter().map(|v| v[1]).fold(f64::NEG_INFINITY, f64::max)); - if mxx < 0.0 || mxy < 0.0 || mnx > lw || mny > lh || key == u32::MAX { - return out.into_iter(); - } - let pc_c = centre(lp); - let (gsum, gn) = groups[&key]; - let gc = [gsum[0] / gn, gsum[1] / gn]; - let i = key; - // Broken when inside a force sphere (centred `depth` in front of the layer). - let mut v = [0.0f64; 3]; - let mut broken = false; - for (fp, fd, fr, fs) in forces { - let rad = fr * lw; - if rad <= 0.0 { - continue; - } - let dv = [gc[0] - fp[0], gc[1] - fp[1], fd * lw]; - let dist = (dv[0] * dv[0] + dv[1] * dv[1] + dv[2] * dv[2]).sqrt(); - if dist < rad { - broken = true; - let f = fs * (1.0 - dist / rad) * unit / dist.max(1e-6); - for q in 0..3 { - v[q] += dv[q] * f; - } - } - } - if broken && let Some(g) = &gradient { - let (c, _) = unpremul(g.sample_bilinear(gc[0] * s + b.offset[0], gc[1] * s + b.offset[1])); - let mut l = effectcraft_color::luminance(c[0], c[1], c[2]).clamp(0.0, 1.0) as f64; - if invert_gradient { - l = 1.0 - l; - } - broken = threshold > 0.0 && l >= 1.0 - threshold; - } - if broken && custom && white_fixed && key == 0xfff { - broken = false; - } - if (broken && render == 1) || (!broken && render == 2) { - return out.into_iter(); - } - let mut gpos = [gc[0], gc[1], 0.0]; - let mut r = rot_axis([1.0, 0.0, 0.0], 0.0); - let moving = broken && t > 0.0; - if moving { - let mass = 1.0 + mass_var * hs(i, 1, seed) as f64; - for q in 0..3 { - v[q] = v[q] / mass + randomness * unit * hs(i, 2 + q as u32, seed) as f64 * 2.0; - } - let (f1, f2) = drag_factors(k, t); - for q in 0..3 { - gpos[q] += v[q] * f1 + g3[q] * f2; - } - let axis = match tumble { - 1 => [0.0, 0.0, 0.0], - 2 => [1.0, 0.0, 0.0], - 3 => [0.0, 1.0, 0.0], - 4 => [0.0, 0.0, 1.0], - 5 => [hs(i, 6, seed) as f64, hs(i, 7, seed) as f64, 0.0], - 6 => [hs(i, 6, seed) as f64, 0.0, hs(i, 8, seed) as f64], - 7 => [0.0, hs(i, 7, seed) as f64, hs(i, 8, seed) as f64], - _ => [hs(i, 6, seed) as f64, hs(i, 7, seed) as f64, hs(i, 8, seed) as f64], - }; - let speed = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt() / unit; - let omega = rot_speed * std::f64::consts::TAU * (0.5 + h(i, 9, seed) as f64) * speed.min(10.0); - r = rot_axis(axis, omega * f1); - } - // The piece's centre: its offset from the group centre, rotated with the group. - let off = [pc_c[0] - gc[0], pc_c[1] - gc[1], 0.0]; - let pos: [f64; 3] = std::array::from_fn(|q| gpos[q] + (0..3).map(|k| r[q][k] * off[k]).sum::()); - let tex: Vec<[f32; 2]> = lp.iter().map(|v| [(v[0] * s + b.offset[0]) as f32, (v[1] * s + b.offset[1]) as f32]).collect(); - let c0 = [(pc_c[0] * s + b.offset[0]) as f32, (pc_c[1] * s + b.offset[1]) as f32]; - let posb = [pos[0] * s + b.offset[0], pos[1] * s + b.offset[1], pos[2] * s]; - let Some(mut pc) = Piece::new(&tex, c0, &r, posb, cam) else { return out.into_iter() }; - pc.light(&light, &r, posb, eye); - pc.alpha = tex_opacity; - let face = if pc.back { &backf } else { &front }; - if face.mode == 0 { - pc.flat = Some([tex_color[0], tex_color[1], tex_color[2]]); - } else if face.mode == 2 { - pc.tint = std::array::from_fn(|q| pc.tint[q] * tex_color[q]); - } - // Extrusion: the sides of a moving piece, from its front outline back by the depth. - if moving && extrude > 0.0 { - let nb = [r[0][2], r[1][2], r[2][2]]; - let n = tex.len(); - for e in 0..n { - let (a, bv) = (tex[e], tex[(e + 1) % n]); - let ed = [(bv[0] - a[0]) as f64, (bv[1] - a[1]) as f64]; - let len = (ed[0] * ed[0] + ed[1] * ed[1]).sqrt(); - if len < 1e-6 { - continue; - } - let ew: [f64; 3] = std::array::from_fn(|q| (r[q][0] * ed[0] + r[q][1] * ed[1]) / len); - let third = [ew[1] * nb[2] - ew[2] * nb[1], ew[2] * nb[0] - ew[0] * nb[2], ew[0] * nb[1] - ew[1] * nb[0]]; - let rs: M3 = std::array::from_fn(|q| [ew[q], nb[q], third[q]]); - // Side centre in world: edge midpoint (world) plus half the depth backwards. - let mid = [((a[0] + bv[0]) * 0.5 - c0[0]) as f64, ((a[1] + bv[1]) * 0.5 - c0[1]) as f64]; - let mw: [f64; 3] = std::array::from_fn(|q| posb[q] + r[q][0] * mid[0] + r[q][1] * mid[1] + nb[q] * extrude * 0.5); - let (hl, hd) = ((len * 0.5) as f32, (extrude * 0.5) as f32); - let cs = [(a[0] + bv[0]) * 0.5, (a[1] + bv[1]) * 0.5]; - let quad = [[cs[0] - hl, cs[1] - hd], [cs[0] + hl, cs[1] - hd], [cs[0] + hl, cs[1] + hd], [cs[0] - hl, cs[1] + hd]]; - if let Some(mut sp) = Piece::new(&quad, cs, &rs, mw, cam) { - sp.light(&light, &rs, mw, eye); - sp.alpha = tex_opacity; - let colour = match (&side.img, side.mode) { - (Some(img), m) if m != 0 => { - let (c, _) = unpremul(img.sample_bilinear(cs[0] as f64, cs[1] as f64)); - if m == 2 { std::array::from_fn(|q| c[q] * tex_color[q]) } else { c } - } - _ => [tex_color[0], tex_color[1], tex_color[2]], - }; - sp.flat = Some(colour); - out.push(sp); - } - } - } - out.push(pc); - out.into_iter() - }) - .collect(); - sort_far_first(&mut pieces); - if view == 0 { - let tex = |i: &Option| i.clone().map(PieceTex::Image); - let front_t = tex(&front.img).unwrap_or(PieceTex::Layer); - let back_t = tex(&backf.img).unwrap_or_else(|| front_t.clone()); - return ShatterPlan::Pieces(PiecePlan { pieces, front: front_t, back: Some(back_t) }); - } - { - // Wireframes: piece outlines (front view: at rest; otherwise animated), plus forces. - let animated = view == 2 || view == 4; - let mut lines = Vec::new(); - for pc in &pieces { - let n = pc.n as usize; - let pts: Vec<[f32; 2]> = if animated { - let fw = inv3(&pc.inv); - match fw { - Some(m) => pc.poly[..n] - .iter() - .map(|v| { - let (a, bb) = ((v[0] - pc.c0[0]) as f64, (v[1] - pc.c0[1]) as f64); - let w = m[2][0] * a + m[2][1] * bb + m[2][2]; - [((m[0][0] * a + m[0][1] * bb + m[0][2]) / w) as f32, ((m[1][0] * a + m[1][1] * bb + m[1][2]) / w) as f32] - }) - .collect(), - None => continue, - } - } else { - pc.poly[..n].to_vec() - }; - for e in 0..n { - let (a, c) = (pts[e], pts[(e + 1) % n]); - lines.push(Sprite::new(a[0], a[1], 0.5, [0.75, 0.85, 1.0, 1.0], Shape::Line { dx: c[0] - a[0], dy: c[1] - a[1] })); - } - } - if view >= 3 { - for (fp, _, fr, _) in forces { - let (fx, fy) = b.to_px(fp); - let rad = fr * lw * s; - for k in 0..64 { - let (a0, a1) = (k as f64 / 64.0 * std::f64::consts::TAU, (k + 1) as f64 / 64.0 * std::f64::consts::TAU); - let (x0, y0) = (fx + rad * a0.cos(), fy + rad * a0.sin()); - let (x1, y1) = (fx + rad * a1.cos(), fy + rad * a1.sin()); - lines.push(Sprite::new(x0 as f32, y0 as f32, 0.5, [1.0, 0.3, 0.3, 1.0], Shape::Line { dx: (x1 - x0) as f32, dy: (y1 - y0) as f32 })); - } - } - } - ShatterPlan::Wire(SpritePlan { sprites: lines, tints: vec![], acc: Acc::Over, post: Post::Replace }) - } -} - -// ------------------------------------------------------------------ Caustics - -/// Sample with repeat mode: 0 Once (transparent outside), 1 Tiled, 2 Reflected. -fn sample_repeat(img: &Image, x: f64, y: f64, mode: u32) -> Px { - let (w, hh) = (img.width as f64, img.height as f64); - match mode { - 1 => img.sample_bilinear_clamped(x.rem_euclid(w), y.rem_euclid(hh)), - 2 => { - let m = |v: f64, n: f64| { - let r = v.rem_euclid(2.0 * n); - if r > n { 2.0 * n - r } else { r } - }; - img.sample_bilinear_clamped(m(x, w), m(y, hh)) - } - _ => img.sample_bilinear(x, y), - } -} - -/// Everything Caustics' per-pixel pass needs, prepared on the CPU (the other layers it reads -/// and the parameter conversions); the layer's own pixels are not read. -pub struct CausticsSetup { - /// The Bottom layer fitted to the buffer, `None` = the layer itself. - pub bottom: Option, - /// Gaussian σ (buffer px) blurring the bottom (transparent edges), 0 = none. - pub bottom_sigma: f64, - /// The water surface's height field (smoothed luminance), `None` = flat. - pub height: Option, - /// The Sky layer fitted to the buffer. - pub sky: Option, - pub scaling: f64, - pub repeat: u32, - pub wave_h: f64, - /// Displacement per unit of height gradient. - pub k: f64, - /// Layer centre (buffer px). - pub cx: f64, - pub cy: f64, - /// Layer width (buffer px). - pub lw: f64, - pub surf: [f32; 4], - pub surf_op: f32, - pub cstr: f32, - pub lc: [f32; 4], - pub li: f32, - /// Light position (buffer px) and height (layer widths); `point` = from the position to - /// each pixel, else one direction from above the layer centre. - pub lx: f64, - pub ly: f64, - pub lheight: f64, - pub point: bool, - pub ambient: f32, - pub diffuse: f32, - pub specular: f32, - pub sharp: f32, - pub sky_scaling: f64, - pub sky_repeat: u32, - pub sky_int: f32, - pub convergence: f64, -} - -/// Caustics' setup for layer geometry `b` (see [`CausticsSetup`]). -pub fn caustics_setup(ctx: &EffectCtx, b: &Buf) -> CausticsSetup { - let pr = ctx.params; - let s = b.scale; - let bottom = ctx.layer_param("bottom/bottom", true).map(|o| crate::util::fit_layer(ctx, b, &o, pr.e("bottom/bottomSizeDiffers") == 1)); - let blur = pr.f("bottom/blur") * s; - let wave_h = pr.f("water/waveHeight"); - let smoothing = pr.f("water/smoothing") * s; - let depth = pr.f("water/waterDepth"); - let ior = pr.f("water/refractiveIndex").max(1.0); - let lpos = pr.v2("lighting/lightPosition"); - // Height field of the water surface (luminance of the chosen layer; none = flat). - let height = ctx.layer_param("water/waterSurface", true).map(|o| { - let img = crate::util::fit_layer(ctx, b, &o, true); - let pl = Plane::luma(&img); - if smoothing > 0.05 { gauss_plane(&pl, smoothing * 0.5, smoothing * 0.5) } else { pl } - }); - let lw = ctx.layer_size[0] * s; - let k = wave_h * depth * (1.0 - 1.0 / ior) * lw * 2.0; - let (cx, cy) = (b.offset[0] + ctx.layer_size[0] * 0.5 * s, b.offset[1] + ctx.layer_size[1] * 0.5 * s); - let (lx, ly) = b.to_px(lpos); - let (lc, li, lheight) = (pr.color("lighting/lightColor"), pr.f("lighting/lightIntensity") as f32, pr.f("lighting/lightHeight").max(0.01)); - // Light Type: Distant Source (one direction, from the light position above the layer - // centre), Point Source (from the light position to each pixel) or First Comp Light. - let light_type = pr.e("lighting/lightType"); - let comp_light = if light_type == 2 { ctx.env.host.and_then(|h| h.comp_scene()).and_then(|sc| sc.light) } else { None }; - let (lc, li, lx, ly, lheight, point) = match (light_type, comp_light) { - (2, Some(l)) => { - let c = [l.color[0], l.color[1], l.color[2], 1.0]; - // Comp space: z away from the viewer; the light above the water is at −z. - let (px, py) = (l.pos[0] * s + b.offset[0], l.pos[1] * s + b.offset[1]); - (c, li, px, py, (-l.pos[2] / ctx.layer_size[0].max(1.0)).max(0.01), l.kind == 1) - } - (2, None) => (lc, 0.0, lx, ly, lheight, false), - (1, _) => (lc, li, lx, ly, lheight, true), - _ => (lc, li, lx, ly, lheight, false), - }; - // Sky: the layer the water surface reflects (Surface Opacity 1 = a mirror of it). - let sky = ctx.layer_param("sky/sky", true).map(|o| crate::util::fit_layer(ctx, b, &o, pr.e("sky/skySizeDiffers") == 1)); - CausticsSetup { - bottom, - bottom_sigma: if blur > 0.05 { blur * 0.5 } else { 0.0 }, - height, - sky, - scaling: pr.f("bottom/scaling").max(0.01), - repeat: pr.e("bottom/repeatMode"), - wave_h, - k, - cx, - cy, - lw, - surf: pr.color("water/surfaceColor"), - surf_op: pr.f("water/surfaceOpacity") as f32, - cstr: pr.f("water/causticsStrength") as f32, - lc, - li, - lx, - ly, - lheight, - point, - ambient: pr.f("lighting/ambientLight") as f32, - diffuse: pr.f("material/diffuse") as f32, - specular: pr.f("material/specular") as f32, - sharp: pr.f("material/highlightSharpness").max(1.0) as f32, - sky_scaling: pr.get("sky/scaling").map(Value::as_f64).unwrap_or(1.0).max(0.01), - sky_repeat: pr.e("sky/repeatMode"), - sky_int: pr.get("sky/intensity").map(Value::as_f64).unwrap_or(0.3) as f32, - convergence: pr.get("sky/convergence").map(Value::as_f64).unwrap_or(0.5).clamp(0.0, 1.0), - } -} - -fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf { - let st = caustics_setup(ctx, &b); - let mut bottom = st.bottom.clone().unwrap_or_else(|| b.img.clone()); - if st.bottom_sigma > 0.0 { - bottom = effectcraft_raster::gaussian_blur(&bottom, st.bottom_sigma, st.bottom_sigma, false); - } - let CausticsSetup { - scaling, - repeat, - wave_h, - k, - cx, - cy, - lw, - surf, - surf_op, - cstr, - lc, - li, - lx, - ly, - lheight, - point, - ambient, - diffuse, - specular, - sharp, - sky_scaling, - sky_repeat, - sky_int, - convergence, - .. - } = st; - let (height, sky) = (&st.height, &st.sky); - let (w, hh) = (b.img.width as usize, b.img.height as usize); - let norm3 = |v: [f64; 3]| { - let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12); - [(v[0] / n) as f32, (v[1] / n) as f32, (v[2] / n) as f32] - }; - let lv_at = |x: f64, y: f64| { - if point { - norm3([(lx - x) / lw.max(1.0), (ly - y) / lw.max(1.0), lheight]) - } else { - norm3([(lx - cx) / lw.max(1.0), (ly - cy) / lw.max(1.0), lheight]) - } - }; - let grad = |x: usize, y: usize| -> (f64, f64) { - match height { - Some(pl) => { - let gx = (pl.get_clamped(x as i64 + 1, y as i64) - pl.get_clamped(x as i64 - 1, y as i64)) as f64 * 0.5; - let gy = (pl.get_clamped(x as i64, y as i64 + 1) - pl.get_clamped(x as i64, y as i64 - 1)) as f64 * 0.5; - (gx, gy) - } - None => (0.0, 0.0), - } - }; - // Displacement and its Jacobian (caustic focusing) per pixel. - let disp: Vec<(f64, f64)> = (0..w * hh) - .into_par_iter() - .map(|i| { - let (gx, gy) = grad(i % w, i / w); - (-gx * k, -gy * k) - }) - .collect(); - b.img.rows_mut().for_each(|(y, row)| { - for (x, px) in row.iter_mut().enumerate() { - let i = y * w + x; - let (dx, dy) = disp[i]; - let d = |xx: usize, yy: usize| disp[yy.min(hh - 1) * w + xx.min(w - 1)]; - let (dxr, _) = d(x + 1, y); - let (_, dyd) = d(x, y + 1); - let (dxl, _) = d(x.saturating_sub(1), y); - let (_, dyu) = d(x, y.saturating_sub(1)); - let jac = (1.0 + (dxr - dxl) * 0.5) * (1.0 + (dyd - dyu) * 0.5); - let focus = ((1.0 / jac.abs().max(0.2)) as f32 - 1.0).clamp(-1.0, 4.0); - let sx = cx + (x as f64 + 0.5 + dx - cx) / scaling; - let sy = cy + (y as f64 + 0.5 + dy - cy) / scaling; - let p = sample_repeat(&bottom, sx, sy, repeat); - let (c, a) = unpremul(p); - let (gx, gy) = grad(x, y); - let n = { - let v = [-gx * wave_h * 20.0, -gy * wave_h * 20.0, 1.0]; - let m = (v[0] * v[0] + v[1] * v[1] + 1.0).sqrt(); - [(v[0] / m) as f32, (v[1] / m) as f32, (v[2] / m) as f32] - }; - let lv = lv_at(x as f64 + 0.5, y as f64 + 0.5); - let nl = (n[0] * lv[0] + n[1] * lv[1] + n[2] * lv[2]).max(0.0); - let hv = { - let v = [lv[0], lv[1], lv[2] + 1.0]; - let m = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); - [v[0] / m, v[1] / m, v[2] / m] - }; - let spec = (n[0] * hv[0] + n[1] * hv[1] + n[2] * hv[2]).max(0.0).powf(sharp) * specular * li; - let light = ambient + diffuse * li * nl + cstr * focus * li; - // The surface: its colour lit like the water, or the sky it reflects (sampled along - // the surface normal; Convergence pulls the reflection towards the pixel itself). - let surface: [f32; 3] = match &sky { - Some(sk) => { - let reach = (1.0 - convergence) * lw * 0.5; - let (rx, ry) = (x as f64 + 0.5 + n[0] as f64 * reach, y as f64 + 0.5 + n[1] as f64 * reach); - let (rx, ry) = (cx + (rx - cx) / sky_scaling, cy + (ry - cy) / sky_scaling); - let (sc, _) = unpremul(sample_repeat(sk, rx, ry, sky_repeat)); - std::array::from_fn(|q| sc[q] * sky_int * 3.0 * surf[q]) - } - None => std::array::from_fn(|q| surf[q] * (ambient + diffuse * li * nl)), - }; - let cc: [f32; 3] = std::array::from_fn(|q| { - let base = c[q] * light * lc[q]; - base * (1.0 - surf_op) + surface[q] * surf_op + spec * lc[q] - }); - let a = (a + surf_op * (1.0 - a)).min(1.0); - *px = [cc[0] * a, cc[1] * a, cc[2] * a, a]; - } - }); - b -} - -// ------------------------------------------------------------------ Wave World - -/// Wave World's simulated surface: an `nx` × `ny` grid of heights `u` and velocities `v`. -#[derive(Clone, Debug)] -pub struct Waves { - pub nx: usize, - pub ny: usize, - pub u: Vec, - pub v: Vec, -} - -struct Producer { - ring: bool, - pos: [f32; 2], - len: f32, - width: f32, - angle: f32, - amp: f32, - freq: f32, - phase: f32, -} - -impl Producer { - /// Weight of a grid point (layer-fraction coordinates, x in widths, y in widths). - fn weight(&self, x: f32, y: f32) -> f32 { - let (dx, dy) = (x - self.pos[0], y - self.pos[1]); - let (sa, ca) = self.angle.sin_cos(); - let (u, v) = (dx * ca + dy * sa, -dx * sa + dy * ca); - let d = if self.ring { - let (a, bb) = ((self.len * 0.5).max(1e-3), (self.width * 0.5).max(1e-3)); - ((u / a).powi(2) + (v / bb).powi(2)).sqrt() - } else { - let l = (self.len * 0.5).max(1e-3); - let wd = (self.width * 0.5).max(1e-3); - (u.abs() / l).max(v.abs() / wd) - }; - (1.0 - d).clamp(0.0, 1.0).min(1.0) - } -} - -static WAVE_CACHE: SimCache = SimCache::new(4); - -/// What Wave World draws: the Height Map view of the simulated surface (grid heights and, -/// with a ground layer, water depths per grid point), or the wireframe preview's lines. -pub enum WavePlan { - Height { st: std::sync::Arc, depth: Option> }, - Wire(crate::sim::SpritePlan), -} - -fn wave_world(ctx: &EffectCtx, b: Buf) -> Buf { - match wave_world_impl(ctx, &b, false) { - Ok(img) => Buf { img, ..b }, - Err(WavePlan::Wire(plan)) => plan.finish(b), - Err(WavePlan::Height { .. }) => b, - } -} - -/// Wave World's simulation state for the frame, as a [`WavePlan`] (its pixels are not read). -pub fn wave_world_plan(ctx: &EffectCtx, b: &Buf) -> Option { - wave_world_impl(ctx, b, true).err() -} - -fn wave_world_impl(ctx: &EffectCtx, b: &Buf, plan_only: bool) -> Result { - let pr = ctx.params; - let [lw, lh] = ctx.layer_size; - let res = pr.f("simulation/gridResolution").clamp(1.0, 400.0) as usize; - let res = if pr.b("simulation/gridResDownsamples") { ((res as f64) * b.scale).round().max(1.0) as usize } else { res }; - let nx = res.max(2) + 1; - let ny = ((res as f64 * lh / lw.max(1.0)).round() as usize).max(2) + 1; - let speed = pr.f("simulation/waveSpeed").max(0.0) as f32; - let damping = pr.f("simulation/damping").max(0.0) as f32; - let reflect = pr.e("simulation/reflectEdges"); - let preroll = pr.f("simulation/preRoll").max(0.0); - let prods: Vec = [1, 2] - .iter() - .map(|k| { - let pos = pr.v2(&format!("producer{k}/producer{k}Position")); - Producer { - ring: pr.e(&format!("producer{k}/producer{k}Type")) == 0, - pos: [(pos[0] / lw.max(1.0)) as f32, (pos[1] / lw.max(1.0)) as f32], - len: pr.f(&format!("producer{k}/producer{k}Length")) as f32, - width: pr.f(&format!("producer{k}/producer{k}Width")) as f32, - angle: (pr.f(&format!("producer{k}/producer{k}Angle")) as f32).to_radians(), - amp: pr.f(&format!("producer{k}/producer{k}Amplitude")) as f32, - freq: pr.f(&format!("producer{k}/producer{k}Frequency")) as f32, - phase: (pr.f(&format!("producer{k}/producer{k}Phase")) as f32).to_radians(), - } - }) - .collect(); - let sps = crate::sim::SPS as f32; - // Wave speed in layer widths per second → cells per second; sub-steps keep it stable. - let cps = speed * (nx - 1) as f32; - let sub = ((cps * 2.0 / sps).ceil() as usize).clamp(1, 64); - let dt = 1.0 / (sps * sub as f32); - let cell = 1.0 / (nx - 1) as f32; - let weights: Vec> = prods.iter().map(|p| (0..nx * ny).map(|i| p.weight((i % nx) as f32 * cell, (i / nx) as f32 * cell)).collect()).collect(); - // Ground: the ground layer's first frame, its luminance an elevation (× Steepness). Where - // it rises above Height the grid is dry (a wall the waves reflect from); shallow water - // slows the waves by Wave Strength. - let ground = pr.get("ground/ground").and_then(Value::as_layer).and_then(|id| { - let host = ctx.env.host?; - host.layer_at(id, ctx.env.comp_time - ctx.time, true) - }); - let steep = pr.get("ground/steepness").map(Value::as_f64).unwrap_or(0.25) as f32; - let level = pr.get("ground/height").map(Value::as_f64).unwrap_or(0.25) as f32; - let wave_strength = pr.f("ground/waveStrength").clamp(0.0, 1.0) as f32; - // Water depth per grid point (None without ground: deep everywhere). - let depth: Option> = ground.as_ref().map(|lp| { - let (gw, gh) = (lp.size[0].max(1.0), lp.size[1].max(1.0)); - (0..nx * ny) - .map(|i| { - let (fx, fy) = ((i % nx) as f64 / (nx - 1) as f64, (i / nx) as f64 / (ny - 1) as f64); - let (c, a) = unpremul(lp.buf.img.sample_bilinear_clamped(fx * gw * lp.buf.scale + lp.buf.offset[0], fy * gh * lp.buf.scale + lp.buf.offset[1])); - level - steep * effectcraft_color::luminance(c[0], c[1], c[2]) * a - }) - .collect() - }); - let speed_k: Vec = match &depth { - Some(d) => d.iter().map(|&v| if v <= 0.0 { 0.0 } else { 1.0 - wave_strength * (1.0 - (v / level.max(1e-3)).clamp(0.0, 1.0).sqrt()) }).collect(), - None => vec![1.0; nx * ny], - }; - let mut key = - params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: if pr.b("simulation/gridResDownsamples") { b.scale } else { 1.0 } }, 4); - if let Some(d) = &depth { - key ^= d.iter().fold(0u64, |a, v| a.rotate_left(3) ^ v.to_bits() as u64); - } - let steps = crate::sim::steps_at(ctx.time + preroll); - let st = WAVE_CACHE.run( - key, - steps, - || Waves { nx, ny, u: vec![0.0; nx * ny], v: vec![0.0; nx * ny] }, - |w, step| { - for k in 0..sub { - let t = (step as f32 + k as f32 / sub as f32) / sps - preroll as f32; - let c2 = cps * cps; - let mut acc = vec![0.0f32; nx * ny]; - for y in 0..ny { - for x in 0..nx { - let i = y * nx + x; - let g = |xx: usize, yy: usize| w.u[yy * nx + xx]; - let l = g(x.saturating_sub(1), y) + g((x + 1).min(nx - 1), y) + g(x, y.saturating_sub(1)) + g(x, (y + 1).min(ny - 1)) - 4.0 * w.u[i]; - acc[i] = c2 * speed_k[i] * speed_k[i] * l - damping * 4.0 * w.v[i]; - } - } - for i in 0..nx * ny { - w.v[i] += acc[i] * dt; - w.u[i] += w.v[i] * dt; - } - // Producers drive the surface. - for (p, wt) in prods.iter().zip(&weights) { - if p.amp == 0.0 { - continue; - } - let target = p.amp * (std::f32::consts::TAU * p.freq * t + p.phase).sin(); - for i in 0..nx * ny { - if wt[i] > 0.0 { - w.u[i] += (target - w.u[i]) * wt[i]; - w.v[i] *= 1.0 - wt[i]; - } - } - } - // Dry ground holds the surface flat. - for i in 0..nx * ny { - if speed_k[i] <= 0.0 { - w.u[i] = 0.0; - w.v[i] = 0.0; - } - } - // Edges: reflective ones mirror; the others absorb in a thin sponge layer. - let refl = |side: u32| reflect == 5 || reflect == side; - let sponge = 4usize.min(nx / 4).max(1); - for y in 0..ny { - for x in 0..nx { - let i = y * nx + x; - let mut f = 1.0f32; - if !refl(1) && x < sponge { - f = f.min(x as f32 / sponge as f32); - } - if !refl(3) && nx - 1 - x < sponge { - f = f.min((nx - 1 - x) as f32 / sponge as f32); - } - if !refl(2) && y < sponge { - f = f.min(y as f32 / sponge as f32); - } - if !refl(4) && ny - 1 - y < sponge { - f = f.min((ny - 1 - y) as f32 / sponge as f32); - } - if f < 1.0 { - let k = 0.85 + 0.15 * f; - w.u[i] *= k; - w.v[i] *= k; - } - } - } - } - }, - ); - let s = b.scale; - let (bw, bh) = (b.img.width, b.img.height); - let sample = |x: f64, y: f64| -> f32 { - let gx = (x / lw.max(1.0) * (st.nx - 1) as f64).clamp(0.0, (st.nx - 1) as f64); - let gy = (y / lw.max(1.0) * (st.nx - 1) as f64).clamp(0.0, (st.ny - 1) as f64); - let (x0, y0) = (gx.floor() as usize, gy.floor() as usize); - let (x1, y1) = ((x0 + 1).min(st.nx - 1), (y0 + 1).min(st.ny - 1)); - let (tx, ty) = ((gx - x0 as f64) as f32, (gy - y0 as f64) as f32); - let g = |xx: usize, yy: usize| st.u[yy * st.nx + xx]; - let a = g(x0, y0) + (g(x1, y0) - g(x0, y0)) * tx; - let c = g(x0, y1) + (g(x1, y1) - g(x0, y1)) * tx; - a + (c - a) * ty - }; - if plan_only && pr.e("view") == 1 { - return Err(WavePlan::Height { st, depth }); - } - if pr.e("view") == 1 { - let mut img = Image::new(bw, bh); - let bright = pr.f("heightMapControls/brightness") as f32; - let contrast = pr.f("heightMapControls/contrast") as f32; - let gamma = pr.f("heightMapControls/gamma").max(0.01) as f32; - let alpha = 1.0 - pr.f("heightMapControls/transparency") as f32; - let dry_transparent = pr.e("heightMapControls/renderDryAreasAs") == 1; - let dry_at = |x: f64, y: f64| -> bool { - let Some(d) = &depth else { return false }; - let gx = (x / lw.max(1.0) * (st.nx - 1) as f64).round().clamp(0.0, (st.nx - 1) as f64) as usize; - let gy = (y / lw.max(1.0) * (st.nx - 1) as f64).round().clamp(0.0, (st.ny - 1) as f64) as usize; - d[gy * st.nx + gx] <= 0.0 - }; - img.rows_mut().for_each(|(y, row)| { - for (x, px) in row.iter_mut().enumerate() { - let lx = (x as f64 + 0.5 - b.offset[0]) / s; - let ly = (y as f64 + 0.5 - b.offset[1]) / s; - let v = (bright + contrast * sample(lx, ly)).clamp(0.0, 1.0).powf(1.0 / gamma); - let al = if dry_transparent && dry_at(lx, ly) { 0.0 } else { alpha }; - *px = [v * al, v * al, v * al, al]; - } - }); - Ok(img) - } else { - // Wireframe preview: the grid turned by Horizontal Rotation about the vertical axis, - // tilted by Vertical Rotation, heights scaled by Vertical Scale. - let hr = pr.f("wireframeControls/horizontalRotation").to_radians(); - let vr = pr.get("wireframeControls/verticalRotation").map(Value::as_f64).unwrap_or(53.0).to_radians(); - let vs = pr.get("wireframeControls/verticalScale").map(Value::as_f64).unwrap_or(0.5); - let (sh, ch) = hr.sin_cos(); - let (sv, cv) = vr.sin_cos(); - let to = |x: usize, y: usize| -> [f32; 2] { - // Grid point relative to the layer centre (layer px), height in layer px. - let gx = x as f64 / (st.nx - 1) as f64 * lw - lw * 0.5; - let gy = y as f64 / (st.nx - 1) as f64 * lw - (st.ny - 1) as f64 / (st.nx - 1) as f64 * lw * 0.5; - let hgt = st.u[y * st.nx + x] as f64 * vs * lh * 0.5; - let (rx, ry) = (gx * ch - gy * sh, gx * sh + gy * ch); - let (px, py) = b.to_px([lw * 0.5 + rx, lh * 0.5 + ry * cv - hgt * sv]); - [px as f32, py as f32] - }; - let mut lines = Vec::new(); - for y in 0..st.ny { - for x in 0..st.nx { - let a = to(x, y); - if x + 1 < st.nx { - let c = to(x + 1, y); - lines.push(Sprite::new(a[0], a[1], 0.4, [0.4, 0.9, 0.4, 1.0], Shape::Line { dx: c[0] - a[0], dy: c[1] - a[1] })); - } - if y + 1 < st.ny { - let c = to(x, y + 1); - lines.push(Sprite::new(a[0], a[1], 0.4, [0.4, 0.9, 0.4, 1.0], Shape::Line { dx: c[0] - a[0], dy: c[1] - a[1] })); - } - } - } - let plan = crate::sim::SpritePlan { sprites: lines, tints: vec![], acc: Acc::Over, post: crate::sim::Post::OverBlack }; - if plan_only { - return Err(WavePlan::Wire(plan)); - } - Ok(plan.finish(Buf { img: Image::new(bw, bh), offset: b.offset, scale: b.scale }).img) - } -} - -// ------------------------------------------------------------------ Foam - -#[derive(Clone, Debug)] -struct FoamBubble { - p: [f32; 2], - v: [f32; 2], - age: f32, - size: f32, - /// Spin (radians) for Physical Orientation. - spin: f32, - id: u32, -} - -#[derive(Clone, Debug, Default)] -struct FoamState { - b: Vec, - carry: f32, - next: u32, -} - -static FOAM_CACHE: SimCache = SimCache::new(4); - -/// Foam steps once per frame at 30 frames per second. -const FOAM_FPS: f64 = 30.0; - -/// Foam's Bubble Texture options (the last uses Bubble Texture Layer). -const FOAM_TEXTURES: [&str; 6] = ["Default Bubble", "Spit", "Bubblegum", "Dishwater", "Milky", "User Defined"]; - -/// Age (in simulation steps) at which bubble `id` pops: its lifespan, shortened at random for -/// frail bubbles (`frailty` 0 = every bubble reaches `lifespan`, 1 = uniformly early). -fn foam_pop_age(id: u32, lifespan: f32, frailty: f32, seed: u32) -> f32 { - lifespan * (1.0 - frailty * (hs(id, 11, seed) * 0.5 + 0.5)) -} - -/// Foam: bubbles born in the producer rectangle grow to their size, drift with the wind, -/// turbulence and the Flow Map (bubbles run downhill on its luminance), repel and stick to each -/// other, wobble, and pop at the end of their (Strength-shortened) lifespan, pushing their -/// neighbours away at Pop Velocity. Rendered bubbles use a built-in texture or the Bubble -/// Texture Layer, oriented by Bubble Orientation, and reflect the Environment Map. -fn foam(ctx: &EffectCtx, b: Buf) -> Buf { - foam_full_plan(ctx, &b).finish(b) -} - -/// Foam's bubbles as sprites, `None` when it draws more than sprites (a User Defined bubble -/// texture, an Environment Map reflection or the Draft + Flow Map view's flow map). -pub(crate) fn foam_plan(ctx: &EffectCtx, b: &Buf) -> Option { - let plan = foam_full_plan(ctx, b); - (plan.user.is_none() && plan.env.is_none() && plan.flow.is_none()).then_some(plan.sprites) -} - -/// A bubble disc filled by Foam's User Defined texture (buffer px; `rot` radians). -#[derive(Clone, Copy, Debug)] -pub struct FoamDisc { - pub x: f64, - pub y: f64, - pub r: f32, - pub rot: f32, - pub fade: f32, -} - -/// Foam's Environment Map reflection: the map fitted to the buffer, Reflection Convergence and -/// Strength, and the bubble discs reflecting it (`rot` and `fade` unused). -pub struct FoamEnv { - pub img: Image, - pub conv: f64, - pub strength: f32, - pub discs: Vec, -} - -/// What Foam draws this frame over a transparent frame of the layer's size: the bubble sprites, -/// then the User Defined texture's discs, the Environment Map's reflections and (Draft + Flow -/// Map) the flow map under it all, each [`foam_disc`]-composited in bubble order. -pub struct FoamPlan { - pub sprites: crate::sim::SpritePlan, - pub user: Option<(crate::LayerPixels, Vec)>, - pub env: Option, - /// The flow map fitted to the buffer. - pub flow: Option, -} - -impl FoamPlan { - pub fn finish(&self, b: Buf) -> Buf { - let mut out = splat(b.img.width, b.img.height, &self.sprites.sprites, self.sprites.acc); - // User Defined: the texture layer fills each bubble's disc. - if let Some((tex, discs)) = &self.user { - for d in discs { - let (rot, fade) = (d.rot, d.fade); - foam_disc(&mut out, d.x, d.y, d.r as f64, |u, v| { - // u, v in −1..1 across the bubble, rotated into the texture. - let (sr, cr) = rot.sin_cos(); - let (tu, tv) = (u as f32 * cr + v as f32 * sr, -u as f32 * sr + v as f32 * cr); - let (x, y) = ((tu as f64 * 0.5 + 0.5) * tex.size[0], (tv as f64 * 0.5 + 0.5) * tex.size[1]); - let px = tex.buf.img.sample_bilinear(x * tex.buf.scale + tex.buf.offset[0], y * tex.buf.scale + tex.buf.offset[1]); - px.map(|c| c * fade) - }); - } - } - // Environment Map: each bubble reflects the map along its sphere normal (Reflection - // Convergence pulls the reflection towards the bubble's own spot of the map). - if let Some(env) = &self.env { - let (conv, strength) = (env.conv, env.strength); - let (w, h) = (b.img.width as f64, b.img.height as f64); - for d in &env.discs { - let (bx, by) = (d.x, d.y); - foam_disc(&mut out, bx, by, d.r as f64, |u, v| { - // Sphere normal; the reflection looks across the whole map, or (convergence 1) - // just behind the bubble. - let (ex, ey) = - if conv >= 1.0 { (bx, by) } else { (bx * conv + (u * 0.5 + 0.5) * w * (1.0 - conv), by * conv + (v * 0.5 + 0.5) * h * (1.0 - conv)) }; - let e = env.img.sample_bilinear(ex, ey); - let rim = ((u * u + v * v) as f32).min(1.0); - let k = strength * (0.3 + 0.7 * rim); - [e[0] * k, e[1] * k, e[2] * k, 0.0] - }); - } - } - // Draft + Flow Map shows the flow map under the bubbles. - if let Some(fm) = &self.flow { - out.data.par_iter_mut().zip(fm.data.par_iter()).for_each(|(o, f)| { - let k = 1.0 - o[3]; - for c in 0..4 { - o[c] += f[c] * 0.5 * k; - } - }); - } - Buf { img: out, ..b } - } -} - -/// Foam's [`FoamPlan`] for layer geometry `b` (its size; its pixels are not read). -pub fn foam_full_plan(ctx: &EffectCtx, b: &Buf) -> FoamPlan { - let pr = ctx.params; - let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); - let unit = lw * 0.01; - let prod = pr.v2("producer/producerPoint"); - let (psx, psy) = (pr.f("producer/producerXSize") as f32 * lw, pr.f("producer/producerYSize") as f32 * lh); - let porient = (pr.f("producer/producerOrientation") as f32).to_radians(); - let rate = pr.f("producer/productionRate").max(0.0) as f32; - let size = pr.f("bubbles/size").max(0.0) as f32; - let size_var = pr.f("bubbles/sizeVariance") as f32; - let lifespan = pr.f("bubbles/lifespan").max(1.0) as f32; - let growth = pr.f("bubbles/growthSpeed").max(0.0001) as f32; - // Strength: weaker bubbles tend to pop before their lifespan (at 20 or more every bubble - // lives the full lifespan); each bubble gets its own pop age. - let frailty = 1.0 - (pr.f("bubbles/strength") as f32 / 20.0).clamp(0.0, 1.0); - let init_speed = pr.f("physics/initialSpeed") as f32; - let init_dir = (pr.f("physics/initialDirection") as f32).to_radians(); - let wind = pr.f("physics/windSpeed") as f32; - let wind_dir = (pr.f("physics/windDirection") as f32).to_radians(); - let turb = pr.f("physics/turbulence") as f32; - let wobble = pr.f("physics/wobbleAmount").max(0.0) as f32; - let repulsion = pr.f("physics/repulsion") as f32; - let pop_velocity = pr.f("physics/popVelocity") as f32; - let viscosity = pr.f("physics/viscosity").clamp(0.0, 1.0) as f32; - let sticky = pr.f("physics/stickiness").clamp(0.0, 1.0) as f32; - let universe = pr.f("universeSize").max(0.01) as f32; - let seed = (pr.f("randomSeed") as u32).wrapping_mul(0x68e31da4) ^ ctx.seed; - let wvec = [wind_dir.sin() * wind * unit, -wind_dir.cos() * wind * unit]; - // Flow Map: the map layer's first frame, its luminance a height field (Fits the layer or - // the whole universe). - let flow = pr.get("flowMap/flowMap").and_then(Value::as_layer).and_then(|id| { - let host = ctx.env.host?; - let me = pr.get(&crate::layer_source_id("flowMap/flowMap")).is_none_or(|v| v.as_enum() != 0); - host.layer_at(id, ctx.env.comp_time - ctx.time, me) - }); - let steep = pr.f("flowMap/flowMapSteepness") as f32; - let fits_universe = pr.e("flowMap/flowMapFits") == 1; - let substeps = match pr.e("flowMap/simulationQuality") { - 1 => 2, - 2 => 4, - _ => 1, - }; - let height = flow.as_ref().map(|lp| { - let img = &lp.buf.img; - let pl = Plane::from_image(img, |px| { - let (c, a) = unpremul(px); - effectcraft_color::luminance(c[0], c[1], c[2]) * a - }); - (pl, lp.buf.scale, lp.buf.offset, lp.size) - }); - let (ux0, uy0) = (-(universe - 1.0) * 0.5 * lw, -(universe - 1.0) * 0.5 * lh); - let (ux1, uy1) = (lw - ux0, lh - uy0); - // Height at a layer point (0..1). - let h_at = |x: f32, y: f32| -> f32 { - let Some((pl, s, off, size)) = &height else { return 0.0 }; - let (fx, fy) = if fits_universe { ((x - ux0) / (ux1 - ux0), (y - uy0) / (uy1 - uy0)) } else { (x / lw, y / lh) }; - pl.sample(fx as f64 * size[0] * s + off[0], fy as f64 * size[1] * s + off[1]) - }; - let mut key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 5); - if let Some((pl, ..)) = &height { - key ^= pl.data.iter().step_by(5).fold(0u64, |a, v| a.rotate_left(3) ^ v.to_bits() as u64); - } - let steps = if ctx.time <= 0.0 { 0 } else { (ctx.time * FOAM_FPS + 1e-6).floor() as u64 }; - let rad = |q: &FoamBubble| q.size * unit * 3.0 * (q.age * growth).min(1.0); - let st = FOAM_CACHE.run(key, steps, FoamState::default, |st, step| { - let t = step as f32 / FOAM_FPS as f32; - // Births. - st.carry += rate; - let n = st.carry.floor() as u32; - st.carry -= n as f32; - for _ in 0..n { - if st.b.len() >= 3000 { - break; - } - let id = st.next; - st.next = st.next.wrapping_add(1); - let (ux, uy) = (hs(id, 1, seed) * 0.5 * psx, hs(id, 2, seed) * 0.5 * psy); - let (so, co) = porient.sin_cos(); - let p = [prod[0] as f32 + ux * co - uy * so, prod[1] as f32 + ux * so + uy * co]; - let v = [init_dir.sin() * init_speed * unit, -init_dir.cos() * init_speed * unit]; - let sz = (size * (1.0 + size_var * hs(id, 3, seed))).max(0.01); - st.b.push(FoamBubble { p, v, age: 0.0, size: sz, spin: hs(id, 12, seed) * std::f32::consts::PI, id }); - } - for _ in 0..substeps { - let k = 1.0 / substeps as f32; - // Forces: drift towards the wind, turbulence, viscosity, the flow map's slope. - for q in st.b.iter_mut() { - let n1 = value_noise(q.p[0] / (lw * 0.15), q.p[1] / (lw * 0.15), t * 0.5, seed) - 0.5; - let n2 = value_noise(q.p[0] / (lw * 0.15) + 19.0, q.p[1] / (lw * 0.15), t * 0.5, seed) - 0.5; - for c in 0..2 { - q.v[c] += (wvec[c] - q.v[c]) * 0.1 * k; - q.v[c] *= 1.0 - viscosity * 0.5 * k; - } - q.v[0] += n1 * turb * unit * k; - q.v[1] += n2 * turb * unit * k; - if height.is_some() && steep != 0.0 { - let e = unit.max(1.0); - let gx = (h_at(q.p[0] + e, q.p[1]) - h_at(q.p[0] - e, q.p[1])) / (2.0 * e); - let gy = (h_at(q.p[0], q.p[1] + e) - h_at(q.p[0], q.p[1] - e)) / (2.0 * e); - // Downhill: from light (high) to dark (low). - q.v[0] -= gx * steep * lw * 0.5 * k; - q.v[1] -= gy * steep * lw * 0.5 * k; - } - // Physical Orientation: bubbles roll with their sideways motion. - q.spin += q.v[0] / (rad(q).max(0.5)) * k; - } - // Repulsion between overlapping bubbles (grid hashed, deterministic order). - if repulsion > 0.0 && st.b.len() > 1 { - let maxr = st.b.iter().map(rad).fold(0.0f32, f32::max).max(1.0); - let cs = maxr * 2.0; - let mut grid: std::collections::HashMap<(i32, i32), Vec> = std::collections::HashMap::new(); - for (i, q) in st.b.iter().enumerate() { - grid.entry(((q.p[0] / cs).floor() as i32, (q.p[1] / cs).floor() as i32)).or_default().push(i); - } - let mut push = vec![[0.0f32; 2]; st.b.len()]; - for (i, q) in st.b.iter().enumerate() { - let (gx, gy) = ((q.p[0] / cs).floor() as i32, (q.p[1] / cs).floor() as i32); - for dy in -1..=1 { - for dx in -1..=1 { - let Some(list) = grid.get(&(gx + dx, gy + dy)) else { continue }; - for &j in list { - if j <= i { - continue; - } - let o = &st.b[j]; - let (ddx, ddy) = (o.p[0] - q.p[0], o.p[1] - q.p[1]); - let d = (ddx * ddx + ddy * ddy).sqrt().max(1e-4); - let overlap = rad(q) + rad(o) - d; - if overlap > 0.0 { - let f = overlap * 0.5 * repulsion.min(4.0) * (1.0 - sticky * 0.5) / d * k; - push[i][0] -= ddx * f; - push[i][1] -= ddy * f; - push[j][0] += ddx * f; - push[j][1] += ddy * f; - } - } - } - } - } - for (q, d) in st.b.iter_mut().zip(&push) { - q.p[0] += d[0].clamp(-maxr, maxr); - q.p[1] += d[1].clamp(-maxr, maxr); - } - } - for q in st.b.iter_mut() { - q.p[0] += q.v[0] * k; - q.p[1] += q.v[1] * k; - } - } - for q in st.b.iter_mut() { - q.age += 1.0; - } - // Pops: bubbles past their pop age burst, pushing their neighbours away. - let popped: Vec<([f32; 2], f32)> = st.b.iter().filter(|q| q.age >= foam_pop_age(q.id, lifespan, frailty, seed)).map(|q| (q.p, rad(q))).collect(); - if pop_velocity != 0.0 { - for (pp, pr_) in &popped { - let reach = (pr_ * 3.0).max(unit); - for q in st.b.iter_mut() { - let (dx, dy) = (q.p[0] - pp[0], q.p[1] - pp[1]); - let d = (dx * dx + dy * dy).sqrt(); - if d > 1e-4 && d < reach { - let f = pop_velocity * unit * (1.0 - d / reach) / d; - q.v[0] += dx * f; - q.v[1] += dy * f; - } - } - } - } - st.b.retain(|q| q.age < foam_pop_age(q.id, lifespan, frailty, seed) && q.p[0] > ux0 && q.p[0] < ux1 && q.p[1] > uy0 && q.p[1] < uy1); - }); - let view = pr.e("view"); - let zoom = pr.f("zoom").max(0.01) as f32; - let zoom_producer = pr.b("producer/zoomProducerPoint"); - let texture = pr.e("rendering/bubbleTexture"); - let orient = pr.e("rendering/bubbleOrientation"); - let blend = pr.e("rendering/blendMode"); - let s = b.scale as f32; - // Zoom centres on the layer, or on the producer with Zoom Producer Point. - let (cx, cy) = if zoom_producer { (prod[0] as f32, prod[1] as f32) } else { (lw * 0.5, lh * 0.5) }; - let mut order: Vec<&FoamBubble> = st.b.iter().collect(); - if blend == 2 { - // Solid New on Top: oldest first. - order.sort_by(|a, c| c.age.total_cmp(&a.age).then(a.id.cmp(&c.id))); - } else if blend == 1 { - order.sort_by(|a, c| a.age.total_cmp(&c.age).then(a.id.cmp(&c.id))); - } - // Where a bubble is drawn: buffer centre, radius, rotation (radians). - let place = |q: &FoamBubble| { - let wob = if wobble > 0.0 { 1.0 + wobble * 0.15 * ((q.age * 0.35) + hs(q.id, 13, seed) * 6.0).sin() } else { 1.0 }; - let r = rad(q) * wob * zoom * s; - let (bx, by) = b.to_px([(cx + (q.p[0] - cx) * zoom) as f64, (cy + (q.p[1] - cy) * zoom) as f64]); - let rot = match orient { - 1 => q.spin, - 2 => q.v[1].atan2(q.v[0]), - _ => 0.0, - }; - (bx, by, r, rot) - }; - let user_tex = (view == 2 && texture == 5).then(|| ctx.layer_param("rendering/bubbleTextureLayer", true)).flatten(); - let sprites: Vec = order - .iter() - .filter(|_| user_tex.is_none()) - .map(|q| { - let (bx, by, r, rot) = place(q); - let fade = ((foam_pop_age(q.id, lifespan, frailty, seed) - q.age) / 5.0).clamp(0.0, 1.0); - // Draft and Draft + Flow Map show the wireframe-style bubbles. - let mut sp = if view != 2 { - Sprite::new(bx as f32, by as f32, r, [0.55, 0.75, 1.0, fade], Shape::Bubble) - } else { - let (c, shape) = match texture { - 1 => ([0.95, 0.95, 0.92], Shape::Faded), - 2 => ([1.0, 0.6, 0.8], Shape::Bubble), - 3 => ([0.8, 0.85, 0.75], Shape::Bubble), - 4 => ([0.97, 0.97, 0.95], Shape::Sphere), - _ => ([1.0, 1.0, 1.0], Shape::Bubble), - }; - Sprite::new(bx as f32, by as f32, r, [c[0], c[1], c[2], fade], shape) - }; - sp.rot = rot; - sp - }) - .collect(); - let acc = if blend == 0 && view == 2 { Acc::Add } else { Acc::Over }; - let disc = |q: &&FoamBubble| { - let (x, y, r, rot) = place(q); - let fade = ((foam_pop_age(q.id, lifespan, frailty, seed) - q.age) / 5.0).clamp(0.0, 1.0); - FoamDisc { x, y, r, rot, fade } - }; - let user = user_tex.map(|tex| (tex, order.iter().map(disc).collect())); - let strength = pr.f("rendering/reflectionStrength").clamp(0.0, 1.0) as f32; - let env = if view == 2 - && strength > 0.0 - && let Some(env) = ctx.layer_param("rendering/environmentMap", true) - { - Some(FoamEnv { - img: crate::util::fit_layer(ctx, b, &env, true), - conv: pr.f("rendering/reflectionConvergence").clamp(0.0, 1.0), - strength, - discs: order.iter().map(disc).collect(), - }) - } else { - None - }; - let flow = if view == 1 - && let Some(lp) = &flow - { - Some(crate::util::fit_layer(ctx, b, lp, true)) - } else { - None - }; - FoamPlan { sprites: crate::sim::SpritePlan { sprites, tints: vec![], acc, post: crate::sim::Post::Replace }, user, env, flow } -} - -/// Add `f(u, v)` (premultiplied; u, v ∈ −1..1 across the disc) over the disc of radius `r` at -/// (`x`, `y`) with anti-aliased edges ("over" for opaque texels, additive for alpha 0). -fn foam_disc(out: &mut Image, x: f64, y: f64, r: f64, f: impl Fn(f64, f64) -> Px) { - if r <= 0.0 { - return; - } - let (x0, x1) = ((x - r - 1.0).floor().max(0.0) as i64, (x + r + 1.0).ceil().min(out.width as f64) as i64); - let (y0, y1) = ((y - r - 1.0).floor().max(0.0) as i64, (y + r + 1.0).ceil().min(out.height as f64) as i64); - for py in y0..y1 { - for px in x0..x1 { - let (u, v) = ((px as f64 + 0.5 - x) / r, (py as f64 + 0.5 - y) / r); - let d = (u * u + v * v).sqrt(); - let cov = ((1.0 - d) * r + 0.5).clamp(0.0, 1.0) as f32; - if cov <= 0.0 { - continue; - } - let s = f(u.clamp(-1.0, 1.0), v.clamp(-1.0, 1.0)).map(|c| c * cov); - let o = out.get(px, py); - let k = 1.0 - s[3]; - out.set(px as u32, py as u32, [s[0] + o[0] * k, s[1] + o[1] * k, s[2] + o[2] * k, (s[3] + o[3] * k).min(1.0)]); - } - } -} - -// ------------------------------------------------------------------ specs - -fn producer_params(k: u32, amp: f64, pos: (f64, f64)) -> Vec { - let ids: [&'static str; 8] = match k { - 1 => [ - "producer1/producer1Type", - "producer1/producer1Position", - "producer1/producer1Length", - "producer1/producer1Width", - "producer1/producer1Angle", - "producer1/producer1Amplitude", - "producer1/producer1Frequency", - "producer1/producer1Phase", - ], - _ => [ - "producer2/producer2Type", - "producer2/producer2Position", - "producer2/producer2Length", - "producer2/producer2Width", - "producer2/producer2Angle", - "producer2/producer2Amplitude", - "producer2/producer2Frequency", - "producer2/producer2Phase", - ], - }; - vec![ - p(ids[0], "Type", Value::Enum(0), popup(&["Ring", "Line"])), - p(ids[1], "Position", pt(pos.0, pos.1), ParamUi::Point), - p(ids[2], "Height/Length", num(0.1), slider(0.0, 2.0, 0.0, 1.0, 3)), - p(ids[3], "Width", num(0.1), slider(0.0, 2.0, 0.0, 1.0, 3)), - p(ids[4], "Angle", num(0.0), ParamUi::Angle), - p(ids[5], "Amplitude", num(amp), slider(-5.0, 5.0, -1.0, 1.0, 3)), - p(ids[6], "Frequency", num(1.0), slider(0.0, 30.0, 0.0, 5.0, 3)), - p(ids[7], "Phase", num(0.0), ParamUi::Angle), - ] -} - -fn card_dance_params() -> Vec { - let mut v = vec![ - p("rowsColumns", "Rows & Columns", Value::Enum(0), popup(&["Independent", "Columns Follows Rows"])), - p("rows", "Rows", num(10.0), slider(1.0, 1000.0, 1.0, 100.0, 0)), - p("columns", "Columns", num(10.0), slider(1.0, 1000.0, 1.0, 100.0, 0)), - p("backLayer", "Back Layer", Value::Layer(None), ParamUi::Layer), - p("gradientLayer1", "Gradient Layer 1", Value::Layer(None), ParamUi::Layer), - p("gradientLayer2", "Gradient Layer 2", Value::Layer(None), ParamUi::Layer), - ]; - let names: [[&'static str; 4]; 8] = [ - ["xPosition/xPosSource", "xPosition/xPosMultiplier", "xPosition/xPosOffset", "X Position"], - ["yPosition/yPosSource", "yPosition/yPosMultiplier", "yPosition/yPosOffset", "Y Position"], - ["zPosition/zPosSource", "zPosition/zPosMultiplier", "zPosition/zPosOffset", "Z Position"], - ["xRotation/xRotSource", "xRotation/xRotMultiplier", "xRotation/xRotOffset", "X Rotation"], - ["yRotation/yRotSource", "yRotation/yRotMultiplier", "yRotation/yRotOffset", "Y Rotation"], - ["zRotation/zRotSource", "zRotation/zRotMultiplier", "zRotation/zRotOffset", "Z Rotation"], - ["xScale/xScaleSource", "xScale/xScaleMultiplier", "xScale/xScaleOffset", "X Scale"], - ["yScale/yScaleSource", "yScale/yScaleMultiplier", "yScale/yScaleOffset", "Y Scale"], - ]; - debug_assert_eq!(names.len(), CD_PROPS.len()); - for (i, n) in names.iter().enumerate() { - let rot = (3..6).contains(&i); - let scale = i >= 6; - let (lo, hi) = if rot { (-3600.0, 3600.0) } else { (-100.0, 100.0) }; - v.push(p(n[0], "Source", Value::Enum(0), popup(CD_SOURCES))); - v.push(p(n[1], "Multiplier", num(0.0), slider(lo, hi, if rot { -360.0 } else { -10.0 }, if rot { 360.0 } else { 10.0 }, 2))); - v.push(p(n[2], "Offset", num(if scale { 1.0 } else { 0.0 }), slider(lo, hi, if rot { -360.0 } else { -10.0 }, if rot { 360.0 } else { 10.0 }, 2))); - } - v.extend(crate::card3d::camera_params(2.0)); - v.extend(crate::card3d::lighting_params(1.0, 0.25)); - v.extend(crate::card3d::material_params(0.75)); - v -} - -pub fn specs() -> Vec { - vec![ - spec( - "ec.sim.ccballaction", - "CC Ball Action", - vec![ - p("scatter", "Scatter", num(0.0), slider(0.0, 2000.0, 0.0, 300.0, 1)), - p("rotationAxis", "Rotation Axis", Value::Enum(0), popup(&["X Axis", "Y Axis", "Z Axis", "XY Axis", "XZ Axis", "YZ Axis", "XYZ Axis"])), - p("rotation", "Rotation", num(0.0), ParamUi::Angle), - p("twistProperty", "Twist Property", Value::Enum(0), popup(&["X Axis", "Y Axis", "Radius", "Brightness"])), - p("twistAngle", "Twist Angle", num(0.0), ParamUi::Angle), - p("gridSpacing", "Grid Spacing", num(4.0), slider(1.0, 200.0, 1.0, 50.0, 0)), - p("ballSize", "Ball Size", num(40.0), slider(0.0, 400.0, 0.0, 200.0, 1)), - p("instabilityState", "Instability State", num(0.0), ParamUi::Angle), - ], - ball_action, - ), - spec( - "ec.sim.ccpixelpolly", - "CC Pixel Polly", - vec![ - p("force", "Force", num(100.0), slider(-1000.0, 1000.0, -300.0, 300.0, 1)), - p("gravity", "Gravity", num(1.0), slider(-20.0, 20.0, -5.0, 5.0, 2)), - p("spinning", "Spinning", num(0.0), ParamUi::Angle), - p("forceCenter", "Force Center", pt(0.5, 0.5), ParamUi::Point), - p("directionRandomness", "Direction Randomness", num(10.0), pct()), - p("speedRandomness", "Speed Randomness", num(10.0), pct()), - p("gridSpacing", "Grid Spacing", num(10.0), slider(1.0, 500.0, 2.0, 100.0, 0)), - p("object", "Object", Value::Enum(0), popup(&["Polygon", "Textured Polygon", "Square", "Textured Square"])), - p("enableDepthSort", "Enable Depth Sort", Value::Bool(true), ParamUi::Checkbox), - p("startTime", "Start Time (sec)", num(0.0), slider(-1000.0, 1000.0, 0.0, 10.0, 2)), - ], - pixel_polly, - ), - spec( - "ec.sim.ccscatterize", - "CC Scatterize", - vec![ - p("scatter", "Scatter", num(0.0), slider(0.0, 1000.0, 0.0, 200.0, 1)), - p("rightTwist", "Right Twist", num(0.0), ParamUi::Angle), - p("leftTwist", "Left Twist", num(0.0), ParamUi::Angle), - p("transferMode", "Transfer Mode", Value::Enum(0), popup(&["Composite", "Add"])), - ], - scatterize, - ), - spec("ec.sim.carddance", "Card Dance", card_dance_params(), card_dance), - spec( - "ec.sim.caustics", - "Caustics", - vec![ - p("bottom/bottom", "Bottom", Value::Layer(None), ParamUi::Layer), - p("bottom/scaling", "Scaling", num(1.0), slider(0.01, 10.0, 0.1, 3.0, 3)), - p("bottom/repeatMode", "Repeat Mode", Value::Enum(2), popup(&["Once", "Tiled", "Reflected"])), - p("bottom/bottomSizeDiffers", "If Layer Size Differs", Value::Enum(1), popup(&["Center", "Stretch to Fit"])), - p("bottom/blur", "Blur", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)), - p("water/waterSurface", "Water Surface", Value::Layer(None), ParamUi::Layer), - p("water/waveHeight", "Wave Height", num(0.2), slider(-1.0, 1.0, -1.0, 1.0, 3)), - p("water/smoothing", "Smoothing", num(5.0), slider(0.0, 100.0, 0.0, 50.0, 1)), - p("water/waterDepth", "Water Depth", num(0.1), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("water/refractiveIndex", "Refractive Index", num(1.2), slider(1.0, 3.0, 1.0, 2.0, 3)), - p("water/surfaceColor", "Surface Color", col(1.0, 1.0, 1.0), ParamUi::Color), - p("water/surfaceOpacity", "Surface Opacity", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("water/causticsStrength", "Caustics Strength", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("sky/sky", "Sky", Value::Layer(None), ParamUi::Layer), - p("sky/scaling", "Scaling", num(1.0), slider(0.01, 10.0, 0.1, 3.0, 3)), - p("sky/repeatMode", "Repeat Mode", Value::Enum(2), popup(&["Once", "Tiled", "Reflected"])), - p("sky/skySizeDiffers", "If Layer Size Differs", Value::Enum(1), popup(&["Center", "Stretch to Fit"])), - p("sky/intensity", "Intensity", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("sky/convergence", "Convergence", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("lighting/lightType", "Light Type", Value::Enum(0), popup(&crate::card3d::LIGHT_TYPES)), - p("lighting/lightIntensity", "Light Intensity", num(1.0), slider(0.0, 4.0, 0.0, 2.0, 2)), - p("lighting/lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color), - p("lighting/lightPosition", "Light Position", pt(0.0, 0.0), ParamUi::Point), - p("lighting/lightHeight", "Light Height", num(1.0), slider(0.0, 10.0, 0.0, 4.0, 2)), - p("lighting/ambientLight", "Ambient Light", num(0.35), slider(0.0, 2.0, 0.0, 1.0, 2)), - p("material/diffuse", "Diffuse Reflection", num(0.75), slider(0.0, 2.0, 0.0, 1.0, 2)), - p("material/specular", "Specular Reflection", num(0.2), slider(0.0, 2.0, 0.0, 1.0, 2)), - p("material/highlightSharpness", "Highlight Sharpness", num(15.0), slider(1.0, 100.0, 1.0, 100.0, 1)), - ], - caustics, - ), - spec( - "ec.sim.waveworld", - "Wave World", - { - let mut v = vec![ - p("view", "View", Value::Enum(0), popup(&["Wireframe Preview", "Height Map"])), - p("wireframeControls/horizontalRotation", "Horizontal Rotation", num(0.0), ParamUi::Angle), - p("wireframeControls/verticalRotation", "Vertical Rotation", num(53.0), slider(0.0, 90.0, 0.0, 90.0, 1)), - p("wireframeControls/verticalScale", "Vertical Scale", num(0.5), slider(0.0, 10.0, 0.0, 2.0, 3)), - p("heightMapControls/brightness", "Brightness", num(0.5), slider(-1.0, 2.0, 0.0, 1.0, 3)), - p("heightMapControls/contrast", "Contrast", num(0.75), slider(0.0, 4.0, 0.0, 2.0, 3)), - p("heightMapControls/gamma", "Gamma Adjustment", num(1.0), slider(0.01, 10.0, 0.2, 5.0, 3)), - p("heightMapControls/renderDryAreasAs", "Render Dry Areas As", Value::Enum(0), popup(&["Solid", "Transparent"])), - p("heightMapControls/transparency", "Transparency", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("simulation/gridResolution", "Grid Resolution", num(60.0), slider(1.0, 400.0, 1.0, 200.0, 0)), - p("simulation/gridResDownsamples", "Grid Res Downsamples", Value::Bool(false), ParamUi::Checkbox), - p("simulation/waveSpeed", "Wave Speed", num(0.2), slider(0.0, 5.0, 0.0, 1.0, 3)), - p("simulation/damping", "Damping", num(0.05), slider(0.0, 5.0, 0.0, 1.0, 3)), - p("simulation/reflectEdges", "Reflect Edges", Value::Enum(0), popup(&["None", "Left", "Top", "Right", "Bottom", "All"])), - p("simulation/preRoll", "Pre-roll (seconds)", num(0.0), slider(0.0, 30.0, 0.0, 10.0, 2)), - p("ground/ground", "Ground", Value::Layer(None), ParamUi::Layer), - p("ground/steepness", "Steepness", num(0.25), slider(0.0, 2.0, 0.0, 1.0, 3)), - p("ground/height", "Height", num(0.25), slider(0.0, 2.0, 0.0, 1.0, 3)), - p("ground/waveStrength", "Wave Strength", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), - ]; - v.extend(producer_params(1, 0.5, (0.5, 0.5))); - v.extend(producer_params(2, 0.0, (0.25, 0.25))); - v - }, - wave_world, - ), - spec( - "ec.sim.shatter", - "Shatter", - vec![ - p( - "view", - "View", - Value::Enum(1), - popup(&["Rendered", "Wireframe Front View", "Wireframe", "Wireframe Front View + Forces", "Wireframe + Forces"]), - ), - p("render", "Render", Value::Enum(0), popup(&["All", "Layer", "Pieces"])), - p("shape/pattern", "Pattern", Value::Enum(0), popup(SHATTER_PATTERNS)), - p("shape/repetitions", "Repetitions", num(10.0), slider(1.0, 500.0, 1.0, 100.0, 2)), - p("shape/direction", "Direction", num(0.0), ParamUi::Angle), - p("shape/origin", "Origin", pt(0.5, 0.5), ParamUi::Point), - p("shape/customShatterMap", "Custom Shatter Map", Value::Layer(None), ParamUi::Layer), - p("shape/whiteTilesFixed", "White Tiles Fixed", Value::Bool(false), ParamUi::Checkbox), - p("shape/extrusionDepth", "Extrusion Depth", num(0.05), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("force1/force1Position", "Position", pt(0.5, 0.5), ParamUi::Point), - p("force1/force1Depth", "Depth", num(0.1), slider(-1.0, 1.0, -1.0, 1.0, 3)), - p("force1/force1Radius", "Radius", num(0.4), slider(0.0, 4.0, 0.0, 2.0, 3)), - p("force1/force1Strength", "Strength", num(5.0), slider(-20.0, 20.0, -10.0, 10.0, 2)), - p("force2/force2Position", "Position", pt(0.25, 0.25), ParamUi::Point), - p("force2/force2Depth", "Depth", num(0.1), slider(-1.0, 1.0, -1.0, 1.0, 3)), - p("force2/force2Radius", "Radius", num(0.0), slider(0.0, 4.0, 0.0, 2.0, 3)), - p("force2/force2Strength", "Strength", num(5.0), slider(-20.0, 20.0, -10.0, 10.0, 2)), - p("physics/rotationSpeed", "Rotation Speed", num(0.2), slider(0.0, 5.0, 0.0, 1.0, 3)), - p("physics/tumbleAxis", "Tumble Axis", Value::Enum(0), popup(&["Free", "None", "X", "Y", "Z", "XY", "XZ", "YZ"])), - p("physics/randomness", "Randomness", num(0.1), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("physics/viscosity", "Viscosity", num(0.1), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("physics/massVariance", "Mass Variance", num(30.0), pct()), - p("physics/gravity", "Gravity", num(3.0), slider(0.0, 100.0, 0.0, 10.0, 2)), - p("physics/gravityDirection", "Gravity Direction", num(180.0), ParamUi::Angle), - p("physics/gravityInclination", "Gravity Inclination", num(0.0), slider(-90.0, 90.0, -90.0, 90.0, 1)), - p("gradient/shatterThreshold", "Shatter Threshold", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), - p("gradient/gradientLayer", "Gradient Layer", Value::Layer(None), ParamUi::Layer), - p("gradient/invertGradient", "Invert Gradient", Value::Bool(false), ParamUi::Checkbox), - p("textures/color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color), - p("textures/opacity", "Opacity", num(1.0), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("textures/frontMode", "Front Mode", Value::Enum(1), popup(&SHATTER_TEXTURE_MODES)), - p("textures/sideMode", "Side Mode", Value::Enum(0), popup(&SHATTER_TEXTURE_MODES)), - p("textures/backMode", "Back Mode", Value::Enum(1), popup(&SHATTER_TEXTURE_MODES)), - p("textures/frontLayer", "Front Layer", Value::Layer(None), ParamUi::Layer), - p("textures/sideLayer", "Side Layer", Value::Layer(None), ParamUi::Layer), - p("textures/backLayer", "Back Layer", Value::Layer(None), ParamUi::Layer), - ] - .into_iter() - .chain(crate::card3d::camera_params(4.0)) - .chain(crate::card3d::lighting_params(1.0, 0.25)) - .chain(crate::card3d::material_params(0.75)) - .chain([p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0))]) - .collect(), - shatter, - ), - spec( - "ec.sim.foam", - "Foam", - vec![ - p("view", "View", Value::Enum(0), popup(&["Draft", "Draft + Flow Map", "Rendered"])), - p("producer/producerPoint", "Producer Point", pt(0.5, 0.5), ParamUi::Point), - p("producer/producerXSize", "Producer X Size", num(0.05), slider(0.0, 2.0, 0.0, 1.0, 3)), - p("producer/producerYSize", "Producer Y Size", num(0.05), slider(0.0, 2.0, 0.0, 1.0, 3)), - p("producer/producerOrientation", "Producer Orientation", num(0.0), ParamUi::Angle), - p("producer/zoomProducerPoint", "Zoom Producer Point", Value::Bool(false), ParamUi::Checkbox), - p("producer/productionRate", "Production Rate", num(1.0), slider(0.0, 50.0, 0.0, 10.0, 3)), - p("bubbles/size", "Size", num(0.5), slider(0.0, 10.0, 0.0, 2.0, 3)), - p("bubbles/sizeVariance", "Size Variance", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("bubbles/lifespan", "Lifespan", num(300.0), slider(1.0, 30_000.0, 1.0, 1000.0, 1)), - p("bubbles/growthSpeed", "Bubble Growth Speed", num(0.1), slider(0.0001, 10.0, 0.0001, 1.0, 3)), - p("bubbles/strength", "Strength", num(10.0), slider(0.0, 100.0, 0.0, 20.0, 2)), - p("physics/initialSpeed", "Initial Speed", num(0.0), slider(-100.0, 100.0, -10.0, 10.0, 3)), - p("physics/initialDirection", "Initial Direction", num(0.0), ParamUi::Angle), - p("physics/windSpeed", "Wind Speed", num(0.5), slider(-100.0, 100.0, -10.0, 10.0, 3)), - p("physics/windDirection", "Wind Direction", num(90.0), ParamUi::Angle), - p("physics/turbulence", "Turbulence", num(0.5), slider(0.0, 10.0, 0.0, 2.0, 3)), - p("physics/wobbleAmount", "Wobble Amount", num(0.05), slider(0.0, 10.0, 0.0, 2.0, 3)), - p("physics/repulsion", "Repulsion", num(1.0), slider(0.0, 10.0, 0.0, 2.0, 3)), - p("physics/popVelocity", "Pop Velocity", num(1.0), slider(-100.0, 100.0, -10.0, 10.0, 3)), - p("physics/viscosity", "Viscosity", num(0.1), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("physics/stickiness", "Stickiness", num(0.75), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("zoom", "Zoom", num(1.0), slider(0.01, 50.0, 0.1, 5.0, 3)), - p("universeSize", "Universe Size", num(1.0), slider(0.01, 10.0, 0.5, 3.0, 3)), - p("rendering/blendMode", "Blend Mode", Value::Enum(0), popup(&["Transparent", "Solid Old on Top", "Solid New on Top"])), - p("rendering/bubbleTexture", "Bubble Texture", Value::Enum(0), popup(&FOAM_TEXTURES)), - p("rendering/bubbleTextureLayer", "Bubble Texture Layer", Value::Layer(None), ParamUi::Layer), - p("rendering/bubbleOrientation", "Bubble Orientation", Value::Enum(0), popup(&["Fixed", "Physical Orientation", "Bubble Velocity"])), - p("rendering/environmentMap", "Environment Map", Value::Layer(None), ParamUi::Layer), - p("rendering/reflectionStrength", "Reflection Strength", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("rendering/reflectionConvergence", "Reflection Convergence", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), - p("flowMap/flowMap", "Flow Map", Value::Layer(None), ParamUi::Layer), - p("flowMap/flowMapSteepness", "Flow Map Steepness", num(0.05), slider(-100.0, 100.0, -1.0, 1.0, 3)), - p("flowMap/flowMapFits", "Flow Map Fits", Value::Enum(0), popup(&["Background", "Universe"])), - p("flowMap/simulationQuality", "Simulation Quality", Value::Enum(0), popup(&["Normal", "High", "Intense"])), - p("randomSeed", "Random Seed", num(1.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)), - ], - foam, - ), - ] -} +mod foam; +mod pieces; +mod pixels; +mod registry; +mod shatter; +mod surfaces; + +pub(crate) use foam::foam_plan; +#[cfg(test)] +use foam::foam_pop_age; +pub use foam::{FoamDisc, FoamEnv, FoamPlan, foam_full_plan}; +#[cfg(test)] +use pieces::Cam; +#[cfg(test)] +pub(crate) use pieces::draw_pieces; +pub use pieces::{Piece, PiecePlan, PieceTex, piece_plan}; +pub(crate) use pieces::{rot_axis, sort_far_first}; +pub use pixels::{PixelPlan, pixel_plan}; +pub use registry::specs; +#[cfg(test)] +use shatter::SHATTER_PATTERNS; +pub use surfaces::{CausticsSetup, WavePlan, Waves, caustics_setup, wave_world_plan}; #[cfg(test)] -mod tests { - use super::*; - use crate::{EffectEnv, run_fx}; - - fn ramp(w: u32, hh: u32) -> Image { - let mut img = Image::new(w, hh); - for y in 0..hh { - for x in 0..w { - img.set(x, y, [x as f32 / w as f32, y as f32 / hh as f32, 0.4, 1.0]); - } - } - img - } - - fn run(id: &str, vals: &[(&str, Value)], img: Image, t: f64) -> Image { - run_fx(id, vals, img, t, EffectEnv::default()).img - } - - fn max_diff(a: &Image, b: &Image) -> f32 { - a.data.iter().zip(&b.data).map(|(p, q)| (0..4).map(|k| (p[k] - q[k]).abs()).fold(0.0, f32::max)).fold(0.0, f32::max) - } - - fn sum_diff(a: &Image, b: &Image) -> f32 { - a.data.iter().zip(&b.data).map(|(p, q)| (0..4).map(|k| (p[k] - q[k]).abs()).sum::()).sum() - } - - #[test] - fn piece_at_rest_is_identity() { - let src = ramp(16, 16); - let cam = Cam { c: [8.0, 8.0], d: 64.0 }; - let id = rot_axis([1.0, 0.0, 0.0], 0.0); - let pc = Piece::new(&[[0.0, 0.0], [16.0, 0.0], [16.0, 16.0], [0.0, 16.0]], [8.0, 8.0], &id, [8.0, 8.0, 0.0], cam).unwrap(); - let mut out = Image::new(16, 16); - draw_pieces(&mut out, &[pc], &src, None); - assert!(max_diff(&out, &src) < 1e-4); - } - - #[test] - fn ball_action_draws_balls() { - let v = [("ballSize", num(80.0))]; - let a = run("ec.sim.ccballaction", &v, ramp(32, 32), 0.0); - assert_eq!(a, run("ec.sim.ccballaction", &v, ramp(32, 32), 0.0)); - assert!(a.data.iter().any(|p| p[3] > 0.9) && a.data.iter().any(|p| p[3] == 0.0), "balls with gaps"); - let none = run("ec.sim.ccballaction", &[("ballSize", num(0.0))], ramp(32, 32), 0.0); - assert!(none.data.iter().all(|p| p[3] == 0.0)); - let sc = run("ec.sim.ccballaction", &[("ballSize", num(80.0)), ("scatter", num(20.0))], ramp(32, 32), 0.0); - assert!(sum_diff(&a, &sc) > 1.0); - } - - #[test] - fn pixel_polly_breaks_after_start() { - let v = [("forceCenter", pt(16.0, 16.0)), ("gridSpacing", num(4.0))]; - let before = run("ec.sim.ccpixelpolly", &v, ramp(32, 32), 0.0); - assert_eq!(before, ramp(32, 32), "intact before the start time"); - let a = run("ec.sim.ccpixelpolly", &v, ramp(32, 32), 0.5); - assert_eq!(a, run("ec.sim.ccpixelpolly", &v, ramp(32, 32), 0.5)); - assert!(sum_diff(&a, &ramp(32, 32)) > 10.0); - let tex = run("ec.sim.ccpixelpolly", &[("forceCenter", pt(16.0, 16.0)), ("gridSpacing", num(4.0)), ("object", Value::Enum(3))], ramp(32, 32), 0.5); - assert!(sum_diff(&a, &tex) > 0.1); - } - - #[test] - fn scatterize_identity_and_scatter() { - assert_eq!(run("ec.sim.ccscatterize", &[], ramp(24, 24), 0.0), ramp(24, 24)); - let a = run("ec.sim.ccscatterize", &[("scatter", num(5.0))], ramp(24, 24), 0.0); - assert_eq!(a, run("ec.sim.ccscatterize", &[("scatter", num(5.0))], ramp(24, 24), 0.0)); - assert!(sum_diff(&a, &ramp(24, 24)) > 1.0); - let tw = run("ec.sim.ccscatterize", &[("leftTwist", num(80.0)), ("rightTwist", num(80.0))], ramp(24, 24), 0.0); - assert!(sum_diff(&tw, &ramp(24, 24)) > 1.0); - } - - #[test] - fn card_dance_identity_and_rotation() { - let src = ramp(30, 20); - let a = run("ec.sim.carddance", &[], src.clone(), 0.0); - assert!(max_diff(&a, &src) < 0.02, "flat cards reproduce the layer: {}", max_diff(&a, &src)); - let r = run("ec.sim.carddance", &[("yRotation/yRotSource", Value::Enum(1)), ("yRotation/yRotMultiplier", num(90.0))], src.clone(), 0.0); - assert_eq!(r, run("ec.sim.carddance", &[("yRotation/yRotSource", Value::Enum(1)), ("yRotation/yRotMultiplier", num(90.0))], src.clone(), 0.0)); - assert!(sum_diff(&r, &src) > 5.0); - let z = run("ec.sim.carddance", &[("zPosition/zPosOffset", num(-1.0))], src.clone(), 0.0); - assert!(sum_diff(&z, &src) > 5.0, "cards pulled towards the camera grow"); - } - - #[test] - fn shatter_at_rest_then_breaks() { - let v = [("view", Value::Enum(0)), ("force1/force1Position", pt(16.0, 16.0)), ("shape/origin", pt(16.0, 16.0))]; - let src = ramp(32, 32); - let t0 = run("ec.sim.shatter", &v, src.clone(), 0.0); - assert!(max_diff(&t0, &src) < 0.03, "{}", max_diff(&t0, &src)); - let t1 = run("ec.sim.shatter", &v, src.clone(), 0.5); - assert_eq!(t1, run("ec.sim.shatter", &v, src.clone(), 0.5)); - assert!(sum_diff(&t1, &src) > 10.0); - for pat in 0..SHATTER_PATTERNS.len() as u32 { - let vv = - [("view", Value::Enum(0)), ("shape/pattern", Value::Enum(pat)), ("force1/force1Position", pt(16.0, 16.0)), ("shape/origin", pt(16.0, 16.0))]; - let r = run("ec.sim.shatter", &vv, src.clone(), 0.0); - assert!(max_diff(&r, &src) < 0.05, "pattern {pat} tiles the layer: {}", max_diff(&r, &src)); - } - let wire = run("ec.sim.shatter", &[("shape/repetitions", num(3.0))], src.clone(), 0.0); - assert!(wire.data.iter().any(|p| p[3] > 0.5) && wire.data.iter().any(|p| p[3] == 0.0)); - let layer_only = - run("ec.sim.shatter", &[("view", Value::Enum(0)), ("render", Value::Enum(1)), ("force1/force1Position", pt(16.0, 16.0))], src.clone(), 0.5); - assert!(layer_only.get(16, 16)[3] == 0.0, "broken centre removed"); - } - - #[test] - fn caustics_flat_water() { - let src = ramp(24, 24); - let a = run( - "ec.sim.caustics", - &[ - ("water/surfaceOpacity", num(0.0)), - ("lighting/ambientLight", num(0.0)), - ("material/diffuse", num(1.0)), - ("lighting/lightHeight", num(1000.0)), - ("material/specular", num(0.0)), - ], - src.clone(), - 0.0, - ); - assert!(max_diff(&a, &src) < 0.01, "flat water, light overhead = bottom: {}", max_diff(&a, &src)); - let b = run("ec.sim.caustics", &[], src.clone(), 0.0); - assert_eq!(b, run("ec.sim.caustics", &[], src.clone(), 0.0)); - assert!(sum_diff(&b, &src) > 0.1); - } - - #[test] - fn caustics_sky_and_light_types() { - let src = Image::filled(32, 32, [0.5, 0.5, 0.5, 1.0]); - let env = EffectEnv { host: Some(&FoamHost), ..Default::default() }; - let run_h = |vals: &[(&str, Value)]| run_fx("ec.sim.caustics", vals, src.clone(), 0.0, env).img; - // A mirror-like surface shows the (green) sky. - let mirror = [("water/surfaceOpacity", num(1.0)), ("sky/sky", Value::Layer(Some(2))), ("sky/intensity", num(1.0))]; - let p = run_h(&mirror).get(16, 16); - assert!(p[1] > p[0] + 0.3 && p[1] > p[2] + 0.3, "{p:?}"); - // Point Source lights pixels by their own direction to the light: the image is no - // longer uniform; Distant Source lights the flat water evenly. - let lit = |t: u32| { - run_h(&[ - ("lighting/lightType", Value::Enum(t)), - ("lighting/lightPosition", pt(0.0, 0.0)), - ("lighting/lightHeight", num(0.2)), - ("water/surfaceOpacity", num(0.0)), - ]) - }; - let (distant, point) = (lit(0), lit(1)); - assert!((distant.get(2, 2)[0] - distant.get(30, 30)[0]).abs() < 1e-5); - assert!(point.get(2, 2)[0] > point.get(30, 30)[0] + 0.02, "{:?} {:?}", point.get(2, 2), point.get(30, 30)); - // First Comp Light without a comp light: ambient only. - let none = run_h(&[("lighting/lightType", Value::Enum(2)), ("water/surfaceOpacity", num(0.0))]); - assert!(none.get(16, 16)[0] < distant.get(16, 16)[0]); - } - - #[test] - fn wave_world_wireframe_controls_ground_and_dry_areas() { - let base = [("producer1/producer1Position", pt(8.0, 16.0)), ("simulation/gridResolution", num(24.0))]; - let wire = |extra: &[(&str, Value)]| run("ec.sim.waveworld", &[base.as_slice(), extra].concat(), Image::new(32, 32), 0.5); - assert_ne!(wire(&[]), wire(&[("wireframeControls/horizontalRotation", num(40.0))])); - assert_ne!(wire(&[]), wire(&[("wireframeControls/verticalScale", num(3.0))])); - // Ground (left dark, right light): the light side rises above the water and is dry. - let env = EffectEnv { host: Some(&FoamHost), ..Default::default() }; - let hm = |extra: &[(&str, Value)]| { - let mut v: Vec<(&str, Value)> = base.to_vec(); - v.extend([("view", Value::Enum(1)), ("ground/ground", Value::Layer(Some(1))), ("ground/steepness", num(0.5)), ("ground/height", num(0.25))]); - v.extend_from_slice(extra); - run_fx("ec.sim.waveworld", &v, Image::new(32, 32), 1.0, env).img - }; - let solid = hm(&[]); - let clear = hm(&[("heightMapControls/renderDryAreasAs", Value::Enum(1))]); - assert!(solid.get(30, 16)[3] > 0.99 && clear.get(30, 16)[3] == 0.0); - assert!(clear.get(4, 16)[3] > 0.99, "wet side stays"); - // Dry ground stays flat: mid grey (brightness 0.5) there. - assert!((solid.get(30, 16)[0] - 0.5).abs() < 1e-4); - // Wave Strength slows waves over shallow water: the surface differs. - assert_ne!(hm(&[]), hm(&[("ground/waveStrength", num(1.0))])); - } - - #[test] - fn wave_world_seek_and_waves() { - let v = vec![("view", Value::Enum(1)), ("producer1/producer1Position", pt(16.0, 16.0)), ("simulation/gridResolution", num(24.0))]; - let _ = run("ec.sim.waveworld", &v, Image::new(32, 32), 0.5); - let a = run("ec.sim.waveworld", &v, Image::new(32, 32), 1.0); - let mut v2 = v.clone(); - v2.push(("heightMapControls/transparency", num(0.0))); - assert_eq!(a, run("ec.sim.waveworld", &v2, Image::new(32, 32), 1.0)); - // Waves spread: some pixels differ from the flat-water grey. - assert!(a.data.iter().any(|p| (p[0] - 0.5).abs() > 0.05), "waves"); - let flat = run("ec.sim.waveworld", &[("view", Value::Enum(1)), ("producer1/producer1Amplitude", num(0.0))], Image::new(32, 32), 1.0); - assert!(flat.data.iter().all(|p| (p[0] - 0.5).abs() < 1e-5)); - let wire = - run("ec.sim.waveworld", &[("producer1/producer1Position", pt(16.0, 16.0)), ("simulation/gridResolution", num(10.0))], Image::new(32, 32), 0.3); - assert!(wire.data.iter().any(|p| p[1] > 0.3)); - } - - #[test] - fn wave_world_steps_match_direct() { - // Fresh caches: resumed simulation equals simulation from scratch. - let v = vec![ - ("view", Value::Enum(1)), - ("producer1/producer1Position", pt(16.0, 16.0)), - ("simulation/gridResolution", num(20.0)), - ("simulation/damping", num(0.07)), - ]; - let a = run("ec.sim.waveworld", &v, Image::new(32, 32), 1.3); - let mut v2 = v.clone(); - v2.push(("simulation/damping", num(0.07000001))); - // Different key: computed from scratch at 1.3 directly vs via 0.4 (both fresh keys). - let _ = run("ec.sim.waveworld", &v2, Image::new(32, 32), 0.4); - let b = run("ec.sim.waveworld", &v2, Image::new(32, 32), 1.3); - assert!(max_diff(&a, &b) < 1e-3, "{}", max_diff(&a, &b)); - } - - #[test] - fn foam_bubbles_drift_and_seek() { - let v = vec![("producer/producerPoint", pt(16.0, 16.0)), ("view", Value::Enum(2)), ("producer/productionRate", num(2.0))]; - let none = run("ec.sim.foam", &v, Image::new(32, 32), 0.0); - assert!(none.data.iter().all(|p| p[3] == 0.0)); - let _ = run("ec.sim.foam", &v, Image::new(32, 32), 0.6); - let a = run("ec.sim.foam", &v, Image::new(32, 32), 1.2); - let mut v2 = v.clone(); - v2.push(("randomSeed", num(1.0))); - assert_eq!(a, run("ec.sim.foam", &v2, Image::new(32, 32), 1.2)); - assert!(a.data.iter().any(|p| p[3] > 0.05), "bubbles"); - let draft = run("ec.sim.foam", &[("producer/producerPoint", pt(16.0, 16.0)), ("producer/productionRate", num(2.0))], Image::new(32, 32), 1.0); - assert!(draft.data.iter().any(|p| p[3] > 0.05)); - } - - #[test] - fn foam_strength_controls_early_popping() { - // Strong bubbles all live their full lifespan; weak ones pop at random earlier. - assert_eq!(foam_pop_age(7, 300.0, 0.0, 1), 300.0); - let early = (0..200).filter(|&id| foam_pop_age(id, 300.0, 1.0, 1) < 150.0).count(); - assert!(early > 60 && early < 140, "{early}"); - let count = |strength: f64| { - let v = vec![ - ("producer/producerPoint", pt(16.0, 16.0)), - ("producer/productionRate", num(3.0)), - ("bubbles/lifespan", num(40.0)), - ("bubbles/strength", num(strength)), - ("physics/windSpeed", num(0.0)), - ]; - run("ec.sim.foam", &v, Image::new(32, 32), 1.5).data.iter().map(|p| p[3]).sum::() - }; - assert!(count(0.0) < count(20.0) * 0.9, "{} vs {}", count(0.0), count(20.0)); - } - - /// A host with a comp camera (identity, or tilted) and a first comp light. - struct Scene { - camera: Option<[[f64; 4]; 3]>, - light: Option, - layer: Option, - } - impl crate::EffectHost for Scene { - fn layer(&self, _: u64, _: bool) -> Option { - let img = self.layer.clone()?; - let size = [img.width as f64, img.height as f64]; - Some(crate::LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size }) - } - fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { - None - } - fn comp_scene(&self) -> Option { - Some(crate::CompScene { camera: self.camera, camera_layer: true, light: self.light }) - } - } - - fn run_env(id: &str, vals: &[(&str, Value)], img: Image, t: f64, host: &Scene) -> Image { - run_fx(id, vals, img, t, EffectEnv { host: Some(host), ..Default::default() }).img - } - - #[test] - fn card_dance_camera_systems() { - let src = ramp(40, 30); - // Camera Position: rotating the camera changes the view; zero rotation is the layer. - let r = run("ec.sim.carddance", &[("cameraPosition/yRotation", num(30.0))], src.clone(), 0.0); - assert!(sum_diff(&r, &src) > 10.0); - let order = run("ec.sim.carddance", &[("cameraPosition/yRotation", num(30.0)), ("cameraPosition/xRotation", num(30.0))], src.clone(), 0.0); - let order2 = run( - "ec.sim.carddance", - &[("cameraPosition/yRotation", num(30.0)), ("cameraPosition/xRotation", num(30.0)), ("cameraPosition/transformOrder", Value::Enum(5))], - src.clone(), - 0.0, - ); - assert!(sum_diff(&order, &order2) > 1.0, "Transform Order matters"); - // X, Y Position moves the view: the layer shifts by the offset. - let moved = run("ec.sim.carddance", &[("cameraPosition/xyPosition", pt(30.0, 15.0))], src.clone(), 0.0); - assert!((moved.get(25, 10)[0] - src.get(15, 10)[0]).abs() < 0.03, "{:?} {:?}", moved.get(25, 10), src.get(15, 10)); - // Corner Pins: the layer's corners land on the pins. - let pins = [ - ("cameraSystem", Value::Enum(1)), - ("cornerPins/upperLeftCorner", pt(10.0, 5.0)), - ("cornerPins/upperRightCorner", pt(30.0, 5.0)), - ("cornerPins/lowerLeftCorner", pt(10.0, 25.0)), - ("cornerPins/lowerRightCorner", pt(30.0, 25.0)), - ]; - let cp = run("ec.sim.carddance", &pins, src.clone(), 0.0); - assert_eq!(cp.get(3, 3)[3], 0.0, "outside the pinned quad"); - assert!(cp.get(20, 15)[3] > 0.99); - assert!((cp.get(20, 15)[0] - src.get(20, 15)[0]).abs() < 0.05, "centre maps to centre"); - // Comp Camera: the identity camera reproduces the layer; a camera that doubles x moves it. - let ident = Scene { camera: Some([[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.002, 1.0]]), light: None, layer: None }; - let cc = run_env("ec.sim.carddance", &[("cameraSystem", Value::Enum(2))], src.clone(), 0.0, &ident); - assert!(max_diff(&cc, &src) < 0.02, "{}", max_diff(&cc, &src)); - let half = Scene { camera: Some([[0.5, 0.0, 0.0, 0.0], [0.0, 0.5, 0.0, 0.0], [0.0, 0.0, 0.002, 1.0]]), ..ident }; - let cc = run_env("ec.sim.carddance", &[("cameraSystem", Value::Enum(2))], src.clone(), 0.0, &half); - assert_eq!(cc.get(35, 25)[3], 0.0, "half-size view leaves the far corner empty"); - } - - #[test] - fn card_dance_lighting_and_material() { - let src = Image::filled(40, 30, [0.5, 0.5, 0.5, 1.0]); - let tilt = [("rows", num(1.0)), ("columns", num(1.0)), ("yRotation/yRotSource", Value::Enum(0)), ("yRotation/yRotOffset", num(60.0))]; - let lit = |extra: &[(&str, Value)]| { - let mut v: Vec<(&str, Value)> = tilt.to_vec(); - v.extend_from_slice(extra); - run("ec.sim.carddance", &v, src.clone(), 0.0).get(20, 15) - }; - let base = lit(&[]); - // Tilted cards facing away from a frontal light get darker than flat ones. - assert!(base[0] < 0.45, "{base:?}"); - // Light colour tints; intensity brightens; ambient alone lights evenly. - let red = lit(&[("lighting/lightColor", col(1.0, 0.0, 0.0))]); - assert!(red[0] > red[1] + 0.05); - assert!(lit(&[("lighting/lightIntensity", num(2.0))])[0] > base[0]); - let amb = lit(&[("lighting/ambientLight", num(1.0)), ("material/diffuse", num(0.0))]); - assert!((amb[0] - 0.5).abs() < 1e-3); - // A light placed to the side (Point Source) changes the shading of the tilted card. - let side = lit(&[("lighting/lightType", Value::Enum(1)), ("lighting/lightPosition", pt(200.0, 15.0)), ("lighting/lightDepth", num(0.2))]); - assert!((side[0] - base[0]).abs() > 0.02, "{side:?} vs {base:?}"); - // Specular adds a highlight on flat, frontally lit cards. - let flat = run("ec.sim.carddance", &[("material/specular", num(0.5))], src.clone(), 0.0).get(20, 15); - assert!(flat[0] > 0.9, "{flat:?}"); - // First Comp Light: no light in the comp → ambient only; a light from the host is used. - let none = Scene { camera: None, light: None, layer: None }; - let a = run_env("ec.sim.carddance", &[("lighting/lightType", Value::Enum(2))], src.clone(), 0.0, &none).get(20, 15); - assert!((a[0] - 0.5 * (0.25 + 0.75)).abs() < 1e-3, "{a:?}"); - let blue = Scene { light: Some(crate::CompLight { pos: [20.0, 15.0, -100.0], dir: [0.0, 0.0, 1.0], color: [0.0, 0.0, 1.0], kind: 0 }), ..none }; - let a = run_env("ec.sim.carddance", &[("lighting/lightType", Value::Enum(2))], src.clone(), 0.0, &blue).get(20, 15); - assert!(a[2] > a[0] + 0.2, "{a:?}"); - } - - #[test] - fn shatter_custom_map_gradient_textures_and_extrusion() { - let src = ramp(32, 32); - let base = [("view", Value::Enum(0)), ("force1/force1Position", pt(16.0, 16.0)), ("force1/force1Radius", num(2.0)), ("shape/origin", pt(16.0, 16.0))]; - let with = |extra: &[(&str, Value)], t: f64, host: Option<&Scene>| { - let mut v: Vec<(&str, Value)> = base.to_vec(); - v.extend_from_slice(extra); - match host { - Some(h) => run_env("ec.sim.shatter", &v, src.clone(), t, h), - None => run("ec.sim.shatter", &v, src.clone(), t), - } - }; - // Custom Shatter Map: a map white on the left and black on the right; with White Tiles - // Fixed the left half stays put while the right half breaks away. - let mut map = Image::new(32, 32); - for y in 0..32 { - for x in 0..32 { - map.set(x, y, if x < 16 { [1.0; 4] } else { [0.0, 0.0, 0.0, 1.0] }); - } - } - let host = Scene { camera: None, light: None, layer: Some(map) }; - let custom = [("shape/pattern", Value::Enum(5)), ("shape/customShatterMap", Value::Layer(Some(1))), ("shape/whiteTilesFixed", Value::Bool(true))]; - let at_rest = with(&custom, 0.0, Some(&host)); - assert!(max_diff(&at_rest, &src) < 0.05, "custom pieces tile the layer: {}", max_diff(&at_rest, &src)); - let broke = with(&custom, 1.0, Some(&host)); - assert!(max_diff_region(&broke, &src, 0..12) < 0.05, "white tiles fixed"); - assert!(sum_diff_region(&broke, &src, 20..32) > 5.0, "black group moved"); - // Gradient: a black gradient layer with threshold 50 % keeps everything together. - let black = Scene { camera: None, light: None, layer: Some(Image::filled(32, 32, [0.0, 0.0, 0.0, 1.0])) }; - let g = with(&[("gradient/gradientLayer", Value::Layer(Some(1))), ("gradient/shatterThreshold", num(50.0))], 1.0, Some(&black)); - assert!(max_diff(&g, &src) < 0.05); - let gi = with( - &[("gradient/gradientLayer", Value::Layer(Some(1))), ("gradient/shatterThreshold", num(50.0)), ("gradient/invertGradient", Value::Bool(true))], - 1.0, - Some(&black), - ); - assert!(sum_diff(&gi, &src) > 10.0); - // Textures: Front Mode Color paints the pieces flat; Opacity fades them. - let flat = with(&[("textures/frontMode", Value::Enum(0)), ("textures/color", col(0.0, 1.0, 0.0))], 0.0, None); - let p = flat.get(16, 16); - assert!(p[1] > 0.95 && p[0] < 0.05, "{p:?}"); - let half = with(&[("textures/opacity", num(0.5)), ("shape/repetitions", num(2.0))], 0.0, None); - assert!((half.get(8, 4)[3] - 0.5).abs() < 0.02, "{:?}", half.get(8, 4)); - // Extrusion: thicker pieces cover more once they tumble. - let cover = |d: f64| { - with(&[("shape/extrusionDepth", num(d)), ("textures/sideMode", Value::Enum(0)), ("textures/color", col(1.0, 1.0, 1.0))], 0.6, None) - .data - .iter() - .map(|p| p[3]) - .sum::() - }; - assert!(cover(1.0) > cover(0.0) + 1.0, "{} vs {}", cover(1.0), cover(0.0)); - // Deterministic and seek-consistent. - assert_eq!(with(&custom, 0.7, Some(&host)), with(&custom, 0.7, Some(&host))); - } - - fn max_diff_region(a: &Image, b: &Image, xs: std::ops::Range) -> f32 { - let mut m = 0.0f32; - for y in 0..a.height { - for x in xs.clone() { - let (p, q) = (a.get(x as i64, y as i64), b.get(x as i64, y as i64)); - m = (0..4).map(|k| (p[k] - q[k]).abs()).fold(m, f32::max); - } - } - m - } - - fn sum_diff_region(a: &Image, b: &Image, xs: std::ops::Range) -> f32 { - let mut s = 0.0; - for y in 0..a.height { - for x in xs.clone() { - let (p, q) = (a.get(x as i64, y as i64), b.get(x as i64, y as i64)); - s += (0..4).map(|k| (p[k] - q[k]).abs()).sum::(); - } - } - s - } - - /// Layer 1 = a left-dark, right-light ramp; layer 2 = solid green. - struct FoamHost; - impl crate::EffectHost for FoamHost { - fn layer(&self, id: u64, _: bool) -> Option { - let img = if id == 1 { ramp(32, 32) } else { Image::filled(32, 32, [0.0, 1.0, 0.0, 1.0]) }; - Some(crate::LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size: [32.0, 32.0] }) - } - fn layer_at(&self, id: u64, _: f64, me: bool) -> Option { - self.layer(id, me) - } - fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { - None - } - } - - #[test] - fn foam_flow_map_pop_velocity_wobble_textures_and_reflection() { - let base = vec![ - ("producer/producerPoint", pt(16.0, 16.0)), - ("producer/productionRate", num(2.0)), - ("physics/windSpeed", num(0.0)), - ("physics/turbulence", num(0.0)), - ("view", Value::Enum(2)), - ]; - let with = |extra: &[(&str, Value)], t: f64| { - let mut v = base.clone(); - v.extend_from_slice(extra); - run_fx("ec.sim.foam", &v, Image::new(32, 32), t, EffectEnv { host: Some(&FoamHost), ..Default::default() }).img - }; - let cx = |img: &Image| { - let (mut s, mut n) = (0.0, 0.0); - for y in 0..32 { - for x in 0..32 { - let a = img.get(x, y)[3]; - s += a * x as f32; - n += a; - } - } - s / n.max(1e-6) - }; - // Flow map: bubbles run downhill (towards the dark left side). - let still = with(&[], 1.0); - let flowing = with(&[("flowMap/flowMap", Value::Layer(Some(1))), ("flowMap/flowMapSteepness", num(1.0))], 1.0); - assert!(cx(&flowing) < cx(&still) - 1.0, "{} vs {}", cx(&flowing), cx(&still)); - let hq = - with(&[("flowMap/flowMap", Value::Layer(Some(1))), ("flowMap/flowMapSteepness", num(1.0)), ("flowMap/simulationQuality", Value::Enum(2))], 1.0); - assert_eq!( - hq, - with(&[("flowMap/flowMap", Value::Layer(Some(1))), ("flowMap/flowMapSteepness", num(1.0)), ("flowMap/simulationQuality", Value::Enum(2))], 1.0) - ); - // Pop velocity scatters the survivors when short-lived bubbles pop. - let short = [("bubbles/lifespan", num(8.0)), ("bubbles/strength", num(0.0))]; - let a = with(&[short.as_slice(), &[("physics/popVelocity", num(0.0))]].concat(), 1.0); - let b2 = with(&[short.as_slice(), &[("physics/popVelocity", num(20.0))]].concat(), 1.0); - assert_ne!(a, b2); - // Wobble changes the drawn bubbles. - assert_ne!(with(&[("physics/wobbleAmount", num(0.0))], 1.0), with(&[("physics/wobbleAmount", num(3.0))], 1.0)); - // User Defined texture: bubbles show the texture layer (green). - let tex = with(&[("rendering/bubbleTexture", Value::Enum(5)), ("rendering/bubbleTextureLayer", Value::Layer(Some(2)))], 1.0); - assert!(tex.data.iter().any(|p| p[1] > 0.5 && p[0] < 0.1)); - // Environment map: reflections tint the bubbles. - let refl = with(&[("rendering/environmentMap", Value::Layer(Some(2))), ("rendering/reflectionStrength", num(1.0))], 1.0); - let g = |i: &Image| i.data.iter().map(|p| p[1] - p[0]).sum::(); - assert!(g(&refl) > g(&still) + 1.0); - // Zoom Producer Point zooms about the producer instead of the layer centre. - let off = [("producer/producerPoint", pt(8.0, 8.0)), ("zoom", num(2.0))]; - let z1 = with(&off, 1.0); - let z2 = with(&[off.as_slice(), &[("producer/zoomProducerPoint", Value::Bool(true))]].concat(), 1.0); - assert!((cx(&z2) - 8.0).abs() < 2.0 && cx(&z1) < 4.0, "{} vs {}", cx(&z2), cx(&z1)); - } -} +mod tests; diff --git a/crates/effects/src/sim2/foam.rs b/crates/effects/src/sim2/foam.rs new file mode 100644 index 000000000..fc187d191 --- /dev/null +++ b/crates/effects/src/sim2/foam.rs @@ -0,0 +1,411 @@ +use super::{ + Acc, Buf, EffectCtx, Image, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, ParallelIterator, Plane, Px, Shape, SimCache, + Sprite, Value, hs, params_key, splat, unpremul, value_noise, +}; + +// ------------------------------------------------------------------ Foam + +#[derive(Clone, Debug)] +struct FoamBubble { + p: [f32; 2], + v: [f32; 2], + age: f32, + size: f32, + /// Spin (radians) for Physical Orientation. + spin: f32, + id: u32, +} + +#[derive(Clone, Debug, Default)] +struct FoamState { + b: Vec, + carry: f32, + next: u32, +} + +static FOAM_CACHE: SimCache = SimCache::new(4); + +/// Foam steps once per frame at 30 frames per second. +const FOAM_FPS: f64 = 30.0; + +/// Foam's Bubble Texture options (the last uses Bubble Texture Layer). +pub(super) const FOAM_TEXTURES: [&str; 6] = ["Default Bubble", "Spit", "Bubblegum", "Dishwater", "Milky", "User Defined"]; + +/// Age (in simulation steps) at which bubble `id` pops: its lifespan, shortened at random for +/// frail bubbles (`frailty` 0 = every bubble reaches `lifespan`, 1 = uniformly early). +pub(super) fn foam_pop_age(id: u32, lifespan: f32, frailty: f32, seed: u32) -> f32 { + lifespan * (1.0 - frailty * (hs(id, 11, seed) * 0.5 + 0.5)) +} + +/// Foam: bubbles born in the producer rectangle grow to their size, drift with the wind, +/// turbulence and the Flow Map (bubbles run downhill on its luminance), repel and stick to each +/// other, wobble, and pop at the end of their (Strength-shortened) lifespan, pushing their +/// neighbours away at Pop Velocity. Rendered bubbles use a built-in texture or the Bubble +/// Texture Layer, oriented by Bubble Orientation, and reflect the Environment Map. +pub(super) fn foam(ctx: &EffectCtx, b: Buf) -> Buf { + foam_full_plan(ctx, &b).finish(b) +} + +/// Foam's bubbles as sprites, `None` when it draws more than sprites (a User Defined bubble +/// texture, an Environment Map reflection or the Draft + Flow Map view's flow map). +pub(crate) fn foam_plan(ctx: &EffectCtx, b: &Buf) -> Option { + let plan = foam_full_plan(ctx, b); + (plan.user.is_none() && plan.env.is_none() && plan.flow.is_none()).then_some(plan.sprites) +} + +/// A bubble disc filled by Foam's User Defined texture (buffer px; `rot` radians). +#[derive(Clone, Copy, Debug)] +pub struct FoamDisc { + pub x: f64, + pub y: f64, + pub r: f32, + pub rot: f32, + pub fade: f32, +} + +/// Foam's Environment Map reflection: the map fitted to the buffer, Reflection Convergence and +/// Strength, and the bubble discs reflecting it (`rot` and `fade` unused). +pub struct FoamEnv { + pub img: Image, + pub conv: f64, + pub strength: f32, + pub discs: Vec, +} + +/// What Foam draws this frame over a transparent frame of the layer's size: the bubble sprites, +/// then the User Defined texture's discs, the Environment Map's reflections and (Draft + Flow +/// Map) the flow map under it all, each `foam_disc`-composited in bubble order. +pub struct FoamPlan { + pub sprites: crate::sim::SpritePlan, + pub user: Option<(crate::LayerPixels, Vec)>, + pub env: Option, + /// The flow map fitted to the buffer. + pub flow: Option, +} + +impl FoamPlan { + pub fn finish(&self, b: Buf) -> Buf { + let mut out = splat(b.img.width, b.img.height, &self.sprites.sprites, self.sprites.acc); + // User Defined: the texture layer fills each bubble's disc. + if let Some((tex, discs)) = &self.user { + for d in discs { + let (rot, fade) = (d.rot, d.fade); + foam_disc(&mut out, d.x, d.y, d.r as f64, |u, v| { + // u, v in −1..1 across the bubble, rotated into the texture. + let (sr, cr) = rot.sin_cos(); + let (tu, tv) = (u as f32 * cr + v as f32 * sr, -u as f32 * sr + v as f32 * cr); + let (x, y) = ((tu as f64 * 0.5 + 0.5) * tex.size[0], (tv as f64 * 0.5 + 0.5) * tex.size[1]); + let px = tex.buf.img.sample_bilinear(x * tex.buf.scale + tex.buf.offset[0], y * tex.buf.scale + tex.buf.offset[1]); + px.map(|c| c * fade) + }); + } + } + // Environment Map: each bubble reflects the map along its sphere normal (Reflection + // Convergence pulls the reflection towards the bubble's own spot of the map). + if let Some(env) = &self.env { + let (conv, strength) = (env.conv, env.strength); + let (w, h) = (b.img.width as f64, b.img.height as f64); + for d in &env.discs { + let (bx, by) = (d.x, d.y); + foam_disc(&mut out, bx, by, d.r as f64, |u, v| { + // Sphere normal; the reflection looks across the whole map, or (convergence 1) + // just behind the bubble. + let (ex, ey) = + if conv >= 1.0 { (bx, by) } else { (bx * conv + (u * 0.5 + 0.5) * w * (1.0 - conv), by * conv + (v * 0.5 + 0.5) * h * (1.0 - conv)) }; + let e = env.img.sample_bilinear(ex, ey); + let rim = ((u * u + v * v) as f32).min(1.0); + let k = strength * (0.3 + 0.7 * rim); + [e[0] * k, e[1] * k, e[2] * k, 0.0] + }); + } + } + // Draft + Flow Map shows the flow map under the bubbles. + if let Some(fm) = &self.flow { + out.data.par_iter_mut().zip(fm.data.par_iter()).for_each(|(o, f)| { + let k = 1.0 - o[3]; + for c in 0..4 { + o[c] += f[c] * 0.5 * k; + } + }); + } + Buf { img: out, ..b } + } +} + +/// Foam's [`FoamPlan`] for layer geometry `b` (its size; its pixels are not read). +pub fn foam_full_plan(ctx: &EffectCtx, b: &Buf) -> FoamPlan { + let pr = ctx.params; + let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); + let unit = lw * 0.01; + let prod = pr.v2("producer/producerPoint"); + let (psx, psy) = (pr.f("producer/producerXSize") as f32 * lw, pr.f("producer/producerYSize") as f32 * lh); + let porient = (pr.f("producer/producerOrientation") as f32).to_radians(); + let rate = pr.f("producer/productionRate").max(0.0) as f32; + let size = pr.f("bubbles/size").max(0.0) as f32; + let size_var = pr.f("bubbles/sizeVariance") as f32; + let lifespan = pr.f("bubbles/lifespan").max(1.0) as f32; + let growth = pr.f("bubbles/growthSpeed").max(0.0001) as f32; + // Strength: weaker bubbles tend to pop before their lifespan (at 20 or more every bubble + // lives the full lifespan); each bubble gets its own pop age. + let frailty = 1.0 - (pr.f("bubbles/strength") as f32 / 20.0).clamp(0.0, 1.0); + let init_speed = pr.f("physics/initialSpeed") as f32; + let init_dir = (pr.f("physics/initialDirection") as f32).to_radians(); + let wind = pr.f("physics/windSpeed") as f32; + let wind_dir = (pr.f("physics/windDirection") as f32).to_radians(); + let turb = pr.f("physics/turbulence") as f32; + let wobble = pr.f("physics/wobbleAmount").max(0.0) as f32; + let repulsion = pr.f("physics/repulsion") as f32; + let pop_velocity = pr.f("physics/popVelocity") as f32; + let viscosity = pr.f("physics/viscosity").clamp(0.0, 1.0) as f32; + let sticky = pr.f("physics/stickiness").clamp(0.0, 1.0) as f32; + let universe = pr.f("universeSize").max(0.01) as f32; + let seed = (pr.f("randomSeed") as u32).wrapping_mul(0x68e31da4) ^ ctx.seed; + let wvec = [wind_dir.sin() * wind * unit, -wind_dir.cos() * wind * unit]; + // Flow Map: the map layer's first frame, its luminance a height field (Fits the layer or + // the whole universe). + let flow = pr.get("flowMap/flowMap").and_then(Value::as_layer).and_then(|id| { + let host = ctx.env.host?; + let me = pr.get(&crate::layer_source_id("flowMap/flowMap")).is_none_or(|v| v.as_enum() != 0); + host.layer_at(id, ctx.env.comp_time - ctx.time, me) + }); + let steep = pr.f("flowMap/flowMapSteepness") as f32; + let fits_universe = pr.e("flowMap/flowMapFits") == 1; + let substeps = match pr.e("flowMap/simulationQuality") { + 1 => 2, + 2 => 4, + _ => 1, + }; + let height = flow.as_ref().map(|lp| { + let img = &lp.buf.img; + let pl = Plane::from_image(img, |px| { + let (c, a) = unpremul(px); + effectcraft_color::luminance(c[0], c[1], c[2]) * a + }); + (pl, lp.buf.scale, lp.buf.offset, lp.size) + }); + let (ux0, uy0) = (-(universe - 1.0) * 0.5 * lw, -(universe - 1.0) * 0.5 * lh); + let (ux1, uy1) = (lw - ux0, lh - uy0); + // Height at a layer point (0..1). + let h_at = |x: f32, y: f32| -> f32 { + let Some((pl, s, off, size)) = &height else { return 0.0 }; + let (fx, fy) = if fits_universe { ((x - ux0) / (ux1 - ux0), (y - uy0) / (uy1 - uy0)) } else { (x / lw, y / lh) }; + pl.sample(fx as f64 * size[0] * s + off[0], fy as f64 * size[1] * s + off[1]) + }; + let mut key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 5); + if let Some((pl, ..)) = &height { + key ^= pl.data.iter().step_by(5).fold(0u64, |a, v| a.rotate_left(3) ^ v.to_bits() as u64); + } + let steps = if ctx.time <= 0.0 { 0 } else { (ctx.time * FOAM_FPS + 1e-6).floor() as u64 }; + let rad = |q: &FoamBubble| q.size * unit * 3.0 * (q.age * growth).min(1.0); + let st = FOAM_CACHE.run(key, steps, FoamState::default, |st, step| { + let t = step as f32 / FOAM_FPS as f32; + // Births. + st.carry += rate; + let n = st.carry.floor() as u32; + st.carry -= n as f32; + for _ in 0..n { + if st.b.len() >= 3000 { + break; + } + let id = st.next; + st.next = st.next.wrapping_add(1); + let (ux, uy) = (hs(id, 1, seed) * 0.5 * psx, hs(id, 2, seed) * 0.5 * psy); + let (so, co) = porient.sin_cos(); + let p = [prod[0] as f32 + ux * co - uy * so, prod[1] as f32 + ux * so + uy * co]; + let v = [init_dir.sin() * init_speed * unit, -init_dir.cos() * init_speed * unit]; + let sz = (size * (1.0 + size_var * hs(id, 3, seed))).max(0.01); + st.b.push(FoamBubble { p, v, age: 0.0, size: sz, spin: hs(id, 12, seed) * std::f32::consts::PI, id }); + } + for _ in 0..substeps { + let k = 1.0 / substeps as f32; + // Forces: drift towards the wind, turbulence, viscosity, the flow map's slope. + for q in st.b.iter_mut() { + let n1 = value_noise(q.p[0] / (lw * 0.15), q.p[1] / (lw * 0.15), t * 0.5, seed) - 0.5; + let n2 = value_noise(q.p[0] / (lw * 0.15) + 19.0, q.p[1] / (lw * 0.15), t * 0.5, seed) - 0.5; + for c in 0..2 { + q.v[c] += (wvec[c] - q.v[c]) * 0.1 * k; + q.v[c] *= 1.0 - viscosity * 0.5 * k; + } + q.v[0] += n1 * turb * unit * k; + q.v[1] += n2 * turb * unit * k; + if height.is_some() && steep != 0.0 { + let e = unit.max(1.0); + let gx = (h_at(q.p[0] + e, q.p[1]) - h_at(q.p[0] - e, q.p[1])) / (2.0 * e); + let gy = (h_at(q.p[0], q.p[1] + e) - h_at(q.p[0], q.p[1] - e)) / (2.0 * e); + // Downhill: from light (high) to dark (low). + q.v[0] -= gx * steep * lw * 0.5 * k; + q.v[1] -= gy * steep * lw * 0.5 * k; + } + // Physical Orientation: bubbles roll with their sideways motion. + q.spin += q.v[0] / (rad(q).max(0.5)) * k; + } + // Repulsion between overlapping bubbles (grid hashed, deterministic order). + if repulsion > 0.0 && st.b.len() > 1 { + let maxr = st.b.iter().map(rad).fold(0.0f32, f32::max).max(1.0); + let cs = maxr * 2.0; + let mut grid: std::collections::HashMap<(i32, i32), Vec> = std::collections::HashMap::new(); + for (i, q) in st.b.iter().enumerate() { + grid.entry(((q.p[0] / cs).floor() as i32, (q.p[1] / cs).floor() as i32)).or_default().push(i); + } + let mut push = vec![[0.0f32; 2]; st.b.len()]; + for (i, q) in st.b.iter().enumerate() { + let (gx, gy) = ((q.p[0] / cs).floor() as i32, (q.p[1] / cs).floor() as i32); + for dy in -1..=1 { + for dx in -1..=1 { + let Some(list) = grid.get(&(gx + dx, gy + dy)) else { continue }; + for &j in list { + if j <= i { + continue; + } + let o = &st.b[j]; + let (ddx, ddy) = (o.p[0] - q.p[0], o.p[1] - q.p[1]); + let d = (ddx * ddx + ddy * ddy).sqrt().max(1e-4); + let overlap = rad(q) + rad(o) - d; + if overlap > 0.0 { + let f = overlap * 0.5 * repulsion.min(4.0) * (1.0 - sticky * 0.5) / d * k; + push[i][0] -= ddx * f; + push[i][1] -= ddy * f; + push[j][0] += ddx * f; + push[j][1] += ddy * f; + } + } + } + } + } + for (q, d) in st.b.iter_mut().zip(&push) { + q.p[0] += d[0].clamp(-maxr, maxr); + q.p[1] += d[1].clamp(-maxr, maxr); + } + } + for q in st.b.iter_mut() { + q.p[0] += q.v[0] * k; + q.p[1] += q.v[1] * k; + } + } + for q in st.b.iter_mut() { + q.age += 1.0; + } + // Pops: bubbles past their pop age burst, pushing their neighbours away. + let popped: Vec<([f32; 2], f32)> = st.b.iter().filter(|q| q.age >= foam_pop_age(q.id, lifespan, frailty, seed)).map(|q| (q.p, rad(q))).collect(); + if pop_velocity != 0.0 { + for (pp, pr_) in &popped { + let reach = (pr_ * 3.0).max(unit); + for q in st.b.iter_mut() { + let (dx, dy) = (q.p[0] - pp[0], q.p[1] - pp[1]); + let d = (dx * dx + dy * dy).sqrt(); + if d > 1e-4 && d < reach { + let f = pop_velocity * unit * (1.0 - d / reach) / d; + q.v[0] += dx * f; + q.v[1] += dy * f; + } + } + } + } + st.b.retain(|q| q.age < foam_pop_age(q.id, lifespan, frailty, seed) && q.p[0] > ux0 && q.p[0] < ux1 && q.p[1] > uy0 && q.p[1] < uy1); + }); + let view = pr.e("view"); + let zoom = pr.f("zoom").max(0.01) as f32; + let zoom_producer = pr.b("producer/zoomProducerPoint"); + let texture = pr.e("rendering/bubbleTexture"); + let orient = pr.e("rendering/bubbleOrientation"); + let blend = pr.e("rendering/blendMode"); + let s = b.scale as f32; + // Zoom centres on the layer, or on the producer with Zoom Producer Point. + let (cx, cy) = if zoom_producer { (prod[0] as f32, prod[1] as f32) } else { (lw * 0.5, lh * 0.5) }; + let mut order: Vec<&FoamBubble> = st.b.iter().collect(); + if blend == 2 { + // Solid New on Top: oldest first. + order.sort_by(|a, c| c.age.total_cmp(&a.age).then(a.id.cmp(&c.id))); + } else if blend == 1 { + order.sort_by(|a, c| a.age.total_cmp(&c.age).then(a.id.cmp(&c.id))); + } + // Where a bubble is drawn: buffer centre, radius, rotation (radians). + let place = |q: &FoamBubble| { + let wob = if wobble > 0.0 { 1.0 + wobble * 0.15 * ((q.age * 0.35) + hs(q.id, 13, seed) * 6.0).sin() } else { 1.0 }; + let r = rad(q) * wob * zoom * s; + let (bx, by) = b.to_px([(cx + (q.p[0] - cx) * zoom) as f64, (cy + (q.p[1] - cy) * zoom) as f64]); + let rot = match orient { + 1 => q.spin, + 2 => q.v[1].atan2(q.v[0]), + _ => 0.0, + }; + (bx, by, r, rot) + }; + let user_tex = (view == 2 && texture == 5).then(|| ctx.layer_param("rendering/bubbleTextureLayer", true)).flatten(); + let sprites: Vec = order + .iter() + .filter(|_| user_tex.is_none()) + .map(|q| { + let (bx, by, r, rot) = place(q); + let fade = ((foam_pop_age(q.id, lifespan, frailty, seed) - q.age) / 5.0).clamp(0.0, 1.0); + // Draft and Draft + Flow Map show the wireframe-style bubbles. + let mut sp = if view != 2 { + Sprite::new(bx as f32, by as f32, r, [0.55, 0.75, 1.0, fade], Shape::Bubble) + } else { + let (c, shape) = match texture { + 1 => ([0.95, 0.95, 0.92], Shape::Faded), + 2 => ([1.0, 0.6, 0.8], Shape::Bubble), + 3 => ([0.8, 0.85, 0.75], Shape::Bubble), + 4 => ([0.97, 0.97, 0.95], Shape::Sphere), + _ => ([1.0, 1.0, 1.0], Shape::Bubble), + }; + Sprite::new(bx as f32, by as f32, r, [c[0], c[1], c[2], fade], shape) + }; + sp.rot = rot; + sp + }) + .collect(); + let acc = if blend == 0 && view == 2 { Acc::Add } else { Acc::Over }; + let disc = |q: &&FoamBubble| { + let (x, y, r, rot) = place(q); + let fade = ((foam_pop_age(q.id, lifespan, frailty, seed) - q.age) / 5.0).clamp(0.0, 1.0); + FoamDisc { x, y, r, rot, fade } + }; + let user = user_tex.map(|tex| (tex, order.iter().map(disc).collect())); + let strength = pr.f("rendering/reflectionStrength").clamp(0.0, 1.0) as f32; + let env = if view == 2 + && strength > 0.0 + && let Some(env) = ctx.layer_param("rendering/environmentMap", true) + { + Some(FoamEnv { + img: crate::util::fit_layer(ctx, b, &env, true), + conv: pr.f("rendering/reflectionConvergence").clamp(0.0, 1.0), + strength, + discs: order.iter().map(disc).collect(), + }) + } else { + None + }; + let flow = if view == 1 + && let Some(lp) = &flow + { + Some(crate::util::fit_layer(ctx, b, lp, true)) + } else { + None + }; + FoamPlan { sprites: crate::sim::SpritePlan { sprites, tints: vec![], acc, post: crate::sim::Post::Replace }, user, env, flow } +} + +/// Add `f(u, v)` (premultiplied; u, v ∈ −1..1 across the disc) over the disc of radius `r` at +/// (`x`, `y`) with anti-aliased edges ("over" for opaque texels, additive for alpha 0). +fn foam_disc(out: &mut Image, x: f64, y: f64, r: f64, f: impl Fn(f64, f64) -> Px) { + if r <= 0.0 { + return; + } + let (x0, x1) = ((x - r - 1.0).floor().max(0.0) as i64, (x + r + 1.0).ceil().min(out.width as f64) as i64); + let (y0, y1) = ((y - r - 1.0).floor().max(0.0) as i64, (y + r + 1.0).ceil().min(out.height as f64) as i64); + for py in y0..y1 { + for px in x0..x1 { + let (u, v) = ((px as f64 + 0.5 - x) / r, (py as f64 + 0.5 - y) / r); + let d = (u * u + v * v).sqrt(); + let cov = ((1.0 - d) * r + 0.5).clamp(0.0, 1.0) as f32; + if cov <= 0.0 { + continue; + } + let s = f(u.clamp(-1.0, 1.0), v.clamp(-1.0, 1.0)).map(|c| c * cov); + let o = out.get(px, py); + let k = 1.0 - s[3]; + out.set(px as u32, py as u32, [s[0] + o[0] * k, s[1] + o[1] * k, s[2] + o[2] * k, (s[3] + o[3] * k).min(1.0)]); + } + } +} diff --git a/crates/effects/src/sim2/pieces.rs b/crates/effects/src/sim2/pieces.rs new file mode 100644 index 000000000..bff5943c0 --- /dev/null +++ b/crates/effects/src/sim2/pieces.rs @@ -0,0 +1,277 @@ +use super::shatter::{ShatterPlan, card_dance_plan, shatter_impl}; +use super::{Acc, Buf, EffectCtx, Image, Lighting, Proj, raster, unpremul}; + +// ------------------------------------------------------------------ 3D pieces + +pub(crate) type M3 = [[f64; 3]; 3]; + +fn mat_mul(a: &M3, b: &M3) -> M3 { + std::array::from_fn(|i| std::array::from_fn(|j| (0..3).map(|k| a[i][k] * b[k][j]).sum())) +} + +/// Rotation from Euler angles (radians), applied X then Y then Z. +pub(super) fn rot_xyz(x: f64, y: f64, z: f64) -> M3 { + let (sx, cx) = x.sin_cos(); + let (sy, cy) = y.sin_cos(); + let (sz, cz) = z.sin_cos(); + let rx = [[1.0, 0.0, 0.0], [0.0, cx, -sx], [0.0, sx, cx]]; + let ry = [[cy, 0.0, sy], [0.0, 1.0, 0.0], [-sy, 0.0, cy]]; + let rz = [[cz, -sz, 0.0], [sz, cz, 0.0], [0.0, 0.0, 1.0]]; + mat_mul(&rz, &mat_mul(&ry, &rx)) +} + +/// Rotation by `angle` about unit `axis` (Rodrigues). +pub(crate) fn rot_axis(axis: [f64; 3], angle: f64) -> M3 { + let l = (axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]).sqrt(); + if l < 1e-12 || angle == 0.0 { + return [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + } + let (x, y, z) = (axis[0] / l, axis[1] / l, axis[2] / l); + let (s, c) = angle.sin_cos(); + let t = 1.0 - c; + [ + [t * x * x + c, t * x * y - s * z, t * x * z + s * y], + [t * x * y + s * z, t * y * y + c, t * y * z - s * x], + [t * x * z - s * y, t * y * z + s * x, t * z * z + c], + ] +} + +pub(super) fn inv3(m: &M3) -> Option { + let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) + + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]); + if det.abs() < 1e-12 { + return None; + } + let d = 1.0 / det; + Some([ + [(m[1][1] * m[2][2] - m[1][2] * m[2][1]) * d, (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * d, (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * d], + [(m[1][2] * m[2][0] - m[1][0] * m[2][2]) * d, (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * d, (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * d], + [(m[1][0] * m[2][1] - m[1][1] * m[2][0]) * d, (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * d, (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * d], + ]) +} + +/// Perspective camera looking down +z at the layer plane z = 0, `d` pixels away, centred on +/// `c` (buffer pixels): screen = c + (P - c) · d / (d + z). +#[derive(Clone, Copy)] +pub(super) struct Cam { + pub(super) c: [f64; 2], + pub(super) d: f64, +} + +impl Cam { + fn proj(self) -> Proj { + Proj::simple(self.c, self.d) + } +} + +/// A planar textured piece ready to draw. +#[derive(Clone, Copy, Debug)] +pub struct Piece { + /// Screen (buffer px, homogeneous) → piece-local (a, b, w). + pub inv: [[f64; 3]; 3], + /// Rest centre of the piece in texture pixels. + pub c0: [f32; 2], + /// Convex polygon (texture pixels), counter-clockwise in y-down space. + pub poly: [[f32; 2]; 6], + pub n: u8, + pub bbox: [f32; 4], + pub shade: f32, + /// Lighting: colour multiplier and additive specular highlight. + pub tint: [f32; 3], + pub spec: [f32; 3], + pub alpha: f32, + /// Flat straight colour instead of the texture. + pub flat: Option<[f32; 3]>, + /// Facing away from the camera (draw the back texture). + pub back: bool, + /// Back faces sample their texture mirrored about the piece centre: 0 no, 1 vertically + /// (flipped about the X axis), 2 horizontally (about the Y axis). + pub back_mirror: u8, + depth: f64, +} + +/// A piece texture: the layer itself or another image (buffer-aligned). +#[derive(Clone, Debug)] +pub enum PieceTex { + Layer, + Image(Image), +} + +/// What a piece-drawing effect (Card Dance, Shatter, Card Wipe) draws this frame: pieces sorted +/// far to near over a transparent frame, textured by `front` (and `back` for pieces facing +/// away; none = they take `front`). +#[derive(Clone, Debug)] +pub struct PiecePlan { + pub pieces: Vec, + pub front: PieceTex, + pub back: Option, +} + +impl PiecePlan { + /// Rasterise over a transparent frame of the layer `b`'s size. + pub fn finish(&self, mut b: Buf) -> Buf { + let mut out = Image::new(b.img.width, b.img.height); + let front = match &self.front { + PieceTex::Layer => &b.img, + PieceTex::Image(i) => i, + }; + let back = self.back.as_ref().map(|t| match t { + PieceTex::Layer => &b.img, + PieceTex::Image(i) => i, + }); + draw_pieces(&mut out, &self.pieces, front, back); + b.img = out; + b + } +} + +/// The piece plan of Card Dance, Shatter (Rendered view) or Card Wipe for layer `b` (its pixels +/// may be read: gradient maps and textures default to the layer), `None` for other effects or +/// views drawn otherwise. +pub fn piece_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option { + match id { + "ec.sim.carddance" => Some(card_dance_plan(ctx, b)), + "ec.sim.shatter" if ctx.params.e("view") == 0 => match shatter_impl(ctx, b) { + ShatterPlan::Pieces(plan) => Some(plan), + ShatterPlan::Wire(_) => None, + }, + "ec.transition.cardwipe" => crate::transition::card_wipe_plan(ctx, b), + _ => None, + } +} + +impl Piece { + /// Place polygon `poly` (texture px, around rest centre `c0`) with rotation `r` and its + /// centre at 3D `pos` (buffer px; z away from the camera), seen through `cam`. + pub(crate) fn new(poly: &[[f32; 2]], c0: [f32; 2], r: &M3, pos: [f64; 3], cam: impl Into) -> Option { + let cam: Proj = cam.into(); + let n = poly.len().min(6); + if n < 3 { + return None; + } + let m = &cam.m; + let row = |i: usize| -> [f64; 3] { + [ + m[i][0] * r[0][0] + m[i][1] * r[1][0] + m[i][2] * r[2][0], + m[i][0] * r[0][1] + m[i][1] * r[1][1] + m[i][2] * r[2][1], + m[i][0] * pos[0] + m[i][1] * pos[1] + m[i][2] * pos[2] + m[i][3], + ] + }; + let hm: M3 = [row(0), row(1), row(2)]; + let mut bb = [f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY]; + for v in &poly[..n] { + let (a, b) = ((v[0] - c0[0]) as f64, (v[1] - c0[1]) as f64); + let w = hm[2][0] * a + hm[2][1] * b + hm[2][2]; + if w < cam.near { + return None; + } + let sx = ((hm[0][0] * a + hm[0][1] * b + hm[0][2]) / w) as f32; + let sy = ((hm[1][0] * a + hm[1][1] * b + hm[1][2]) / w) as f32; + bb = [bb[0].min(sx), bb[1].min(sy), bb[2].max(sx), bb[3].max(sy)]; + } + let inv = inv3(&hm)?; + let mut pp = [[0.0f32; 2]; 6]; + pp[..n].copy_from_slice(&poly[..n]); + // Normalise winding so "inside" is cross >= 0 for every edge. + let area: f32 = (0..n).map(|i| pp[i][0] * pp[(i + 1) % n][1] - pp[(i + 1) % n][0] * pp[i][1]).sum(); + if area < 0.0 { + pp[..n].reverse(); + } + // The front faces -z at rest; it faces the camera when its normal points at the eye. + let eye = cam.eye(); + let nrm = [-r[0][2], -r[1][2], -r[2][2]]; + let to_eye = [eye[0] - pos[0], eye[1] - pos[1], eye[2] - pos[2]]; + let facing = nrm[0] * to_eye[0] + nrm[1] * to_eye[1] + nrm[2] * to_eye[2]; + let depth = hm[2][2]; + Some(Piece { + inv, + c0, + poly: pp, + n: n as u8, + bbox: [bb[0] - 1.0, bb[1] - 1.0, bb[2] + 1.0, bb[3] + 1.0], + shade: 1.0, + tint: [1.0; 3], + spec: [0.0; 3], + alpha: 1.0, + flat: None, + back: facing < 0.0, + back_mirror: 0, + depth, + }) + } + + /// Lambert factor of the piece normal against a light shining along +z. + pub(super) fn facing(r: &M3) -> f32 { + r[2][2].abs() as f32 + } + + /// Light the piece (rotation `r`, centre `pos`) with Lighting / Material. + pub(crate) fn light(&mut self, l: &Lighting, r: &M3, pos: [f64; 3], eye: [f64; 3]) { + let (t, s) = l.shade([-r[0][2], -r[1][2], -r[2][2]], pos, eye); + self.tint = t; + self.spec = s; + } +} + +impl From for Proj { + fn from(c: Cam) -> Proj { + c.proj() + } +} + +/// Draw pieces (already sorted far → near) over `out`. +pub(crate) fn draw_pieces(out: &mut Image, pieces: &[Piece], front: &Image, back: Option<&Image>) { + raster( + out, + pieces, + |q| Some(q.bbox), + |q, x, y| { + let (xf, yf) = (x as f64, y as f64); + let m = &q.inv; + let w = m[2][0] * xf + m[2][1] * yf + m[2][2]; + if w.abs() < 1e-12 { + return None; + } + let u = ((m[0][0] * xf + m[0][1] * yf + m[0][2]) / w) as f32 + q.c0[0]; + let v = ((m[1][0] * xf + m[1][1] * yf + m[1][2]) / w) as f32 + q.c0[1]; + let n = q.n as usize; + let mut md = f32::INFINITY; + for i in 0..n { + let a = q.poly[i]; + let b = q.poly[(i + 1) % n]; + let (ex, ey) = (b[0] - a[0], b[1] - a[1]); + let l = (ex * ex + ey * ey).sqrt().max(1e-6); + md = md.min((ex * (v - a[1]) - ey * (u - a[0])) / l); + } + // Coverage extends half a pixel past the edge so abutting pieces leave no seams. + let cov = (md + 1.0).clamp(0.0, 1.0); + if cov <= 0.0 { + return None; + } + let (c, a) = match q.flat { + Some(c) => (c, 1.0), + None => { + let tex = if q.back { back.unwrap_or(front) } else { front }; + let (u, v) = match (q.back, q.back_mirror) { + (true, 1) => (u, 2.0 * q.c0[1] - v), + (true, 2) => (2.0 * q.c0[0] - u, v), + _ => (u, v), + }; + unpremul(tex.sample_bilinear(u as f64, v as f64)) + } + }; + let a = a * cov * q.alpha; + if a <= 1e-6 { + return None; + } + let k = q.shade; + let o: [f32; 3] = std::array::from_fn(|i| c[i] * k * q.tint[i] + q.spec[i]); + Some([o[0] * a, o[1] * a, o[2] * a, a]) + }, + Acc::Over, + ); +} + +pub(crate) fn sort_far_first(pieces: &mut [Piece]) { + pieces.sort_by(|a, b| b.depth.total_cmp(&a.depth)); +} diff --git a/crates/effects/src/sim2/pixels.rs b/crates/effects/src/sim2/pixels.rs new file mode 100644 index 000000000..2cda428db --- /dev/null +++ b/crates/effects/src/sim2/pixels.rs @@ -0,0 +1,228 @@ +use super::pieces::{Cam, Piece, PiecePlan, PieceTex, rot_axis, sort_far_first}; +use super::shatter::{ShatterPlan, shatter_impl}; +use super::{Acc, Buf, EffectCtx, IntoParallelIterator, ParallelIterator, Post, Shape, Sprite, SpritePlan, h, hs, unpremul}; + +// ------------------------------------------------------------------ CC Ball Action + +pub(super) fn ball_action(ctx: &EffectCtx, b: Buf) -> Buf { + ball_action_plan(ctx, &b).finish(b) +} + +/// CC Ball Action's balls (far to near), coloured by the layer's pixels. +fn ball_action_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan { + let pr = ctx.params; + let scatter = pr.f("scatter") as f32; + let axis = pr.e("rotationAxis"); + let rot = pr.f("rotation").to_radians(); + let twist_prop = pr.e("twistProperty"); + let twist = pr.f("twistAngle").to_radians(); + let spacing = pr.f("gridSpacing").max(1.0) as f32; + let size = pr.f("ballSize") as f32 / 100.0; + let instab = pr.f("instabilityState").to_radians() as f32; + let seed = ctx.seed.wrapping_mul(0x2545f491); + let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); + let s = b.scale as f32; + let nx = (lw / spacing).ceil().max(1.0) as u32; + let ny = (lh / spacing).ceil().max(1.0) as u32; + let d = 2.0 * lw.max(lh) as f64; + let (cx, cy) = (lw as f64 * 0.5, lh as f64 * 0.5); + let src = &b.img; + let ax: [f64; 3] = match axis { + 0 => [1.0, 0.0, 0.0], + 1 => [0.0, 1.0, 0.0], + 2 => [0.0, 0.0, 1.0], + 3 => [1.0, 1.0, 0.0], + 4 => [1.0, 0.0, 1.0], + 5 => [0.0, 1.0, 1.0], + _ => [1.0, 1.0, 1.0], + }; + let maxr = ((lw * lw + lh * lh).sqrt() * 0.5).max(1.0); + let mut balls: Vec<(f64, Sprite)> = (0..nx * ny) + .into_par_iter() + .filter_map(|i| { + let gx = (i % nx) as f32 * spacing + spacing * 0.5; + let gy = (i / nx) as f32 * spacing + spacing * 0.5; + let (sx, sy) = b.to_px([gx as f64, gy as f64]); + let (c, a) = unpremul(src.sample_bilinear(sx, sy)); + if a <= 0.0 || size <= 0.0 { + return None; + } + let tp = match twist_prop { + 0 => gx / lw.max(1.0), + 1 => gy / lh.max(1.0), + 2 => ((gx - cx as f32).hypot(gy - cy as f32)) / maxr, + _ => crate::util::rgb3([c[0], c[1], c[2], 0.0]).iter().sum::() / 3.0, + } as f64; + let ang = rot + twist * tp; + // Scatter: random 3D offsets whose direction drifts with the instability state. + let ph = h(i, 1, seed) * std::f32::consts::TAU + instab; + let sc = scatter * h(i, 2, seed); + let off = [(sc * ph.cos()) as f64, (sc * ph.sin()) as f64, (scatter * hs(i, 3, seed)) as f64]; + let p0 = [gx as f64 - cx + off[0], gy as f64 - cy + off[1], off[2]]; + let m = rot_axis(ax, ang); + let q: [f64; 3] = std::array::from_fn(|k| m[k][0] * p0[0] + m[k][1] * p0[1] + m[k][2] * p0[2]); + if d + q[2] <= d * 0.05 { + return None; + } + let persp = d / (d + q[2]); + let (bx, by) = b.to_px([cx + q[0] * persp, cy + q[1] * persp]); + let r = spacing * 0.5 * size * persp as f32 * s; + Some((q[2], Sprite::new(bx as f32, by as f32, r, [c[0], c[1], c[2], a], Shape::Sphere))) + }) + .collect(); + balls.sort_by(|a, b| b.0.total_cmp(&a.0)); + let sprites: Vec = balls.into_iter().map(|(_, s)| s).collect(); + SpritePlan { sprites, tints: vec![], acc: Acc::Over, post: Post::Replace } +} + +// ------------------------------------------------------------------ CC Pixel Polly + +pub(super) fn pixel_polly(ctx: &EffectCtx, b: Buf) -> Buf { + match pixel_polly_plan(ctx, &b) { + Some(plan) => plan.finish(b), + None => b, + } +} + +/// CC Pixel Polly's pieces, `None` before Start Time (the layer as is). +fn pixel_polly_plan(ctx: &EffectCtx, b: &Buf) -> Option { + let pr = ctx.params; + let t = ctx.time - pr.f("startTime"); + if t <= 0.0 { + return None; + } + let force = pr.f("force"); + let gravity = pr.f("gravity"); + let spin = pr.f("spinning").to_radians(); + let fc = pr.v2("forceCenter"); + let dir_rand = pr.f("directionRandomness") / 100.0; + let spd_rand = pr.f("speedRandomness") / 100.0; + let g = pr.f("gridSpacing").max(1.0); + let object = pr.e("object"); + let depth_sort = pr.b("enableDepthSort"); + let seed = ctx.seed.wrapping_mul(0x632be5ab); + let [lw, lh] = ctx.layer_size; + let s = b.scale; + let cam = Cam { c: [b.offset[0] + lw * 0.5 * s, b.offset[1] + lh * 0.5 * s], d: 2.0 * lw.max(lh) * s }; + let nx = (lw / g).ceil().max(1.0) as u32; + let ny = (lh / g).ceil().max(1.0) as u32; + let tri = object <= 1; + let textured = object == 1 || object == 3; + let maxd = lw.max(lh); + let src = &b.img; + let per = if tri { 2 } else { 1 }; + let mut pieces: Vec = (0..nx * ny * per) + .into_par_iter() + .filter_map(|i| { + let cell = i / per; + let (x0, y0) = ((cell % nx) as f64 * g, (cell / nx) as f64 * g); + let poly: Vec<[f64; 2]> = if !tri { + vec![[x0, y0], [x0 + g, y0], [x0 + g, y0 + g], [x0, y0 + g]] + } else if i % 2 == 0 { + vec![[x0, y0], [x0 + g, y0], [x0, y0 + g]] + } else { + vec![[x0 + g, y0], [x0 + g, y0 + g], [x0, y0 + g]] + }; + let c = [poly.iter().map(|v| v[0]).sum::() / poly.len() as f64, poly.iter().map(|v| v[1]).sum::() / poly.len() as f64]; + let (dx, dy) = (c[0] - fc[0], c[1] - fc[1]); + let dist = (dx * dx + dy * dy).sqrt().max(1e-6); + let ang = dy.atan2(dx) + dir_rand * std::f64::consts::PI * hs(i, 1, seed) as f64; + let speed = force / 100.0 * 0.6 * maxd / (1.0 + dist / (0.5 * maxd)) * (1.0 + spd_rand * hs(i, 2, seed) as f64); + let vz = -speed * 0.5 * h(i, 3, seed) as f64; + let pos = [c[0] + ang.cos() * speed * t, c[1] + ang.sin() * speed * t + 0.5 * gravity * 0.5 * lh * t * t, vz * t]; + let axis = [hs(i, 4, seed) as f64, hs(i, 5, seed) as f64, hs(i, 6, seed) as f64]; + let r = rot_axis(axis, t * (std::f64::consts::PI * h(i, 7, seed) as f64 + spin)); + let tex: Vec<[f32; 2]> = poly.iter().map(|v| [(v[0] * s + b.offset[0]) as f32, (v[1] * s + b.offset[1]) as f32]).collect(); + let c0 = [(c[0] * s + b.offset[0]) as f32, (c[1] * s + b.offset[1]) as f32]; + let posb = [pos[0] * s + b.offset[0], pos[1] * s + b.offset[1], pos[2] * s]; + let mut pc = Piece::new(&tex, c0, &r, posb, cam)?; + pc.shade = 0.35 + 0.65 * Piece::facing(&r); + if !textured { + let (cc, a) = unpremul(src.sample_bilinear(c0[0] as f64, c0[1] as f64)); + if a <= 0.0 { + return None; + } + pc.flat = Some(cc); + pc.alpha = a; + } + Some(pc) + }) + .collect(); + if depth_sort { + sort_far_first(&mut pieces); + } + Some(PiecePlan { pieces, front: PieceTex::Layer, back: None }) +} + +// ------------------------------------------------------------------ CC Scatterize + +pub(super) fn scatterize(ctx: &EffectCtx, b: Buf) -> Buf { + match scatterize_plan(ctx, &b) { + Some(plan) => plan.finish(b), + None => b, + } +} + +/// CC Scatterize's pixel dots, `None` when nothing moves (the layer as is). +fn scatterize_plan(ctx: &EffectCtx, b: &Buf) -> Option { + let pr = ctx.params; + let scatter = pr.f("scatter") as f32; + let right = pr.f("rightTwist").to_radians(); + let left = pr.f("leftTwist").to_radians(); + if scatter == 0.0 && right == 0.0 && left == 0.0 { + return None; + } + let add = pr.e("transferMode") == 1; + let seed = ctx.seed.wrapping_mul(0x7feb352d); + let s = b.scale; + let (w, hh) = (b.img.width, b.img.height); + let (cx, cy) = (b.offset[0] + ctx.layer_size[0] * 0.5 * s, b.offset[1] + ctx.layer_size[1] * 0.5 * s); + let half = (ctx.layer_size[0] * 0.5 * s).max(1.0); + let d = 2.0 * ctx.layer_size[0].max(ctx.layer_size[1]) * s; + let src = &b.img; + let sprites: Vec = (0..w * hh) + .into_par_iter() + .filter_map(|i| { + let px = src.data[i as usize]; + if px[3] <= 0.0 { + return None; + } + let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5); + // Twist about the horizontal axis through the centre, growing towards each side. + let k = ((x - cx) / half).abs().min(1.0); + let ang = if x < cx { left } else { right } * k; + let (sa, ca) = ang.sin_cos(); + let (yy, zz) = ((y - cy) * ca, (y - cy) * sa); + let persp = d / (d + zz).max(d * 0.05); + let jx = hs(i, 1, seed) * scatter * s as f32; + let jy = hs(i, 2, seed) * scatter * s as f32; + let sx = cx + (x - cx) * persp + jx as f64; + let sy = cy + yy * persp + jy as f64; + let (c, a) = unpremul(px); + Some(Sprite::new(sx as f32, sy as f32, 0.6 * persp as f32, [c[0], c[1], c[2], a], Shape::Disc)) + }) + .collect(); + Some(SpritePlan { sprites, tints: vec![], acc: if add { Acc::Add } else { Acc::Over }, post: Post::Replace }) +} + +/// What a particle effect that reads the layer's pixels draws this frame (CC Ball Action, CC +/// Pixel Polly, CC Scatterize, Shatter's wireframe views), for layer `b`; `None` for other +/// effects or a frame showing the layer as is. +pub enum PixelPlan { + Sprites(SpritePlan), + Pieces(PiecePlan), +} + +/// The [`PixelPlan`] of effect `id` for layer `b` (its pixels are read). +pub fn pixel_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option { + match id { + "ec.sim.ccballaction" => Some(PixelPlan::Sprites(ball_action_plan(ctx, b))), + "ec.sim.ccpixelpolly" => pixel_polly_plan(ctx, b).map(PixelPlan::Pieces), + "ec.sim.ccscatterize" => scatterize_plan(ctx, b).map(PixelPlan::Sprites), + "ec.sim.shatter" => Some(match shatter_impl(ctx, b) { + ShatterPlan::Pieces(plan) => PixelPlan::Pieces(plan), + ShatterPlan::Wire(plan) => PixelPlan::Sprites(plan), + }), + _ => None, + } +} diff --git a/crates/effects/src/sim2/registry.rs b/crates/effects/src/sim2/registry.rs new file mode 100644 index 000000000..333916a16 --- /dev/null +++ b/crates/effects/src/sim2/registry.rs @@ -0,0 +1,288 @@ +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::{EffectSpec, ParamUi, Value, col, num, p, pct, popup, pt, slider, spec}; + +// ------------------------------------------------------------------ specs + +fn producer_params(k: u32, amp: f64, pos: (f64, f64)) -> Vec { + let ids: [&'static str; 8] = match k { + 1 => [ + "producer1/producer1Type", + "producer1/producer1Position", + "producer1/producer1Length", + "producer1/producer1Width", + "producer1/producer1Angle", + "producer1/producer1Amplitude", + "producer1/producer1Frequency", + "producer1/producer1Phase", + ], + _ => [ + "producer2/producer2Type", + "producer2/producer2Position", + "producer2/producer2Length", + "producer2/producer2Width", + "producer2/producer2Angle", + "producer2/producer2Amplitude", + "producer2/producer2Frequency", + "producer2/producer2Phase", + ], + }; + vec![ + p(ids[0], "Type", Value::Enum(0), popup(&["Ring", "Line"])), + p(ids[1], "Position", pt(pos.0, pos.1), ParamUi::Point), + p(ids[2], "Height/Length", num(0.1), slider(0.0, 2.0, 0.0, 1.0, 3)), + p(ids[3], "Width", num(0.1), slider(0.0, 2.0, 0.0, 1.0, 3)), + p(ids[4], "Angle", num(0.0), ParamUi::Angle), + p(ids[5], "Amplitude", num(amp), slider(-5.0, 5.0, -1.0, 1.0, 3)), + p(ids[6], "Frequency", num(1.0), slider(0.0, 30.0, 0.0, 5.0, 3)), + p(ids[7], "Phase", num(0.0), ParamUi::Angle), + ] +} + +fn card_dance_params() -> Vec { + let mut v = vec![ + p("rowsColumns", "Rows & Columns", Value::Enum(0), popup(&["Independent", "Columns Follows Rows"])), + p("rows", "Rows", num(10.0), slider(1.0, 1000.0, 1.0, 100.0, 0)), + p("columns", "Columns", num(10.0), slider(1.0, 1000.0, 1.0, 100.0, 0)), + p("backLayer", "Back Layer", Value::Layer(None), ParamUi::Layer), + p("gradientLayer1", "Gradient Layer 1", Value::Layer(None), ParamUi::Layer), + p("gradientLayer2", "Gradient Layer 2", Value::Layer(None), ParamUi::Layer), + ]; + let names: [[&'static str; 4]; 8] = [ + ["xPosition/xPosSource", "xPosition/xPosMultiplier", "xPosition/xPosOffset", "X Position"], + ["yPosition/yPosSource", "yPosition/yPosMultiplier", "yPosition/yPosOffset", "Y Position"], + ["zPosition/zPosSource", "zPosition/zPosMultiplier", "zPosition/zPosOffset", "Z Position"], + ["xRotation/xRotSource", "xRotation/xRotMultiplier", "xRotation/xRotOffset", "X Rotation"], + ["yRotation/yRotSource", "yRotation/yRotMultiplier", "yRotation/yRotOffset", "Y Rotation"], + ["zRotation/zRotSource", "zRotation/zRotMultiplier", "zRotation/zRotOffset", "Z Rotation"], + ["xScale/xScaleSource", "xScale/xScaleMultiplier", "xScale/xScaleOffset", "X Scale"], + ["yScale/yScaleSource", "yScale/yScaleMultiplier", "yScale/yScaleOffset", "Y Scale"], + ]; + debug_assert_eq!(names.len(), CD_PROPS.len()); + for (i, n) in names.iter().enumerate() { + let rot = (3..6).contains(&i); + let scale = i >= 6; + let (lo, hi) = if rot { (-3600.0, 3600.0) } else { (-100.0, 100.0) }; + v.push(p(n[0], "Source", Value::Enum(0), popup(CD_SOURCES))); + v.push(p(n[1], "Multiplier", num(0.0), slider(lo, hi, if rot { -360.0 } else { -10.0 }, if rot { 360.0 } else { 10.0 }, 2))); + v.push(p(n[2], "Offset", num(if scale { 1.0 } else { 0.0 }), slider(lo, hi, if rot { -360.0 } else { -10.0 }, if rot { 360.0 } else { 10.0 }, 2))); + } + v.extend(crate::card3d::camera_params(2.0)); + v.extend(crate::card3d::lighting_params(1.0, 0.25)); + v.extend(crate::card3d::material_params(0.75)); + v +} + +pub fn specs() -> Vec { + vec![ + spec( + "ec.sim.ccballaction", + "CC Ball Action", + vec![ + p("scatter", "Scatter", num(0.0), slider(0.0, 2000.0, 0.0, 300.0, 1)), + p("rotationAxis", "Rotation Axis", Value::Enum(0), popup(&["X Axis", "Y Axis", "Z Axis", "XY Axis", "XZ Axis", "YZ Axis", "XYZ Axis"])), + p("rotation", "Rotation", num(0.0), ParamUi::Angle), + p("twistProperty", "Twist Property", Value::Enum(0), popup(&["X Axis", "Y Axis", "Radius", "Brightness"])), + p("twistAngle", "Twist Angle", num(0.0), ParamUi::Angle), + p("gridSpacing", "Grid Spacing", num(4.0), slider(1.0, 200.0, 1.0, 50.0, 0)), + p("ballSize", "Ball Size", num(40.0), slider(0.0, 400.0, 0.0, 200.0, 1)), + p("instabilityState", "Instability State", num(0.0), ParamUi::Angle), + ], + ball_action, + ), + spec( + "ec.sim.ccpixelpolly", + "CC Pixel Polly", + vec![ + p("force", "Force", num(100.0), slider(-1000.0, 1000.0, -300.0, 300.0, 1)), + p("gravity", "Gravity", num(1.0), slider(-20.0, 20.0, -5.0, 5.0, 2)), + p("spinning", "Spinning", num(0.0), ParamUi::Angle), + p("forceCenter", "Force Center", pt(0.5, 0.5), ParamUi::Point), + p("directionRandomness", "Direction Randomness", num(10.0), pct()), + p("speedRandomness", "Speed Randomness", num(10.0), pct()), + p("gridSpacing", "Grid Spacing", num(10.0), slider(1.0, 500.0, 2.0, 100.0, 0)), + p("object", "Object", Value::Enum(0), popup(&["Polygon", "Textured Polygon", "Square", "Textured Square"])), + p("enableDepthSort", "Enable Depth Sort", Value::Bool(true), ParamUi::Checkbox), + p("startTime", "Start Time (sec)", num(0.0), slider(-1000.0, 1000.0, 0.0, 10.0, 2)), + ], + pixel_polly, + ), + spec( + "ec.sim.ccscatterize", + "CC Scatterize", + vec![ + p("scatter", "Scatter", num(0.0), slider(0.0, 1000.0, 0.0, 200.0, 1)), + p("rightTwist", "Right Twist", num(0.0), ParamUi::Angle), + p("leftTwist", "Left Twist", num(0.0), ParamUi::Angle), + p("transferMode", "Transfer Mode", Value::Enum(0), popup(&["Composite", "Add"])), + ], + scatterize, + ), + spec("ec.sim.carddance", "Card Dance", card_dance_params(), card_dance), + spec( + "ec.sim.caustics", + "Caustics", + vec![ + p("bottom/bottom", "Bottom", Value::Layer(None), ParamUi::Layer), + p("bottom/scaling", "Scaling", num(1.0), slider(0.01, 10.0, 0.1, 3.0, 3)), + p("bottom/repeatMode", "Repeat Mode", Value::Enum(2), popup(&["Once", "Tiled", "Reflected"])), + p("bottom/bottomSizeDiffers", "If Layer Size Differs", Value::Enum(1), popup(&["Center", "Stretch to Fit"])), + p("bottom/blur", "Blur", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("water/waterSurface", "Water Surface", Value::Layer(None), ParamUi::Layer), + p("water/waveHeight", "Wave Height", num(0.2), slider(-1.0, 1.0, -1.0, 1.0, 3)), + p("water/smoothing", "Smoothing", num(5.0), slider(0.0, 100.0, 0.0, 50.0, 1)), + p("water/waterDepth", "Water Depth", num(0.1), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("water/refractiveIndex", "Refractive Index", num(1.2), slider(1.0, 3.0, 1.0, 2.0, 3)), + p("water/surfaceColor", "Surface Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("water/surfaceOpacity", "Surface Opacity", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("water/causticsStrength", "Caustics Strength", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("sky/sky", "Sky", Value::Layer(None), ParamUi::Layer), + p("sky/scaling", "Scaling", num(1.0), slider(0.01, 10.0, 0.1, 3.0, 3)), + p("sky/repeatMode", "Repeat Mode", Value::Enum(2), popup(&["Once", "Tiled", "Reflected"])), + p("sky/skySizeDiffers", "If Layer Size Differs", Value::Enum(1), popup(&["Center", "Stretch to Fit"])), + p("sky/intensity", "Intensity", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("sky/convergence", "Convergence", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("lighting/lightType", "Light Type", Value::Enum(0), popup(&crate::card3d::LIGHT_TYPES)), + p("lighting/lightIntensity", "Light Intensity", num(1.0), slider(0.0, 4.0, 0.0, 2.0, 2)), + p("lighting/lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("lighting/lightPosition", "Light Position", pt(0.0, 0.0), ParamUi::Point), + p("lighting/lightHeight", "Light Height", num(1.0), slider(0.0, 10.0, 0.0, 4.0, 2)), + p("lighting/ambientLight", "Ambient Light", num(0.35), slider(0.0, 2.0, 0.0, 1.0, 2)), + p("material/diffuse", "Diffuse Reflection", num(0.75), slider(0.0, 2.0, 0.0, 1.0, 2)), + p("material/specular", "Specular Reflection", num(0.2), slider(0.0, 2.0, 0.0, 1.0, 2)), + p("material/highlightSharpness", "Highlight Sharpness", num(15.0), slider(1.0, 100.0, 1.0, 100.0, 1)), + ], + caustics, + ), + spec( + "ec.sim.waveworld", + "Wave World", + { + let mut v = vec![ + p("view", "View", Value::Enum(0), popup(&["Wireframe Preview", "Height Map"])), + p("wireframeControls/horizontalRotation", "Horizontal Rotation", num(0.0), ParamUi::Angle), + p("wireframeControls/verticalRotation", "Vertical Rotation", num(53.0), slider(0.0, 90.0, 0.0, 90.0, 1)), + p("wireframeControls/verticalScale", "Vertical Scale", num(0.5), slider(0.0, 10.0, 0.0, 2.0, 3)), + p("heightMapControls/brightness", "Brightness", num(0.5), slider(-1.0, 2.0, 0.0, 1.0, 3)), + p("heightMapControls/contrast", "Contrast", num(0.75), slider(0.0, 4.0, 0.0, 2.0, 3)), + p("heightMapControls/gamma", "Gamma Adjustment", num(1.0), slider(0.01, 10.0, 0.2, 5.0, 3)), + p("heightMapControls/renderDryAreasAs", "Render Dry Areas As", Value::Enum(0), popup(&["Solid", "Transparent"])), + p("heightMapControls/transparency", "Transparency", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("simulation/gridResolution", "Grid Resolution", num(60.0), slider(1.0, 400.0, 1.0, 200.0, 0)), + p("simulation/gridResDownsamples", "Grid Res Downsamples", Value::Bool(false), ParamUi::Checkbox), + p("simulation/waveSpeed", "Wave Speed", num(0.2), slider(0.0, 5.0, 0.0, 1.0, 3)), + p("simulation/damping", "Damping", num(0.05), slider(0.0, 5.0, 0.0, 1.0, 3)), + p("simulation/reflectEdges", "Reflect Edges", Value::Enum(0), popup(&["None", "Left", "Top", "Right", "Bottom", "All"])), + p("simulation/preRoll", "Pre-roll (seconds)", num(0.0), slider(0.0, 30.0, 0.0, 10.0, 2)), + p("ground/ground", "Ground", Value::Layer(None), ParamUi::Layer), + p("ground/steepness", "Steepness", num(0.25), slider(0.0, 2.0, 0.0, 1.0, 3)), + p("ground/height", "Height", num(0.25), slider(0.0, 2.0, 0.0, 1.0, 3)), + p("ground/waveStrength", "Wave Strength", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), + ]; + v.extend(producer_params(1, 0.5, (0.5, 0.5))); + v.extend(producer_params(2, 0.0, (0.25, 0.25))); + v + }, + wave_world, + ), + spec( + "ec.sim.shatter", + "Shatter", + vec![ + p( + "view", + "View", + Value::Enum(1), + popup(&["Rendered", "Wireframe Front View", "Wireframe", "Wireframe Front View + Forces", "Wireframe + Forces"]), + ), + p("render", "Render", Value::Enum(0), popup(&["All", "Layer", "Pieces"])), + p("shape/pattern", "Pattern", Value::Enum(0), popup(SHATTER_PATTERNS)), + p("shape/repetitions", "Repetitions", num(10.0), slider(1.0, 500.0, 1.0, 100.0, 2)), + p("shape/direction", "Direction", num(0.0), ParamUi::Angle), + p("shape/origin", "Origin", pt(0.5, 0.5), ParamUi::Point), + p("shape/customShatterMap", "Custom Shatter Map", Value::Layer(None), ParamUi::Layer), + p("shape/whiteTilesFixed", "White Tiles Fixed", Value::Bool(false), ParamUi::Checkbox), + p("shape/extrusionDepth", "Extrusion Depth", num(0.05), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("force1/force1Position", "Position", pt(0.5, 0.5), ParamUi::Point), + p("force1/force1Depth", "Depth", num(0.1), slider(-1.0, 1.0, -1.0, 1.0, 3)), + p("force1/force1Radius", "Radius", num(0.4), slider(0.0, 4.0, 0.0, 2.0, 3)), + p("force1/force1Strength", "Strength", num(5.0), slider(-20.0, 20.0, -10.0, 10.0, 2)), + p("force2/force2Position", "Position", pt(0.25, 0.25), ParamUi::Point), + p("force2/force2Depth", "Depth", num(0.1), slider(-1.0, 1.0, -1.0, 1.0, 3)), + p("force2/force2Radius", "Radius", num(0.0), slider(0.0, 4.0, 0.0, 2.0, 3)), + p("force2/force2Strength", "Strength", num(5.0), slider(-20.0, 20.0, -10.0, 10.0, 2)), + p("physics/rotationSpeed", "Rotation Speed", num(0.2), slider(0.0, 5.0, 0.0, 1.0, 3)), + p("physics/tumbleAxis", "Tumble Axis", Value::Enum(0), popup(&["Free", "None", "X", "Y", "Z", "XY", "XZ", "YZ"])), + p("physics/randomness", "Randomness", num(0.1), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("physics/viscosity", "Viscosity", num(0.1), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("physics/massVariance", "Mass Variance", num(30.0), pct()), + p("physics/gravity", "Gravity", num(3.0), slider(0.0, 100.0, 0.0, 10.0, 2)), + p("physics/gravityDirection", "Gravity Direction", num(180.0), ParamUi::Angle), + p("physics/gravityInclination", "Gravity Inclination", num(0.0), slider(-90.0, 90.0, -90.0, 90.0, 1)), + p("gradient/shatterThreshold", "Shatter Threshold", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("gradient/gradientLayer", "Gradient Layer", Value::Layer(None), ParamUi::Layer), + p("gradient/invertGradient", "Invert Gradient", Value::Bool(false), ParamUi::Checkbox), + p("textures/color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("textures/opacity", "Opacity", num(1.0), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("textures/frontMode", "Front Mode", Value::Enum(1), popup(&SHATTER_TEXTURE_MODES)), + p("textures/sideMode", "Side Mode", Value::Enum(0), popup(&SHATTER_TEXTURE_MODES)), + p("textures/backMode", "Back Mode", Value::Enum(1), popup(&SHATTER_TEXTURE_MODES)), + p("textures/frontLayer", "Front Layer", Value::Layer(None), ParamUi::Layer), + p("textures/sideLayer", "Side Layer", Value::Layer(None), ParamUi::Layer), + p("textures/backLayer", "Back Layer", Value::Layer(None), ParamUi::Layer), + ] + .into_iter() + .chain(crate::card3d::camera_params(4.0)) + .chain(crate::card3d::lighting_params(1.0, 0.25)) + .chain(crate::card3d::material_params(0.75)) + .chain([p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0))]) + .collect(), + shatter, + ), + spec( + "ec.sim.foam", + "Foam", + vec![ + p("view", "View", Value::Enum(0), popup(&["Draft", "Draft + Flow Map", "Rendered"])), + p("producer/producerPoint", "Producer Point", pt(0.5, 0.5), ParamUi::Point), + p("producer/producerXSize", "Producer X Size", num(0.05), slider(0.0, 2.0, 0.0, 1.0, 3)), + p("producer/producerYSize", "Producer Y Size", num(0.05), slider(0.0, 2.0, 0.0, 1.0, 3)), + p("producer/producerOrientation", "Producer Orientation", num(0.0), ParamUi::Angle), + p("producer/zoomProducerPoint", "Zoom Producer Point", Value::Bool(false), ParamUi::Checkbox), + p("producer/productionRate", "Production Rate", num(1.0), slider(0.0, 50.0, 0.0, 10.0, 3)), + p("bubbles/size", "Size", num(0.5), slider(0.0, 10.0, 0.0, 2.0, 3)), + p("bubbles/sizeVariance", "Size Variance", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("bubbles/lifespan", "Lifespan", num(300.0), slider(1.0, 30_000.0, 1.0, 1000.0, 1)), + p("bubbles/growthSpeed", "Bubble Growth Speed", num(0.1), slider(0.0001, 10.0, 0.0001, 1.0, 3)), + p("bubbles/strength", "Strength", num(10.0), slider(0.0, 100.0, 0.0, 20.0, 2)), + p("physics/initialSpeed", "Initial Speed", num(0.0), slider(-100.0, 100.0, -10.0, 10.0, 3)), + p("physics/initialDirection", "Initial Direction", num(0.0), ParamUi::Angle), + p("physics/windSpeed", "Wind Speed", num(0.5), slider(-100.0, 100.0, -10.0, 10.0, 3)), + p("physics/windDirection", "Wind Direction", num(90.0), ParamUi::Angle), + p("physics/turbulence", "Turbulence", num(0.5), slider(0.0, 10.0, 0.0, 2.0, 3)), + p("physics/wobbleAmount", "Wobble Amount", num(0.05), slider(0.0, 10.0, 0.0, 2.0, 3)), + p("physics/repulsion", "Repulsion", num(1.0), slider(0.0, 10.0, 0.0, 2.0, 3)), + p("physics/popVelocity", "Pop Velocity", num(1.0), slider(-100.0, 100.0, -10.0, 10.0, 3)), + p("physics/viscosity", "Viscosity", num(0.1), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("physics/stickiness", "Stickiness", num(0.75), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("zoom", "Zoom", num(1.0), slider(0.01, 50.0, 0.1, 5.0, 3)), + p("universeSize", "Universe Size", num(1.0), slider(0.01, 10.0, 0.5, 3.0, 3)), + p("rendering/blendMode", "Blend Mode", Value::Enum(0), popup(&["Transparent", "Solid Old on Top", "Solid New on Top"])), + p("rendering/bubbleTexture", "Bubble Texture", Value::Enum(0), popup(&FOAM_TEXTURES)), + p("rendering/bubbleTextureLayer", "Bubble Texture Layer", Value::Layer(None), ParamUi::Layer), + p("rendering/bubbleOrientation", "Bubble Orientation", Value::Enum(0), popup(&["Fixed", "Physical Orientation", "Bubble Velocity"])), + p("rendering/environmentMap", "Environment Map", Value::Layer(None), ParamUi::Layer), + p("rendering/reflectionStrength", "Reflection Strength", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("rendering/reflectionConvergence", "Reflection Convergence", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("flowMap/flowMap", "Flow Map", Value::Layer(None), ParamUi::Layer), + p("flowMap/flowMapSteepness", "Flow Map Steepness", num(0.05), slider(-100.0, 100.0, -1.0, 1.0, 3)), + p("flowMap/flowMapFits", "Flow Map Fits", Value::Enum(0), popup(&["Background", "Universe"])), + p("flowMap/simulationQuality", "Simulation Quality", Value::Enum(0), popup(&["Normal", "High", "Intense"])), + p("randomSeed", "Random Seed", num(1.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)), + ], + foam, + ), + ] +} diff --git a/crates/effects/src/sim2/shatter.rs b/crates/effects/src/sim2/shatter.rs new file mode 100644 index 000000000..844fbb4b3 --- /dev/null +++ b/crates/effects/src/sim2/shatter.rs @@ -0,0 +1,484 @@ +use super::pieces::{M3, Piece, PiecePlan, PieceTex, inv3, rot_axis, rot_xyz, sort_far_first}; +use super::{ + Acc, Buf, EffectCtx, Image, IndexedParallelIterator, IntoParallelIterator, IntoParallelRefIterator, Lighting, ParallelIterator, Post, Shape, Sprite, + SpritePlan, Value, h, hs, layer_or_self, unpremul, +}; + +// ------------------------------------------------------------------ Card Dance + +pub(super) const CD_SOURCES: &[&str] = + &["None", "Intensity 1", "Red 1", "Green 1", "Blue 1", "Alpha 1", "Intensity 2", "Red 2", "Green 2", "Blue 2", "Alpha 2"]; + +/// (param id prefix, twirl-down group match id) per transformed card property. +pub(super) const CD_PROPS: [(&str, &str); 8] = [ + ("xPos", "xPosition"), + ("yPos", "yPosition"), + ("zPos", "zPosition"), + ("xRot", "xRotation"), + ("yRot", "yRotation"), + ("zRot", "zRotation"), + ("xScale", "xScale"), + ("yScale", "yScale"), +]; + +fn card_source(src: u32, g1: [f32; 4], g2: [f32; 4]) -> f32 { + let (c1, a1) = unpremul(g1); + let (c2, a2) = unpremul(g2); + let lum = |c: [f32; 3]| effectcraft_color::luminance(c[0], c[1], c[2]); + match src { + 1 => lum(c1), + 2 => c1[0], + 3 => c1[1], + 4 => c1[2], + 5 => a1, + 6 => lum(c2), + 7 => c2[0], + 8 => c2[1], + 9 => c2[2], + 10 => a2, + _ => 0.0, + } +} + +pub(super) fn card_dance(ctx: &EffectCtx, b: Buf) -> Buf { + card_dance_plan(ctx, &b).finish(b) +} + +pub(super) fn card_dance_plan(ctx: &EffectCtx, b: &Buf) -> PiecePlan { + let pr = ctx.params; + let rows = pr.f("rows").clamp(1.0, 1000.0) as u32; + let cols = if pr.e("rowsColumns") == 1 { rows } else { pr.f("columns").clamp(1.0, 1000.0) as u32 }; + let g1 = layer_or_self(ctx, b, "gradientLayer1", true, true); + let g2 = layer_or_self(ctx, b, "gradientLayer2", true, true); + let back = ctx.layer_param("backLayer", true).map(|o| crate::util::fit_layer(ctx, b, &o, true)); + let [lw, lh] = ctx.layer_size; + let s = b.scale; + let (cw, ch) = (lw / cols as f64, lh / rows as f64); + let cam = crate::card3d::projection(ctx, b); + let eye = cam.eye(); + let light = Lighting::from(ctx, b); + let props: Vec<(u32, f64, f64)> = + CD_PROPS.iter().map(|(id, g)| (pr.e(&format!("{g}/{id}Source")), pr.f(&format!("{g}/{id}Multiplier")), pr.f(&format!("{g}/{id}Offset")))).collect(); + let mut pieces: Vec = (0..rows * cols) + .into_par_iter() + .filter_map(|i| { + let (cx0, cy0) = ((i % cols) as f64 * cw, (i / cols) as f64 * ch); + let c = [cx0 + cw * 0.5, cy0 + ch * 0.5]; + let (bx, by) = (c[0] * s + b.offset[0], c[1] * s + b.offset[1]); + let (gp1, gp2) = (g1.sample_bilinear(bx, by), g2.sample_bilinear(bx, by)); + let val = |k: usize| { + let (sr, m, o) = props[k]; + o + m * card_source(sr, gp1, gp2) as f64 + }; + let (sx, sy) = (val(6), val(7)); + let r = rot_xyz(val(3).to_radians(), val(4).to_radians(), val(5).to_radians()); + let rs: M3 = std::array::from_fn(|k| [r[k][0] * sx, r[k][1] * sy, r[k][2]]); + let pos = [bx + val(0) * cw * s, by + val(1) * ch * s, val(2) * cw.max(ch) * s]; + let (tx0, ty0) = ((cx0 * s + b.offset[0]) as f32, (cy0 * s + b.offset[1]) as f32); + let (tw, th) = ((cw * s) as f32, (ch * s) as f32); + let poly = [[tx0, ty0], [tx0 + tw, ty0], [tx0 + tw, ty0 + th], [tx0, ty0 + th]]; + let mut pc = Piece::new(&poly, [bx as f32, by as f32], &rs, pos, cam)?; + pc.light(&light, &r, pos, eye); + Some(pc) + }) + .collect(); + sort_far_first(&mut pieces); + PiecePlan { pieces, front: PieceTex::Layer, back: back.map(PieceTex::Image) } +} + +// ------------------------------------------------------------------ Shatter + +/// Shatter's Pattern options; the last, Custom, takes its pieces from Custom Shatter Map. +pub(super) const SHATTER_PATTERNS: &[&str] = &["Bricks", "Glass", "Hexagons", "Squares", "Triangles", "Custom"]; + +/// Pattern cells (polygons in pattern space, before rotation) covering `[x0, x1]×[y0, y1]`. +fn pattern_cells(kind: u32, cell: f64, x0: f64, y0: f64, x1: f64, y1: f64, seed: u32) -> Vec> { + let mut out = Vec::new(); + let i0 = (x0 / cell).floor() as i64 - 1; + let i1 = (x1 / cell).ceil() as i64 + 1; + let j0 = (y0 / cell).floor() as i64 - 1; + let j1 = (y1 / cell).ceil() as i64 + 1; + match kind { + 0 => { + let bh = cell * 0.5; + let r0 = (y0 / bh).floor() as i64 - 1; + let r1 = (y1 / bh).ceil() as i64 + 1; + for r in r0..=r1 { + let sh = if r.rem_euclid(2) == 1 { cell * 0.5 } else { 0.0 }; + for i in i0..=i1 { + let (x, y) = (i as f64 * cell + sh, r as f64 * bh); + out.push(vec![[x, y], [x + cell, y], [x + cell, y + bh], [x, y + bh]]); + } + } + } + 1 => { + // Jittered lattice split into triangles along a random diagonal. + let jit = |i: i64, j: i64| -> [f64; 2] { + let (a, b) = (i as u32, j as u32); + [i as f64 * cell + hs(a, b.wrapping_add(17), seed) as f64 * cell * 0.35, j as f64 * cell + hs(a, b.wrapping_add(91), seed) as f64 * cell * 0.35] + }; + for j in j0..=j1 { + for i in i0..=i1 { + let (p00, p10, p11, p01) = (jit(i, j), jit(i + 1, j), jit(i + 1, j + 1), jit(i, j + 1)); + if h(i as u32, j as u32, seed ^ 0x55) < 0.5 { + out.push(vec![p00, p10, p11]); + out.push(vec![p00, p11, p01]); + } else { + out.push(vec![p00, p10, p01]); + out.push(vec![p10, p11, p01]); + } + } + } + } + 2 => { + let r = cell / 3f64.sqrt(); + let dx = 1.5 * r; + let dy = 3f64.sqrt() * r; + let c0 = (x0 / dx).floor() as i64 - 1; + let c1 = (x1 / dx).ceil() as i64 + 1; + let rr0 = (y0 / dy).floor() as i64 - 1; + let rr1 = (y1 / dy).ceil() as i64 + 1; + for ci in c0..=c1 { + for rj in rr0..=rr1 { + let cx = ci as f64 * dx; + let cy = (rj as f64 + if ci.rem_euclid(2) == 1 { 0.5 } else { 0.0 }) * dy; + out.push( + (0..6) + .map(|k| { + let a = k as f64 * std::f64::consts::PI / 3.0; + [cx + r * a.cos(), cy + r * a.sin()] + }) + .collect(), + ); + } + } + } + 3 => { + for j in j0..=j1 { + for i in i0..=i1 { + let (x, y) = (i as f64 * cell, j as f64 * cell); + out.push(vec![[x, y], [x + cell, y], [x + cell, y + cell], [x, y + cell]]); + } + } + } + _ => { + for j in j0..=j1 { + for i in i0..=i1 { + let (x, y) = (i as f64 * cell, j as f64 * cell); + if (i + j).rem_euclid(2) == 0 { + out.push(vec![[x, y], [x + cell, y], [x, y + cell]]); + out.push(vec![[x + cell, y], [x + cell, y + cell], [x, y + cell]]); + } else { + out.push(vec![[x, y], [x + cell, y], [x + cell, y + cell]]); + out.push(vec![[x, y], [x + cell, y + cell], [x, y + cell]]); + } + } + } + } + } + out +} + +/// `∫0^t e^{-ks} ds` and `∫0^t ∫0^u e^{-ks} ds du` (drag-damped ballistic factors). +fn drag_factors(k: f64, t: f64) -> (f64, f64) { + if k < 1e-6 { + (t, 0.5 * t * t) + } else { + let e = (-k * t).exp(); + ((1.0 - e) / k, t / k - (1.0 - e) / (k * k)) + } +} + +/// Shatter's Front / Side / Back Mode options. +pub(super) const SHATTER_TEXTURE_MODES: [&str; 3] = ["Color", "Layer", "Tinted Layer"]; + +/// One face's texture: a flat colour, a layer, or a layer tinted by the Textures colour. +struct Face { + mode: u32, + img: Option, +} + +impl Face { + fn from(ctx: &EffectCtx, b: &Buf, mode: &str, layer: &str, default_mode: u32) -> Face { + let mode = ctx.params.get(mode).map(|v| v.as_enum()).unwrap_or(default_mode); + // A face layer of None is the effect's own layer. + let img = (mode != 0).then(|| layer_or_self(ctx, b, layer, true, true)); + Face { mode, img } + } +} + +/// Group key of a Custom Shatter Map colour (pieces of one colour break as one). +fn map_key(c: [f32; 4]) -> u32 { + let (c, a) = unpremul(c); + if a < 0.5 { + return u32::MAX; + } + let q = |v: f32| ((v.clamp(0.0, 1.0) * 15.0).round() as u32) & 15; + q(c[0]) << 8 | q(c[1]) << 4 | q(c[2]) +} + +pub(super) fn shatter(ctx: &EffectCtx, b: Buf) -> Buf { + match shatter_impl(ctx, &b) { + ShatterPlan::Pieces(plan) => plan.finish(b), + ShatterPlan::Wire(plan) => plan.finish(b), + } +} + +/// What Shatter draws: the pieces (Rendered view) or the wireframe lines. +pub(super) enum ShatterPlan { + Pieces(PiecePlan), + Wire(SpritePlan), +} + +pub(super) fn shatter_impl(ctx: &EffectCtx, b: &Buf) -> ShatterPlan { + let pr = ctx.params; + let view = pr.e("view"); + let render = pr.e("render"); + let kind = pr.e("shape/pattern"); + let custom = kind as usize == SHATTER_PATTERNS.len() - 1; + let reps = pr.f("shape/repetitions").clamp(1.0, 500.0); + let dir = pr.f("shape/direction").to_radians(); + let origin = pr.v2("shape/origin"); + let forces = [ + (pr.v2("force1/force1Position"), pr.f("force1/force1Depth"), pr.f("force1/force1Radius"), pr.f("force1/force1Strength")), + (pr.v2("force2/force2Position"), pr.f("force2/force2Depth"), pr.f("force2/force2Radius"), pr.f("force2/force2Strength")), + ]; + let rot_speed = pr.f("physics/rotationSpeed"); + let tumble = pr.e("physics/tumbleAxis"); + let randomness = pr.f("physics/randomness"); + let k = pr.f("physics/viscosity").max(0.0) * 5.0; + let mass_var = pr.f("physics/massVariance") / 100.0; + let gravity = pr.f("physics/gravity"); + let gdir = pr.f("physics/gravityDirection").to_radians(); + let ginc = pr.f("physics/gravityInclination").to_radians(); + let seed = (pr.f("randomSeed") as u32).wrapping_mul(0x9e3779b1) ^ ctx.seed; + let [lw, lh] = ctx.layer_size; + let s = b.scale; + let t = ctx.time.max(0.0); + let unit = lw * 0.1; + let cell = lw / reps; + let extrude = pr.f("shape/extrusionDepth").max(0.0) * cell * s; + let cam = crate::card3d::projection(ctx, b); + let eye = cam.eye(); + let light = Lighting::from(ctx, b); + // Gradient: with a gradient layer only pieces at least as bright as 1 − Shatter Threshold + // (Invert Gradient: as dark) break. + let gradient = ctx.layer_param("gradient/gradientLayer", true).map(|o| crate::util::fit_layer(ctx, b, &o, true)); + let threshold = pr.get("gradient/shatterThreshold").map(Value::as_f64).unwrap_or(0.0) / 100.0; + let invert_gradient = pr.b("gradient/invertGradient"); + // Textures. + let tex_color = pr.get("textures/color").map(|v| v.as_color()).unwrap_or([1.0; 4]); + let tex_opacity = pr.get("textures/opacity").map(Value::as_f64).unwrap_or(1.0).clamp(0.0, 1.0) as f32; + let front = Face::from(ctx, b, "textures/frontMode", "textures/frontLayer", 1); + let side = Face::from(ctx, b, "textures/sideMode", "textures/sideLayer", 0); + let backf = Face::from(ctx, b, "textures/backMode", "textures/backLayer", 1); + let custom_map = custom.then(|| layer_or_self(ctx, b, "shape/customShatterMap", true, true)); + let white_fixed = pr.b("shape/whiteTilesFixed"); + let (sd, cd) = dir.sin_cos(); + let to_layer = |q: [f64; 2]| [origin[0] + q[0] * cd - q[1] * sd, origin[1] + q[0] * sd + q[1] * cd]; + // Pattern-space bounds of the layer rectangle. + let corners = [[0.0, 0.0], [lw, 0.0], [lw, lh], [0.0, lh]].map(|c: [f64; 2]| { + let (dx, dy) = (c[0] - origin[0], c[1] - origin[1]); + [dx * cd + dy * sd, -dx * sd + dy * cd] + }); + let (px0, px1) = (corners.iter().map(|c| c[0]).fold(f64::INFINITY, f64::min), corners.iter().map(|c| c[0]).fold(f64::NEG_INFINITY, f64::max)); + let (py0, py1) = (corners.iter().map(|c| c[1]).fold(f64::INFINITY, f64::min), corners.iter().map(|c| c[1]).fold(f64::NEG_INFINITY, f64::max)); + // Custom Shatter Map: square cells grouped by the map's colour. + let cells = pattern_cells(if custom { 3 } else { kind }, cell, px0, py0, px1, py1, seed); + let polys: Vec> = cells.iter().map(|poly| poly.iter().map(|&q| to_layer(q)).collect()).collect(); + let centre = |lp: &[[f64; 2]]| [lp.iter().map(|v| v[0]).sum::() / lp.len() as f64, lp.iter().map(|v| v[1]).sum::() / lp.len() as f64]; + let keys: Vec = match &custom_map { + Some(m) => polys + .iter() + .map(|lp| { + let c = centre(lp); + map_key(m.sample_bilinear(c[0] * s + b.offset[0], c[1] * s + b.offset[1])) + }) + .collect(), + None => (0..polys.len() as u32).collect(), + }; + // Group centres (custom map) — each group moves as one rigid piece. + let mut groups: std::collections::HashMap = std::collections::HashMap::new(); + for (lp, &key) in polys.iter().zip(&keys) { + let c = centre(lp); + let e = groups.entry(key).or_insert(([0.0; 2], 0.0)); + e.0[0] += c[0]; + e.0[1] += c[1]; + e.1 += 1.0; + } + let g3 = [gdir.sin() * ginc.cos() * gravity * unit, -gdir.cos() * ginc.cos() * gravity * unit, ginc.sin() * gravity * unit]; + let mut pieces: Vec = polys + .par_iter() + .zip(keys.par_iter()) + .flat_map_iter(|(lp, &key)| { + let mut out: Vec = vec![]; + let (mnx, mxx) = (lp.iter().map(|v| v[0]).fold(f64::INFINITY, f64::min), lp.iter().map(|v| v[0]).fold(f64::NEG_INFINITY, f64::max)); + let (mny, mxy) = (lp.iter().map(|v| v[1]).fold(f64::INFINITY, f64::min), lp.iter().map(|v| v[1]).fold(f64::NEG_INFINITY, f64::max)); + if mxx < 0.0 || mxy < 0.0 || mnx > lw || mny > lh || key == u32::MAX { + return out.into_iter(); + } + let pc_c = centre(lp); + let (gsum, gn) = groups[&key]; + let gc = [gsum[0] / gn, gsum[1] / gn]; + let i = key; + // Broken when inside a force sphere (centred `depth` in front of the layer). + let mut v = [0.0f64; 3]; + let mut broken = false; + for (fp, fd, fr, fs) in forces { + let rad = fr * lw; + if rad <= 0.0 { + continue; + } + let dv = [gc[0] - fp[0], gc[1] - fp[1], fd * lw]; + let dist = (dv[0] * dv[0] + dv[1] * dv[1] + dv[2] * dv[2]).sqrt(); + if dist < rad { + broken = true; + let f = fs * (1.0 - dist / rad) * unit / dist.max(1e-6); + for q in 0..3 { + v[q] += dv[q] * f; + } + } + } + if broken && let Some(g) = &gradient { + let (c, _) = unpremul(g.sample_bilinear(gc[0] * s + b.offset[0], gc[1] * s + b.offset[1])); + let mut l = effectcraft_color::luminance(c[0], c[1], c[2]).clamp(0.0, 1.0) as f64; + if invert_gradient { + l = 1.0 - l; + } + broken = threshold > 0.0 && l >= 1.0 - threshold; + } + if broken && custom && white_fixed && key == 0xfff { + broken = false; + } + if (broken && render == 1) || (!broken && render == 2) { + return out.into_iter(); + } + let mut gpos = [gc[0], gc[1], 0.0]; + let mut r = rot_axis([1.0, 0.0, 0.0], 0.0); + let moving = broken && t > 0.0; + if moving { + let mass = 1.0 + mass_var * hs(i, 1, seed) as f64; + for q in 0..3 { + v[q] = v[q] / mass + randomness * unit * hs(i, 2 + q as u32, seed) as f64 * 2.0; + } + let (f1, f2) = drag_factors(k, t); + for q in 0..3 { + gpos[q] += v[q] * f1 + g3[q] * f2; + } + let axis = match tumble { + 1 => [0.0, 0.0, 0.0], + 2 => [1.0, 0.0, 0.0], + 3 => [0.0, 1.0, 0.0], + 4 => [0.0, 0.0, 1.0], + 5 => [hs(i, 6, seed) as f64, hs(i, 7, seed) as f64, 0.0], + 6 => [hs(i, 6, seed) as f64, 0.0, hs(i, 8, seed) as f64], + 7 => [0.0, hs(i, 7, seed) as f64, hs(i, 8, seed) as f64], + _ => [hs(i, 6, seed) as f64, hs(i, 7, seed) as f64, hs(i, 8, seed) as f64], + }; + let speed = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt() / unit; + let omega = rot_speed * std::f64::consts::TAU * (0.5 + h(i, 9, seed) as f64) * speed.min(10.0); + r = rot_axis(axis, omega * f1); + } + // The piece's centre: its offset from the group centre, rotated with the group. + let off = [pc_c[0] - gc[0], pc_c[1] - gc[1], 0.0]; + let pos: [f64; 3] = std::array::from_fn(|q| gpos[q] + (0..3).map(|k| r[q][k] * off[k]).sum::()); + let tex: Vec<[f32; 2]> = lp.iter().map(|v| [(v[0] * s + b.offset[0]) as f32, (v[1] * s + b.offset[1]) as f32]).collect(); + let c0 = [(pc_c[0] * s + b.offset[0]) as f32, (pc_c[1] * s + b.offset[1]) as f32]; + let posb = [pos[0] * s + b.offset[0], pos[1] * s + b.offset[1], pos[2] * s]; + let Some(mut pc) = Piece::new(&tex, c0, &r, posb, cam) else { return out.into_iter() }; + pc.light(&light, &r, posb, eye); + pc.alpha = tex_opacity; + let face = if pc.back { &backf } else { &front }; + if face.mode == 0 { + pc.flat = Some([tex_color[0], tex_color[1], tex_color[2]]); + } else if face.mode == 2 { + pc.tint = std::array::from_fn(|q| pc.tint[q] * tex_color[q]); + } + // Extrusion: the sides of a moving piece, from its front outline back by the depth. + if moving && extrude > 0.0 { + let nb = [r[0][2], r[1][2], r[2][2]]; + let n = tex.len(); + for e in 0..n { + let (a, bv) = (tex[e], tex[(e + 1) % n]); + let ed = [(bv[0] - a[0]) as f64, (bv[1] - a[1]) as f64]; + let len = (ed[0] * ed[0] + ed[1] * ed[1]).sqrt(); + if len < 1e-6 { + continue; + } + let ew: [f64; 3] = std::array::from_fn(|q| (r[q][0] * ed[0] + r[q][1] * ed[1]) / len); + let third = [ew[1] * nb[2] - ew[2] * nb[1], ew[2] * nb[0] - ew[0] * nb[2], ew[0] * nb[1] - ew[1] * nb[0]]; + let rs: M3 = std::array::from_fn(|q| [ew[q], nb[q], third[q]]); + // Side centre in world: edge midpoint (world) plus half the depth backwards. + let mid = [((a[0] + bv[0]) * 0.5 - c0[0]) as f64, ((a[1] + bv[1]) * 0.5 - c0[1]) as f64]; + let mw: [f64; 3] = std::array::from_fn(|q| posb[q] + r[q][0] * mid[0] + r[q][1] * mid[1] + nb[q] * extrude * 0.5); + let (hl, hd) = ((len * 0.5) as f32, (extrude * 0.5) as f32); + let cs = [(a[0] + bv[0]) * 0.5, (a[1] + bv[1]) * 0.5]; + let quad = [[cs[0] - hl, cs[1] - hd], [cs[0] + hl, cs[1] - hd], [cs[0] + hl, cs[1] + hd], [cs[0] - hl, cs[1] + hd]]; + if let Some(mut sp) = Piece::new(&quad, cs, &rs, mw, cam) { + sp.light(&light, &rs, mw, eye); + sp.alpha = tex_opacity; + let colour = match (&side.img, side.mode) { + (Some(img), m) if m != 0 => { + let (c, _) = unpremul(img.sample_bilinear(cs[0] as f64, cs[1] as f64)); + if m == 2 { std::array::from_fn(|q| c[q] * tex_color[q]) } else { c } + } + _ => [tex_color[0], tex_color[1], tex_color[2]], + }; + sp.flat = Some(colour); + out.push(sp); + } + } + } + out.push(pc); + out.into_iter() + }) + .collect(); + sort_far_first(&mut pieces); + if view == 0 { + let tex = |i: &Option| i.clone().map(PieceTex::Image); + let front_t = tex(&front.img).unwrap_or(PieceTex::Layer); + let back_t = tex(&backf.img).unwrap_or_else(|| front_t.clone()); + return ShatterPlan::Pieces(PiecePlan { pieces, front: front_t, back: Some(back_t) }); + } + { + // Wireframes: piece outlines (front view: at rest; otherwise animated), plus forces. + let animated = view == 2 || view == 4; + let mut lines = Vec::new(); + for pc in &pieces { + let n = pc.n as usize; + let pts: Vec<[f32; 2]> = if animated { + let fw = inv3(&pc.inv); + match fw { + Some(m) => pc.poly[..n] + .iter() + .map(|v| { + let (a, bb) = ((v[0] - pc.c0[0]) as f64, (v[1] - pc.c0[1]) as f64); + let w = m[2][0] * a + m[2][1] * bb + m[2][2]; + [((m[0][0] * a + m[0][1] * bb + m[0][2]) / w) as f32, ((m[1][0] * a + m[1][1] * bb + m[1][2]) / w) as f32] + }) + .collect(), + None => continue, + } + } else { + pc.poly[..n].to_vec() + }; + for e in 0..n { + let (a, c) = (pts[e], pts[(e + 1) % n]); + lines.push(Sprite::new(a[0], a[1], 0.5, [0.75, 0.85, 1.0, 1.0], Shape::Line { dx: c[0] - a[0], dy: c[1] - a[1] })); + } + } + if view >= 3 { + for (fp, _, fr, _) in forces { + let (fx, fy) = b.to_px(fp); + let rad = fr * lw * s; + for k in 0..64 { + let (a0, a1) = (k as f64 / 64.0 * std::f64::consts::TAU, (k + 1) as f64 / 64.0 * std::f64::consts::TAU); + let (x0, y0) = (fx + rad * a0.cos(), fy + rad * a0.sin()); + let (x1, y1) = (fx + rad * a1.cos(), fy + rad * a1.sin()); + lines.push(Sprite::new(x0 as f32, y0 as f32, 0.5, [1.0, 0.3, 0.3, 1.0], Shape::Line { dx: (x1 - x0) as f32, dy: (y1 - y0) as f32 })); + } + } + } + ShatterPlan::Wire(SpritePlan { sprites: lines, tints: vec![], acc: Acc::Over, post: Post::Replace }) + } +} diff --git a/crates/effects/src/sim2/surfaces.rs b/crates/effects/src/sim2/surfaces.rs new file mode 100644 index 000000000..7c3c032f0 --- /dev/null +++ b/crates/effects/src/sim2/surfaces.rs @@ -0,0 +1,524 @@ +use super::{Acc, Buf, EffectCtx, Image, IntoParallelIterator, ParallelIterator, Plane, Px, Shape, SimCache, Sprite, Value, gauss_plane, params_key, unpremul}; + +// ------------------------------------------------------------------ Caustics + +/// Sample with repeat mode: 0 Once (transparent outside), 1 Tiled, 2 Reflected. +fn sample_repeat(img: &Image, x: f64, y: f64, mode: u32) -> Px { + let (w, hh) = (img.width as f64, img.height as f64); + match mode { + 1 => img.sample_bilinear_clamped(x.rem_euclid(w), y.rem_euclid(hh)), + 2 => { + let m = |v: f64, n: f64| { + let r = v.rem_euclid(2.0 * n); + if r > n { 2.0 * n - r } else { r } + }; + img.sample_bilinear_clamped(m(x, w), m(y, hh)) + } + _ => img.sample_bilinear(x, y), + } +} + +/// Everything Caustics' per-pixel pass needs, prepared on the CPU (the other layers it reads +/// and the parameter conversions); the layer's own pixels are not read. +pub struct CausticsSetup { + /// The Bottom layer fitted to the buffer, `None` = the layer itself. + pub bottom: Option, + /// Gaussian σ (buffer px) blurring the bottom (transparent edges), 0 = none. + pub bottom_sigma: f64, + /// The water surface's height field (smoothed luminance), `None` = flat. + pub height: Option, + /// The Sky layer fitted to the buffer. + pub sky: Option, + pub scaling: f64, + pub repeat: u32, + pub wave_h: f64, + /// Displacement per unit of height gradient. + pub k: f64, + /// Layer centre (buffer px). + pub cx: f64, + pub cy: f64, + /// Layer width (buffer px). + pub lw: f64, + pub surf: [f32; 4], + pub surf_op: f32, + pub cstr: f32, + pub lc: [f32; 4], + pub li: f32, + /// Light position (buffer px) and height (layer widths); `point` = from the position to + /// each pixel, else one direction from above the layer centre. + pub lx: f64, + pub ly: f64, + pub lheight: f64, + pub point: bool, + pub ambient: f32, + pub diffuse: f32, + pub specular: f32, + pub sharp: f32, + pub sky_scaling: f64, + pub sky_repeat: u32, + pub sky_int: f32, + pub convergence: f64, +} + +/// Caustics' setup for layer geometry `b` (see [`CausticsSetup`]). +pub fn caustics_setup(ctx: &EffectCtx, b: &Buf) -> CausticsSetup { + let pr = ctx.params; + let s = b.scale; + let bottom = ctx.layer_param("bottom/bottom", true).map(|o| crate::util::fit_layer(ctx, b, &o, pr.e("bottom/bottomSizeDiffers") == 1)); + let blur = pr.f("bottom/blur") * s; + let wave_h = pr.f("water/waveHeight"); + let smoothing = pr.f("water/smoothing") * s; + let depth = pr.f("water/waterDepth"); + let ior = pr.f("water/refractiveIndex").max(1.0); + let lpos = pr.v2("lighting/lightPosition"); + // Height field of the water surface (luminance of the chosen layer; none = flat). + let height = ctx.layer_param("water/waterSurface", true).map(|o| { + let img = crate::util::fit_layer(ctx, b, &o, true); + let pl = Plane::luma(&img); + if smoothing > 0.05 { gauss_plane(&pl, smoothing * 0.5, smoothing * 0.5) } else { pl } + }); + let lw = ctx.layer_size[0] * s; + let k = wave_h * depth * (1.0 - 1.0 / ior) * lw * 2.0; + let (cx, cy) = (b.offset[0] + ctx.layer_size[0] * 0.5 * s, b.offset[1] + ctx.layer_size[1] * 0.5 * s); + let (lx, ly) = b.to_px(lpos); + let (lc, li, lheight) = (pr.color("lighting/lightColor"), pr.f("lighting/lightIntensity") as f32, pr.f("lighting/lightHeight").max(0.01)); + // Light Type: Distant Source (one direction, from the light position above the layer + // centre), Point Source (from the light position to each pixel) or First Comp Light. + let light_type = pr.e("lighting/lightType"); + let comp_light = if light_type == 2 { ctx.env.host.and_then(|h| h.comp_scene()).and_then(|sc| sc.light) } else { None }; + let (lc, li, lx, ly, lheight, point) = match (light_type, comp_light) { + (2, Some(l)) => { + let c = [l.color[0], l.color[1], l.color[2], 1.0]; + // Comp space: z away from the viewer; the light above the water is at −z. + let (px, py) = (l.pos[0] * s + b.offset[0], l.pos[1] * s + b.offset[1]); + (c, li, px, py, (-l.pos[2] / ctx.layer_size[0].max(1.0)).max(0.01), l.kind == 1) + } + (2, None) => (lc, 0.0, lx, ly, lheight, false), + (1, _) => (lc, li, lx, ly, lheight, true), + _ => (lc, li, lx, ly, lheight, false), + }; + // Sky: the layer the water surface reflects (Surface Opacity 1 = a mirror of it). + let sky = ctx.layer_param("sky/sky", true).map(|o| crate::util::fit_layer(ctx, b, &o, pr.e("sky/skySizeDiffers") == 1)); + CausticsSetup { + bottom, + bottom_sigma: if blur > 0.05 { blur * 0.5 } else { 0.0 }, + height, + sky, + scaling: pr.f("bottom/scaling").max(0.01), + repeat: pr.e("bottom/repeatMode"), + wave_h, + k, + cx, + cy, + lw, + surf: pr.color("water/surfaceColor"), + surf_op: pr.f("water/surfaceOpacity") as f32, + cstr: pr.f("water/causticsStrength") as f32, + lc, + li, + lx, + ly, + lheight, + point, + ambient: pr.f("lighting/ambientLight") as f32, + diffuse: pr.f("material/diffuse") as f32, + specular: pr.f("material/specular") as f32, + sharp: pr.f("material/highlightSharpness").max(1.0) as f32, + sky_scaling: pr.get("sky/scaling").map(Value::as_f64).unwrap_or(1.0).max(0.01), + sky_repeat: pr.e("sky/repeatMode"), + sky_int: pr.get("sky/intensity").map(Value::as_f64).unwrap_or(0.3) as f32, + convergence: pr.get("sky/convergence").map(Value::as_f64).unwrap_or(0.5).clamp(0.0, 1.0), + } +} + +pub(super) fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf { + let st = caustics_setup(ctx, &b); + let mut bottom = st.bottom.clone().unwrap_or_else(|| b.img.clone()); + if st.bottom_sigma > 0.0 { + bottom = effectcraft_raster::gaussian_blur(&bottom, st.bottom_sigma, st.bottom_sigma, false); + } + let CausticsSetup { + scaling, + repeat, + wave_h, + k, + cx, + cy, + lw, + surf, + surf_op, + cstr, + lc, + li, + lx, + ly, + lheight, + point, + ambient, + diffuse, + specular, + sharp, + sky_scaling, + sky_repeat, + sky_int, + convergence, + .. + } = st; + let (height, sky) = (&st.height, &st.sky); + let (w, hh) = (b.img.width as usize, b.img.height as usize); + let norm3 = |v: [f64; 3]| { + let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12); + [(v[0] / n) as f32, (v[1] / n) as f32, (v[2] / n) as f32] + }; + let lv_at = |x: f64, y: f64| { + if point { + norm3([(lx - x) / lw.max(1.0), (ly - y) / lw.max(1.0), lheight]) + } else { + norm3([(lx - cx) / lw.max(1.0), (ly - cy) / lw.max(1.0), lheight]) + } + }; + let grad = |x: usize, y: usize| -> (f64, f64) { + match height { + Some(pl) => { + let gx = (pl.get_clamped(x as i64 + 1, y as i64) - pl.get_clamped(x as i64 - 1, y as i64)) as f64 * 0.5; + let gy = (pl.get_clamped(x as i64, y as i64 + 1) - pl.get_clamped(x as i64, y as i64 - 1)) as f64 * 0.5; + (gx, gy) + } + None => (0.0, 0.0), + } + }; + // Displacement and its Jacobian (caustic focusing) per pixel. + let disp: Vec<(f64, f64)> = (0..w * hh) + .into_par_iter() + .map(|i| { + let (gx, gy) = grad(i % w, i / w); + (-gx * k, -gy * k) + }) + .collect(); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let i = y * w + x; + let (dx, dy) = disp[i]; + let d = |xx: usize, yy: usize| disp[yy.min(hh - 1) * w + xx.min(w - 1)]; + let (dxr, _) = d(x + 1, y); + let (_, dyd) = d(x, y + 1); + let (dxl, _) = d(x.saturating_sub(1), y); + let (_, dyu) = d(x, y.saturating_sub(1)); + let jac = (1.0 + (dxr - dxl) * 0.5) * (1.0 + (dyd - dyu) * 0.5); + let focus = ((1.0 / jac.abs().max(0.2)) as f32 - 1.0).clamp(-1.0, 4.0); + let sx = cx + (x as f64 + 0.5 + dx - cx) / scaling; + let sy = cy + (y as f64 + 0.5 + dy - cy) / scaling; + let p = sample_repeat(&bottom, sx, sy, repeat); + let (c, a) = unpremul(p); + let (gx, gy) = grad(x, y); + let n = { + let v = [-gx * wave_h * 20.0, -gy * wave_h * 20.0, 1.0]; + let m = (v[0] * v[0] + v[1] * v[1] + 1.0).sqrt(); + [(v[0] / m) as f32, (v[1] / m) as f32, (v[2] / m) as f32] + }; + let lv = lv_at(x as f64 + 0.5, y as f64 + 0.5); + let nl = (n[0] * lv[0] + n[1] * lv[1] + n[2] * lv[2]).max(0.0); + let hv = { + let v = [lv[0], lv[1], lv[2] + 1.0]; + let m = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); + [v[0] / m, v[1] / m, v[2] / m] + }; + let spec = (n[0] * hv[0] + n[1] * hv[1] + n[2] * hv[2]).max(0.0).powf(sharp) * specular * li; + let light = ambient + diffuse * li * nl + cstr * focus * li; + // The surface: its colour lit like the water, or the sky it reflects (sampled along + // the surface normal; Convergence pulls the reflection towards the pixel itself). + let surface: [f32; 3] = match &sky { + Some(sk) => { + let reach = (1.0 - convergence) * lw * 0.5; + let (rx, ry) = (x as f64 + 0.5 + n[0] as f64 * reach, y as f64 + 0.5 + n[1] as f64 * reach); + let (rx, ry) = (cx + (rx - cx) / sky_scaling, cy + (ry - cy) / sky_scaling); + let (sc, _) = unpremul(sample_repeat(sk, rx, ry, sky_repeat)); + std::array::from_fn(|q| sc[q] * sky_int * 3.0 * surf[q]) + } + None => std::array::from_fn(|q| surf[q] * (ambient + diffuse * li * nl)), + }; + let cc: [f32; 3] = std::array::from_fn(|q| { + let base = c[q] * light * lc[q]; + base * (1.0 - surf_op) + surface[q] * surf_op + spec * lc[q] + }); + let a = (a + surf_op * (1.0 - a)).min(1.0); + *px = [cc[0] * a, cc[1] * a, cc[2] * a, a]; + } + }); + b +} + +// ------------------------------------------------------------------ Wave World + +/// Wave World's simulated surface: an `nx` × `ny` grid of heights `u` and velocities `v`. +#[derive(Clone, Debug)] +pub struct Waves { + pub nx: usize, + pub ny: usize, + pub u: Vec, + pub v: Vec, +} + +struct Producer { + ring: bool, + pos: [f32; 2], + len: f32, + width: f32, + angle: f32, + amp: f32, + freq: f32, + phase: f32, +} + +impl Producer { + /// Weight of a grid point (layer-fraction coordinates, x in widths, y in widths). + fn weight(&self, x: f32, y: f32) -> f32 { + let (dx, dy) = (x - self.pos[0], y - self.pos[1]); + let (sa, ca) = self.angle.sin_cos(); + let (u, v) = (dx * ca + dy * sa, -dx * sa + dy * ca); + let d = if self.ring { + let (a, bb) = ((self.len * 0.5).max(1e-3), (self.width * 0.5).max(1e-3)); + ((u / a).powi(2) + (v / bb).powi(2)).sqrt() + } else { + let l = (self.len * 0.5).max(1e-3); + let wd = (self.width * 0.5).max(1e-3); + (u.abs() / l).max(v.abs() / wd) + }; + (1.0 - d).clamp(0.0, 1.0).min(1.0) + } +} + +static WAVE_CACHE: SimCache = SimCache::new(4); + +/// What Wave World draws: the Height Map view of the simulated surface (grid heights and, +/// with a ground layer, water depths per grid point), or the wireframe preview's lines. +pub enum WavePlan { + Height { st: std::sync::Arc, depth: Option> }, + Wire(crate::sim::SpritePlan), +} + +pub(super) fn wave_world(ctx: &EffectCtx, b: Buf) -> Buf { + match wave_world_impl(ctx, &b, false) { + Ok(img) => Buf { img, ..b }, + Err(WavePlan::Wire(plan)) => plan.finish(b), + Err(WavePlan::Height { .. }) => b, + } +} + +/// Wave World's simulation state for the frame, as a [`WavePlan`] (its pixels are not read). +pub fn wave_world_plan(ctx: &EffectCtx, b: &Buf) -> Option { + wave_world_impl(ctx, b, true).err() +} + +fn wave_world_impl(ctx: &EffectCtx, b: &Buf, plan_only: bool) -> Result { + let pr = ctx.params; + let [lw, lh] = ctx.layer_size; + let res = pr.f("simulation/gridResolution").clamp(1.0, 400.0) as usize; + let res = if pr.b("simulation/gridResDownsamples") { ((res as f64) * b.scale).round().max(1.0) as usize } else { res }; + let nx = res.max(2) + 1; + let ny = ((res as f64 * lh / lw.max(1.0)).round() as usize).max(2) + 1; + let speed = pr.f("simulation/waveSpeed").max(0.0) as f32; + let damping = pr.f("simulation/damping").max(0.0) as f32; + let reflect = pr.e("simulation/reflectEdges"); + let preroll = pr.f("simulation/preRoll").max(0.0); + let prods: Vec = [1, 2] + .iter() + .map(|k| { + let pos = pr.v2(&format!("producer{k}/producer{k}Position")); + Producer { + ring: pr.e(&format!("producer{k}/producer{k}Type")) == 0, + pos: [(pos[0] / lw.max(1.0)) as f32, (pos[1] / lw.max(1.0)) as f32], + len: pr.f(&format!("producer{k}/producer{k}Length")) as f32, + width: pr.f(&format!("producer{k}/producer{k}Width")) as f32, + angle: (pr.f(&format!("producer{k}/producer{k}Angle")) as f32).to_radians(), + amp: pr.f(&format!("producer{k}/producer{k}Amplitude")) as f32, + freq: pr.f(&format!("producer{k}/producer{k}Frequency")) as f32, + phase: (pr.f(&format!("producer{k}/producer{k}Phase")) as f32).to_radians(), + } + }) + .collect(); + let sps = crate::sim::SPS as f32; + // Wave speed in layer widths per second → cells per second; sub-steps keep it stable. + let cps = speed * (nx - 1) as f32; + let sub = ((cps * 2.0 / sps).ceil() as usize).clamp(1, 64); + let dt = 1.0 / (sps * sub as f32); + let cell = 1.0 / (nx - 1) as f32; + let weights: Vec> = prods.iter().map(|p| (0..nx * ny).map(|i| p.weight((i % nx) as f32 * cell, (i / nx) as f32 * cell)).collect()).collect(); + // Ground: the ground layer's first frame, its luminance an elevation (× Steepness). Where + // it rises above Height the grid is dry (a wall the waves reflect from); shallow water + // slows the waves by Wave Strength. + let ground = pr.get("ground/ground").and_then(Value::as_layer).and_then(|id| { + let host = ctx.env.host?; + host.layer_at(id, ctx.env.comp_time - ctx.time, true) + }); + let steep = pr.get("ground/steepness").map(Value::as_f64).unwrap_or(0.25) as f32; + let level = pr.get("ground/height").map(Value::as_f64).unwrap_or(0.25) as f32; + let wave_strength = pr.f("ground/waveStrength").clamp(0.0, 1.0) as f32; + // Water depth per grid point (None without ground: deep everywhere). + let depth: Option> = ground.as_ref().map(|lp| { + let (gw, gh) = (lp.size[0].max(1.0), lp.size[1].max(1.0)); + (0..nx * ny) + .map(|i| { + let (fx, fy) = ((i % nx) as f64 / (nx - 1) as f64, (i / nx) as f64 / (ny - 1) as f64); + let (c, a) = unpremul(lp.buf.img.sample_bilinear_clamped(fx * gw * lp.buf.scale + lp.buf.offset[0], fy * gh * lp.buf.scale + lp.buf.offset[1])); + level - steep * effectcraft_color::luminance(c[0], c[1], c[2]) * a + }) + .collect() + }); + let speed_k: Vec = match &depth { + Some(d) => d.iter().map(|&v| if v <= 0.0 { 0.0 } else { 1.0 - wave_strength * (1.0 - (v / level.max(1e-3)).clamp(0.0, 1.0).sqrt()) }).collect(), + None => vec![1.0; nx * ny], + }; + let mut key = + params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: if pr.b("simulation/gridResDownsamples") { b.scale } else { 1.0 } }, 4); + if let Some(d) = &depth { + key ^= d.iter().fold(0u64, |a, v| a.rotate_left(3) ^ v.to_bits() as u64); + } + let steps = crate::sim::steps_at(ctx.time + preroll); + let st = WAVE_CACHE.run( + key, + steps, + || Waves { nx, ny, u: vec![0.0; nx * ny], v: vec![0.0; nx * ny] }, + |w, step| { + for k in 0..sub { + let t = (step as f32 + k as f32 / sub as f32) / sps - preroll as f32; + let c2 = cps * cps; + let mut acc = vec![0.0f32; nx * ny]; + for y in 0..ny { + for x in 0..nx { + let i = y * nx + x; + let g = |xx: usize, yy: usize| w.u[yy * nx + xx]; + let l = g(x.saturating_sub(1), y) + g((x + 1).min(nx - 1), y) + g(x, y.saturating_sub(1)) + g(x, (y + 1).min(ny - 1)) - 4.0 * w.u[i]; + acc[i] = c2 * speed_k[i] * speed_k[i] * l - damping * 4.0 * w.v[i]; + } + } + for i in 0..nx * ny { + w.v[i] += acc[i] * dt; + w.u[i] += w.v[i] * dt; + } + // Producers drive the surface. + for (p, wt) in prods.iter().zip(&weights) { + if p.amp == 0.0 { + continue; + } + let target = p.amp * (std::f32::consts::TAU * p.freq * t + p.phase).sin(); + for i in 0..nx * ny { + if wt[i] > 0.0 { + w.u[i] += (target - w.u[i]) * wt[i]; + w.v[i] *= 1.0 - wt[i]; + } + } + } + // Dry ground holds the surface flat. + for i in 0..nx * ny { + if speed_k[i] <= 0.0 { + w.u[i] = 0.0; + w.v[i] = 0.0; + } + } + // Edges: reflective ones mirror; the others absorb in a thin sponge layer. + let refl = |side: u32| reflect == 5 || reflect == side; + let sponge = 4usize.min(nx / 4).max(1); + for y in 0..ny { + for x in 0..nx { + let i = y * nx + x; + let mut f = 1.0f32; + if !refl(1) && x < sponge { + f = f.min(x as f32 / sponge as f32); + } + if !refl(3) && nx - 1 - x < sponge { + f = f.min((nx - 1 - x) as f32 / sponge as f32); + } + if !refl(2) && y < sponge { + f = f.min(y as f32 / sponge as f32); + } + if !refl(4) && ny - 1 - y < sponge { + f = f.min((ny - 1 - y) as f32 / sponge as f32); + } + if f < 1.0 { + let k = 0.85 + 0.15 * f; + w.u[i] *= k; + w.v[i] *= k; + } + } + } + } + }, + ); + let s = b.scale; + let (bw, bh) = (b.img.width, b.img.height); + let sample = |x: f64, y: f64| -> f32 { + let gx = (x / lw.max(1.0) * (st.nx - 1) as f64).clamp(0.0, (st.nx - 1) as f64); + let gy = (y / lw.max(1.0) * (st.nx - 1) as f64).clamp(0.0, (st.ny - 1) as f64); + let (x0, y0) = (gx.floor() as usize, gy.floor() as usize); + let (x1, y1) = ((x0 + 1).min(st.nx - 1), (y0 + 1).min(st.ny - 1)); + let (tx, ty) = ((gx - x0 as f64) as f32, (gy - y0 as f64) as f32); + let g = |xx: usize, yy: usize| st.u[yy * st.nx + xx]; + let a = g(x0, y0) + (g(x1, y0) - g(x0, y0)) * tx; + let c = g(x0, y1) + (g(x1, y1) - g(x0, y1)) * tx; + a + (c - a) * ty + }; + if plan_only && pr.e("view") == 1 { + return Err(WavePlan::Height { st, depth }); + } + if pr.e("view") == 1 { + let mut img = Image::new(bw, bh); + let bright = pr.f("heightMapControls/brightness") as f32; + let contrast = pr.f("heightMapControls/contrast") as f32; + let gamma = pr.f("heightMapControls/gamma").max(0.01) as f32; + let alpha = 1.0 - pr.f("heightMapControls/transparency") as f32; + let dry_transparent = pr.e("heightMapControls/renderDryAreasAs") == 1; + let dry_at = |x: f64, y: f64| -> bool { + let Some(d) = &depth else { return false }; + let gx = (x / lw.max(1.0) * (st.nx - 1) as f64).round().clamp(0.0, (st.nx - 1) as f64) as usize; + let gy = (y / lw.max(1.0) * (st.nx - 1) as f64).round().clamp(0.0, (st.ny - 1) as f64) as usize; + d[gy * st.nx + gx] <= 0.0 + }; + img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let lx = (x as f64 + 0.5 - b.offset[0]) / s; + let ly = (y as f64 + 0.5 - b.offset[1]) / s; + let v = (bright + contrast * sample(lx, ly)).clamp(0.0, 1.0).powf(1.0 / gamma); + let al = if dry_transparent && dry_at(lx, ly) { 0.0 } else { alpha }; + *px = [v * al, v * al, v * al, al]; + } + }); + Ok(img) + } else { + // Wireframe preview: the grid turned by Horizontal Rotation about the vertical axis, + // tilted by Vertical Rotation, heights scaled by Vertical Scale. + let hr = pr.f("wireframeControls/horizontalRotation").to_radians(); + let vr = pr.get("wireframeControls/verticalRotation").map(Value::as_f64).unwrap_or(53.0).to_radians(); + let vs = pr.get("wireframeControls/verticalScale").map(Value::as_f64).unwrap_or(0.5); + let (sh, ch) = hr.sin_cos(); + let (sv, cv) = vr.sin_cos(); + let to = |x: usize, y: usize| -> [f32; 2] { + // Grid point relative to the layer centre (layer px), height in layer px. + let gx = x as f64 / (st.nx - 1) as f64 * lw - lw * 0.5; + let gy = y as f64 / (st.nx - 1) as f64 * lw - (st.ny - 1) as f64 / (st.nx - 1) as f64 * lw * 0.5; + let hgt = st.u[y * st.nx + x] as f64 * vs * lh * 0.5; + let (rx, ry) = (gx * ch - gy * sh, gx * sh + gy * ch); + let (px, py) = b.to_px([lw * 0.5 + rx, lh * 0.5 + ry * cv - hgt * sv]); + [px as f32, py as f32] + }; + let mut lines = Vec::new(); + for y in 0..st.ny { + for x in 0..st.nx { + let a = to(x, y); + if x + 1 < st.nx { + let c = to(x + 1, y); + lines.push(Sprite::new(a[0], a[1], 0.4, [0.4, 0.9, 0.4, 1.0], Shape::Line { dx: c[0] - a[0], dy: c[1] - a[1] })); + } + if y + 1 < st.ny { + let c = to(x, y + 1); + lines.push(Sprite::new(a[0], a[1], 0.4, [0.4, 0.9, 0.4, 1.0], Shape::Line { dx: c[0] - a[0], dy: c[1] - a[1] })); + } + } + } + let plan = crate::sim::SpritePlan { sprites: lines, tints: vec![], acc: Acc::Over, post: crate::sim::Post::OverBlack }; + if plan_only { + return Err(WavePlan::Wire(plan)); + } + Ok(plan.finish(Buf { img: Image::new(bw, bh), offset: b.offset, scale: b.scale }).img) + } +} diff --git a/crates/effects/src/sim2/tests.rs b/crates/effects/src/sim2/tests.rs new file mode 100644 index 000000000..2ef35b122 --- /dev/null +++ b/crates/effects/src/sim2/tests.rs @@ -0,0 +1,485 @@ +use super::*; +use crate::{EffectEnv, run_fx}; + +fn ramp(w: u32, hh: u32) -> Image { + let mut img = Image::new(w, hh); + for y in 0..hh { + for x in 0..w { + img.set(x, y, [x as f32 / w as f32, y as f32 / hh as f32, 0.4, 1.0]); + } + } + img +} + +fn run(id: &str, vals: &[(&str, Value)], img: Image, t: f64) -> Image { + run_fx(id, vals, img, t, EffectEnv::default()).img +} + +fn max_diff(a: &Image, b: &Image) -> f32 { + a.data.iter().zip(&b.data).map(|(p, q)| (0..4).map(|k| (p[k] - q[k]).abs()).fold(0.0, f32::max)).fold(0.0, f32::max) +} + +fn sum_diff(a: &Image, b: &Image) -> f32 { + a.data.iter().zip(&b.data).map(|(p, q)| (0..4).map(|k| (p[k] - q[k]).abs()).sum::()).sum() +} + +#[test] +fn piece_at_rest_is_identity() { + let src = ramp(16, 16); + let cam = Cam { c: [8.0, 8.0], d: 64.0 }; + let id = rot_axis([1.0, 0.0, 0.0], 0.0); + let pc = Piece::new(&[[0.0, 0.0], [16.0, 0.0], [16.0, 16.0], [0.0, 16.0]], [8.0, 8.0], &id, [8.0, 8.0, 0.0], cam).unwrap(); + let mut out = Image::new(16, 16); + draw_pieces(&mut out, &[pc], &src, None); + assert!(max_diff(&out, &src) < 1e-4); +} + +#[test] +fn ball_action_draws_balls() { + let v = [("ballSize", num(80.0))]; + let a = run("ec.sim.ccballaction", &v, ramp(32, 32), 0.0); + assert_eq!(a, run("ec.sim.ccballaction", &v, ramp(32, 32), 0.0)); + assert!(a.data.iter().any(|p| p[3] > 0.9) && a.data.iter().any(|p| p[3] == 0.0), "balls with gaps"); + let none = run("ec.sim.ccballaction", &[("ballSize", num(0.0))], ramp(32, 32), 0.0); + assert!(none.data.iter().all(|p| p[3] == 0.0)); + let sc = run("ec.sim.ccballaction", &[("ballSize", num(80.0)), ("scatter", num(20.0))], ramp(32, 32), 0.0); + assert!(sum_diff(&a, &sc) > 1.0); +} + +#[test] +fn pixel_polly_breaks_after_start() { + let v = [("forceCenter", pt(16.0, 16.0)), ("gridSpacing", num(4.0))]; + let before = run("ec.sim.ccpixelpolly", &v, ramp(32, 32), 0.0); + assert_eq!(before, ramp(32, 32), "intact before the start time"); + let a = run("ec.sim.ccpixelpolly", &v, ramp(32, 32), 0.5); + assert_eq!(a, run("ec.sim.ccpixelpolly", &v, ramp(32, 32), 0.5)); + assert!(sum_diff(&a, &ramp(32, 32)) > 10.0); + let tex = run("ec.sim.ccpixelpolly", &[("forceCenter", pt(16.0, 16.0)), ("gridSpacing", num(4.0)), ("object", Value::Enum(3))], ramp(32, 32), 0.5); + assert!(sum_diff(&a, &tex) > 0.1); +} + +#[test] +fn scatterize_identity_and_scatter() { + assert_eq!(run("ec.sim.ccscatterize", &[], ramp(24, 24), 0.0), ramp(24, 24)); + let a = run("ec.sim.ccscatterize", &[("scatter", num(5.0))], ramp(24, 24), 0.0); + assert_eq!(a, run("ec.sim.ccscatterize", &[("scatter", num(5.0))], ramp(24, 24), 0.0)); + assert!(sum_diff(&a, &ramp(24, 24)) > 1.0); + let tw = run("ec.sim.ccscatterize", &[("leftTwist", num(80.0)), ("rightTwist", num(80.0))], ramp(24, 24), 0.0); + assert!(sum_diff(&tw, &ramp(24, 24)) > 1.0); +} + +#[test] +fn card_dance_identity_and_rotation() { + let src = ramp(30, 20); + let a = run("ec.sim.carddance", &[], src.clone(), 0.0); + assert!(max_diff(&a, &src) < 0.02, "flat cards reproduce the layer: {}", max_diff(&a, &src)); + let r = run("ec.sim.carddance", &[("yRotation/yRotSource", Value::Enum(1)), ("yRotation/yRotMultiplier", num(90.0))], src.clone(), 0.0); + assert_eq!(r, run("ec.sim.carddance", &[("yRotation/yRotSource", Value::Enum(1)), ("yRotation/yRotMultiplier", num(90.0))], src.clone(), 0.0)); + assert!(sum_diff(&r, &src) > 5.0); + let z = run("ec.sim.carddance", &[("zPosition/zPosOffset", num(-1.0))], src.clone(), 0.0); + assert!(sum_diff(&z, &src) > 5.0, "cards pulled towards the camera grow"); +} + +#[test] +fn shatter_at_rest_then_breaks() { + let v = [("view", Value::Enum(0)), ("force1/force1Position", pt(16.0, 16.0)), ("shape/origin", pt(16.0, 16.0))]; + let src = ramp(32, 32); + let t0 = run("ec.sim.shatter", &v, src.clone(), 0.0); + assert!(max_diff(&t0, &src) < 0.03, "{}", max_diff(&t0, &src)); + let t1 = run("ec.sim.shatter", &v, src.clone(), 0.5); + assert_eq!(t1, run("ec.sim.shatter", &v, src.clone(), 0.5)); + assert!(sum_diff(&t1, &src) > 10.0); + for pat in 0..SHATTER_PATTERNS.len() as u32 { + let vv = [("view", Value::Enum(0)), ("shape/pattern", Value::Enum(pat)), ("force1/force1Position", pt(16.0, 16.0)), ("shape/origin", pt(16.0, 16.0))]; + let r = run("ec.sim.shatter", &vv, src.clone(), 0.0); + assert!(max_diff(&r, &src) < 0.05, "pattern {pat} tiles the layer: {}", max_diff(&r, &src)); + } + let wire = run("ec.sim.shatter", &[("shape/repetitions", num(3.0))], src.clone(), 0.0); + assert!(wire.data.iter().any(|p| p[3] > 0.5) && wire.data.iter().any(|p| p[3] == 0.0)); + let layer_only = + run("ec.sim.shatter", &[("view", Value::Enum(0)), ("render", Value::Enum(1)), ("force1/force1Position", pt(16.0, 16.0))], src.clone(), 0.5); + assert!(layer_only.get(16, 16)[3] == 0.0, "broken centre removed"); +} + +#[test] +fn caustics_flat_water() { + let src = ramp(24, 24); + let a = run( + "ec.sim.caustics", + &[ + ("water/surfaceOpacity", num(0.0)), + ("lighting/ambientLight", num(0.0)), + ("material/diffuse", num(1.0)), + ("lighting/lightHeight", num(1000.0)), + ("material/specular", num(0.0)), + ], + src.clone(), + 0.0, + ); + assert!(max_diff(&a, &src) < 0.01, "flat water, light overhead = bottom: {}", max_diff(&a, &src)); + let b = run("ec.sim.caustics", &[], src.clone(), 0.0); + assert_eq!(b, run("ec.sim.caustics", &[], src.clone(), 0.0)); + assert!(sum_diff(&b, &src) > 0.1); +} + +#[test] +fn caustics_sky_and_light_types() { + let src = Image::filled(32, 32, [0.5, 0.5, 0.5, 1.0]); + let env = EffectEnv { host: Some(&FoamHost), ..Default::default() }; + let run_h = |vals: &[(&str, Value)]| run_fx("ec.sim.caustics", vals, src.clone(), 0.0, env).img; + // A mirror-like surface shows the (green) sky. + let mirror = [("water/surfaceOpacity", num(1.0)), ("sky/sky", Value::Layer(Some(2))), ("sky/intensity", num(1.0))]; + let p = run_h(&mirror).get(16, 16); + assert!(p[1] > p[0] + 0.3 && p[1] > p[2] + 0.3, "{p:?}"); + // Point Source lights pixels by their own direction to the light: the image is no + // longer uniform; Distant Source lights the flat water evenly. + let lit = |t: u32| { + run_h(&[ + ("lighting/lightType", Value::Enum(t)), + ("lighting/lightPosition", pt(0.0, 0.0)), + ("lighting/lightHeight", num(0.2)), + ("water/surfaceOpacity", num(0.0)), + ]) + }; + let (distant, point) = (lit(0), lit(1)); + assert!((distant.get(2, 2)[0] - distant.get(30, 30)[0]).abs() < 1e-5); + assert!(point.get(2, 2)[0] > point.get(30, 30)[0] + 0.02, "{:?} {:?}", point.get(2, 2), point.get(30, 30)); + // First Comp Light without a comp light: ambient only. + let none = run_h(&[("lighting/lightType", Value::Enum(2)), ("water/surfaceOpacity", num(0.0))]); + assert!(none.get(16, 16)[0] < distant.get(16, 16)[0]); +} + +#[test] +fn wave_world_wireframe_controls_ground_and_dry_areas() { + let base = [("producer1/producer1Position", pt(8.0, 16.0)), ("simulation/gridResolution", num(24.0))]; + let wire = |extra: &[(&str, Value)]| run("ec.sim.waveworld", &[base.as_slice(), extra].concat(), Image::new(32, 32), 0.5); + assert_ne!(wire(&[]), wire(&[("wireframeControls/horizontalRotation", num(40.0))])); + assert_ne!(wire(&[]), wire(&[("wireframeControls/verticalScale", num(3.0))])); + // Ground (left dark, right light): the light side rises above the water and is dry. + let env = EffectEnv { host: Some(&FoamHost), ..Default::default() }; + let hm = |extra: &[(&str, Value)]| { + let mut v: Vec<(&str, Value)> = base.to_vec(); + v.extend([("view", Value::Enum(1)), ("ground/ground", Value::Layer(Some(1))), ("ground/steepness", num(0.5)), ("ground/height", num(0.25))]); + v.extend_from_slice(extra); + run_fx("ec.sim.waveworld", &v, Image::new(32, 32), 1.0, env).img + }; + let solid = hm(&[]); + let clear = hm(&[("heightMapControls/renderDryAreasAs", Value::Enum(1))]); + assert!(solid.get(30, 16)[3] > 0.99 && clear.get(30, 16)[3] == 0.0); + assert!(clear.get(4, 16)[3] > 0.99, "wet side stays"); + // Dry ground stays flat: mid grey (brightness 0.5) there. + assert!((solid.get(30, 16)[0] - 0.5).abs() < 1e-4); + // Wave Strength slows waves over shallow water: the surface differs. + assert_ne!(hm(&[]), hm(&[("ground/waveStrength", num(1.0))])); +} + +#[test] +fn wave_world_seek_and_waves() { + let v = vec![("view", Value::Enum(1)), ("producer1/producer1Position", pt(16.0, 16.0)), ("simulation/gridResolution", num(24.0))]; + let _ = run("ec.sim.waveworld", &v, Image::new(32, 32), 0.5); + let a = run("ec.sim.waveworld", &v, Image::new(32, 32), 1.0); + let mut v2 = v.clone(); + v2.push(("heightMapControls/transparency", num(0.0))); + assert_eq!(a, run("ec.sim.waveworld", &v2, Image::new(32, 32), 1.0)); + // Waves spread: some pixels differ from the flat-water grey. + assert!(a.data.iter().any(|p| (p[0] - 0.5).abs() > 0.05), "waves"); + let flat = run("ec.sim.waveworld", &[("view", Value::Enum(1)), ("producer1/producer1Amplitude", num(0.0))], Image::new(32, 32), 1.0); + assert!(flat.data.iter().all(|p| (p[0] - 0.5).abs() < 1e-5)); + let wire = run("ec.sim.waveworld", &[("producer1/producer1Position", pt(16.0, 16.0)), ("simulation/gridResolution", num(10.0))], Image::new(32, 32), 0.3); + assert!(wire.data.iter().any(|p| p[1] > 0.3)); +} + +#[test] +fn wave_world_steps_match_direct() { + // Fresh caches: resumed simulation equals simulation from scratch. + let v = vec![ + ("view", Value::Enum(1)), + ("producer1/producer1Position", pt(16.0, 16.0)), + ("simulation/gridResolution", num(20.0)), + ("simulation/damping", num(0.07)), + ]; + let a = run("ec.sim.waveworld", &v, Image::new(32, 32), 1.3); + let mut v2 = v.clone(); + v2.push(("simulation/damping", num(0.07000001))); + // Different key: computed from scratch at 1.3 directly vs via 0.4 (both fresh keys). + let _ = run("ec.sim.waveworld", &v2, Image::new(32, 32), 0.4); + let b = run("ec.sim.waveworld", &v2, Image::new(32, 32), 1.3); + assert!(max_diff(&a, &b) < 1e-3, "{}", max_diff(&a, &b)); +} + +#[test] +fn foam_bubbles_drift_and_seek() { + let v = vec![("producer/producerPoint", pt(16.0, 16.0)), ("view", Value::Enum(2)), ("producer/productionRate", num(2.0))]; + let none = run("ec.sim.foam", &v, Image::new(32, 32), 0.0); + assert!(none.data.iter().all(|p| p[3] == 0.0)); + let _ = run("ec.sim.foam", &v, Image::new(32, 32), 0.6); + let a = run("ec.sim.foam", &v, Image::new(32, 32), 1.2); + let mut v2 = v.clone(); + v2.push(("randomSeed", num(1.0))); + assert_eq!(a, run("ec.sim.foam", &v2, Image::new(32, 32), 1.2)); + assert!(a.data.iter().any(|p| p[3] > 0.05), "bubbles"); + let draft = run("ec.sim.foam", &[("producer/producerPoint", pt(16.0, 16.0)), ("producer/productionRate", num(2.0))], Image::new(32, 32), 1.0); + assert!(draft.data.iter().any(|p| p[3] > 0.05)); +} + +#[test] +fn foam_strength_controls_early_popping() { + // Strong bubbles all live their full lifespan; weak ones pop at random earlier. + assert_eq!(foam_pop_age(7, 300.0, 0.0, 1), 300.0); + let early = (0..200).filter(|&id| foam_pop_age(id, 300.0, 1.0, 1) < 150.0).count(); + assert!(early > 60 && early < 140, "{early}"); + let count = |strength: f64| { + let v = vec![ + ("producer/producerPoint", pt(16.0, 16.0)), + ("producer/productionRate", num(3.0)), + ("bubbles/lifespan", num(40.0)), + ("bubbles/strength", num(strength)), + ("physics/windSpeed", num(0.0)), + ]; + run("ec.sim.foam", &v, Image::new(32, 32), 1.5).data.iter().map(|p| p[3]).sum::() + }; + assert!(count(0.0) < count(20.0) * 0.9, "{} vs {}", count(0.0), count(20.0)); +} + +/// A host with a comp camera (identity, or tilted) and a first comp light. +struct Scene { + camera: Option<[[f64; 4]; 3]>, + light: Option, + layer: Option, +} +impl crate::EffectHost for Scene { + fn layer(&self, _: u64, _: bool) -> Option { + let img = self.layer.clone()?; + let size = [img.width as f64, img.height as f64]; + Some(crate::LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size }) + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn comp_scene(&self) -> Option { + Some(crate::CompScene { camera: self.camera, camera_layer: true, light: self.light }) + } +} + +fn run_env(id: &str, vals: &[(&str, Value)], img: Image, t: f64, host: &Scene) -> Image { + run_fx(id, vals, img, t, EffectEnv { host: Some(host), ..Default::default() }).img +} + +#[test] +fn card_dance_camera_systems() { + let src = ramp(40, 30); + // Camera Position: rotating the camera changes the view; zero rotation is the layer. + let r = run("ec.sim.carddance", &[("cameraPosition/yRotation", num(30.0))], src.clone(), 0.0); + assert!(sum_diff(&r, &src) > 10.0); + let order = run("ec.sim.carddance", &[("cameraPosition/yRotation", num(30.0)), ("cameraPosition/xRotation", num(30.0))], src.clone(), 0.0); + let order2 = run( + "ec.sim.carddance", + &[("cameraPosition/yRotation", num(30.0)), ("cameraPosition/xRotation", num(30.0)), ("cameraPosition/transformOrder", Value::Enum(5))], + src.clone(), + 0.0, + ); + assert!(sum_diff(&order, &order2) > 1.0, "Transform Order matters"); + // X, Y Position moves the view: the layer shifts by the offset. + let moved = run("ec.sim.carddance", &[("cameraPosition/xyPosition", pt(30.0, 15.0))], src.clone(), 0.0); + assert!((moved.get(25, 10)[0] - src.get(15, 10)[0]).abs() < 0.03, "{:?} {:?}", moved.get(25, 10), src.get(15, 10)); + // Corner Pins: the layer's corners land on the pins. + let pins = [ + ("cameraSystem", Value::Enum(1)), + ("cornerPins/upperLeftCorner", pt(10.0, 5.0)), + ("cornerPins/upperRightCorner", pt(30.0, 5.0)), + ("cornerPins/lowerLeftCorner", pt(10.0, 25.0)), + ("cornerPins/lowerRightCorner", pt(30.0, 25.0)), + ]; + let cp = run("ec.sim.carddance", &pins, src.clone(), 0.0); + assert_eq!(cp.get(3, 3)[3], 0.0, "outside the pinned quad"); + assert!(cp.get(20, 15)[3] > 0.99); + assert!((cp.get(20, 15)[0] - src.get(20, 15)[0]).abs() < 0.05, "centre maps to centre"); + // Comp Camera: the identity camera reproduces the layer; a camera that doubles x moves it. + let ident = Scene { camera: Some([[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.002, 1.0]]), light: None, layer: None }; + let cc = run_env("ec.sim.carddance", &[("cameraSystem", Value::Enum(2))], src.clone(), 0.0, &ident); + assert!(max_diff(&cc, &src) < 0.02, "{}", max_diff(&cc, &src)); + let half = Scene { camera: Some([[0.5, 0.0, 0.0, 0.0], [0.0, 0.5, 0.0, 0.0], [0.0, 0.0, 0.002, 1.0]]), ..ident }; + let cc = run_env("ec.sim.carddance", &[("cameraSystem", Value::Enum(2))], src.clone(), 0.0, &half); + assert_eq!(cc.get(35, 25)[3], 0.0, "half-size view leaves the far corner empty"); +} + +#[test] +fn card_dance_lighting_and_material() { + let src = Image::filled(40, 30, [0.5, 0.5, 0.5, 1.0]); + let tilt = [("rows", num(1.0)), ("columns", num(1.0)), ("yRotation/yRotSource", Value::Enum(0)), ("yRotation/yRotOffset", num(60.0))]; + let lit = |extra: &[(&str, Value)]| { + let mut v: Vec<(&str, Value)> = tilt.to_vec(); + v.extend_from_slice(extra); + run("ec.sim.carddance", &v, src.clone(), 0.0).get(20, 15) + }; + let base = lit(&[]); + // Tilted cards facing away from a frontal light get darker than flat ones. + assert!(base[0] < 0.45, "{base:?}"); + // Light colour tints; intensity brightens; ambient alone lights evenly. + let red = lit(&[("lighting/lightColor", col(1.0, 0.0, 0.0))]); + assert!(red[0] > red[1] + 0.05); + assert!(lit(&[("lighting/lightIntensity", num(2.0))])[0] > base[0]); + let amb = lit(&[("lighting/ambientLight", num(1.0)), ("material/diffuse", num(0.0))]); + assert!((amb[0] - 0.5).abs() < 1e-3); + // A light placed to the side (Point Source) changes the shading of the tilted card. + let side = lit(&[("lighting/lightType", Value::Enum(1)), ("lighting/lightPosition", pt(200.0, 15.0)), ("lighting/lightDepth", num(0.2))]); + assert!((side[0] - base[0]).abs() > 0.02, "{side:?} vs {base:?}"); + // Specular adds a highlight on flat, frontally lit cards. + let flat = run("ec.sim.carddance", &[("material/specular", num(0.5))], src.clone(), 0.0).get(20, 15); + assert!(flat[0] > 0.9, "{flat:?}"); + // First Comp Light: no light in the comp → ambient only; a light from the host is used. + let none = Scene { camera: None, light: None, layer: None }; + let a = run_env("ec.sim.carddance", &[("lighting/lightType", Value::Enum(2))], src.clone(), 0.0, &none).get(20, 15); + assert!((a[0] - 0.5 * (0.25 + 0.75)).abs() < 1e-3, "{a:?}"); + let blue = Scene { light: Some(crate::CompLight { pos: [20.0, 15.0, -100.0], dir: [0.0, 0.0, 1.0], color: [0.0, 0.0, 1.0], kind: 0 }), ..none }; + let a = run_env("ec.sim.carddance", &[("lighting/lightType", Value::Enum(2))], src.clone(), 0.0, &blue).get(20, 15); + assert!(a[2] > a[0] + 0.2, "{a:?}"); +} + +#[test] +fn shatter_custom_map_gradient_textures_and_extrusion() { + let src = ramp(32, 32); + let base = [("view", Value::Enum(0)), ("force1/force1Position", pt(16.0, 16.0)), ("force1/force1Radius", num(2.0)), ("shape/origin", pt(16.0, 16.0))]; + let with = |extra: &[(&str, Value)], t: f64, host: Option<&Scene>| { + let mut v: Vec<(&str, Value)> = base.to_vec(); + v.extend_from_slice(extra); + match host { + Some(h) => run_env("ec.sim.shatter", &v, src.clone(), t, h), + None => run("ec.sim.shatter", &v, src.clone(), t), + } + }; + // Custom Shatter Map: a map white on the left and black on the right; with White Tiles + // Fixed the left half stays put while the right half breaks away. + let mut map = Image::new(32, 32); + for y in 0..32 { + for x in 0..32 { + map.set(x, y, if x < 16 { [1.0; 4] } else { [0.0, 0.0, 0.0, 1.0] }); + } + } + let host = Scene { camera: None, light: None, layer: Some(map) }; + let custom = [("shape/pattern", Value::Enum(5)), ("shape/customShatterMap", Value::Layer(Some(1))), ("shape/whiteTilesFixed", Value::Bool(true))]; + let at_rest = with(&custom, 0.0, Some(&host)); + assert!(max_diff(&at_rest, &src) < 0.05, "custom pieces tile the layer: {}", max_diff(&at_rest, &src)); + let broke = with(&custom, 1.0, Some(&host)); + assert!(max_diff_region(&broke, &src, 0..12) < 0.05, "white tiles fixed"); + assert!(sum_diff_region(&broke, &src, 20..32) > 5.0, "black group moved"); + // Gradient: a black gradient layer with threshold 50 % keeps everything together. + let black = Scene { camera: None, light: None, layer: Some(Image::filled(32, 32, [0.0, 0.0, 0.0, 1.0])) }; + let g = with(&[("gradient/gradientLayer", Value::Layer(Some(1))), ("gradient/shatterThreshold", num(50.0))], 1.0, Some(&black)); + assert!(max_diff(&g, &src) < 0.05); + let gi = with( + &[("gradient/gradientLayer", Value::Layer(Some(1))), ("gradient/shatterThreshold", num(50.0)), ("gradient/invertGradient", Value::Bool(true))], + 1.0, + Some(&black), + ); + assert!(sum_diff(&gi, &src) > 10.0); + // Textures: Front Mode Color paints the pieces flat; Opacity fades them. + let flat = with(&[("textures/frontMode", Value::Enum(0)), ("textures/color", col(0.0, 1.0, 0.0))], 0.0, None); + let p = flat.get(16, 16); + assert!(p[1] > 0.95 && p[0] < 0.05, "{p:?}"); + let half = with(&[("textures/opacity", num(0.5)), ("shape/repetitions", num(2.0))], 0.0, None); + assert!((half.get(8, 4)[3] - 0.5).abs() < 0.02, "{:?}", half.get(8, 4)); + // Extrusion: thicker pieces cover more once they tumble. + let cover = |d: f64| { + with(&[("shape/extrusionDepth", num(d)), ("textures/sideMode", Value::Enum(0)), ("textures/color", col(1.0, 1.0, 1.0))], 0.6, None) + .data + .iter() + .map(|p| p[3]) + .sum::() + }; + assert!(cover(1.0) > cover(0.0) + 1.0, "{} vs {}", cover(1.0), cover(0.0)); + // Deterministic and seek-consistent. + assert_eq!(with(&custom, 0.7, Some(&host)), with(&custom, 0.7, Some(&host))); +} + +fn max_diff_region(a: &Image, b: &Image, xs: std::ops::Range) -> f32 { + let mut m = 0.0f32; + for y in 0..a.height { + for x in xs.clone() { + let (p, q) = (a.get(x as i64, y as i64), b.get(x as i64, y as i64)); + m = (0..4).map(|k| (p[k] - q[k]).abs()).fold(m, f32::max); + } + } + m +} + +fn sum_diff_region(a: &Image, b: &Image, xs: std::ops::Range) -> f32 { + let mut s = 0.0; + for y in 0..a.height { + for x in xs.clone() { + let (p, q) = (a.get(x as i64, y as i64), b.get(x as i64, y as i64)); + s += (0..4).map(|k| (p[k] - q[k]).abs()).sum::(); + } + } + s +} + +/// Layer 1 = a left-dark, right-light ramp; layer 2 = solid green. +struct FoamHost; +impl crate::EffectHost for FoamHost { + fn layer(&self, id: u64, _: bool) -> Option { + let img = if id == 1 { ramp(32, 32) } else { Image::filled(32, 32, [0.0, 1.0, 0.0, 1.0]) }; + Some(crate::LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size: [32.0, 32.0] }) + } + fn layer_at(&self, id: u64, _: f64, me: bool) -> Option { + self.layer(id, me) + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } +} + +#[test] +fn foam_flow_map_pop_velocity_wobble_textures_and_reflection() { + let base = vec![ + ("producer/producerPoint", pt(16.0, 16.0)), + ("producer/productionRate", num(2.0)), + ("physics/windSpeed", num(0.0)), + ("physics/turbulence", num(0.0)), + ("view", Value::Enum(2)), + ]; + let with = |extra: &[(&str, Value)], t: f64| { + let mut v = base.clone(); + v.extend_from_slice(extra); + run_fx("ec.sim.foam", &v, Image::new(32, 32), t, EffectEnv { host: Some(&FoamHost), ..Default::default() }).img + }; + let cx = |img: &Image| { + let (mut s, mut n) = (0.0, 0.0); + for y in 0..32 { + for x in 0..32 { + let a = img.get(x, y)[3]; + s += a * x as f32; + n += a; + } + } + s / n.max(1e-6) + }; + // Flow map: bubbles run downhill (towards the dark left side). + let still = with(&[], 1.0); + let flowing = with(&[("flowMap/flowMap", Value::Layer(Some(1))), ("flowMap/flowMapSteepness", num(1.0))], 1.0); + assert!(cx(&flowing) < cx(&still) - 1.0, "{} vs {}", cx(&flowing), cx(&still)); + let hq = with(&[("flowMap/flowMap", Value::Layer(Some(1))), ("flowMap/flowMapSteepness", num(1.0)), ("flowMap/simulationQuality", Value::Enum(2))], 1.0); + assert_eq!( + hq, + with(&[("flowMap/flowMap", Value::Layer(Some(1))), ("flowMap/flowMapSteepness", num(1.0)), ("flowMap/simulationQuality", Value::Enum(2))], 1.0) + ); + // Pop velocity scatters the survivors when short-lived bubbles pop. + let short = [("bubbles/lifespan", num(8.0)), ("bubbles/strength", num(0.0))]; + let a = with(&[short.as_slice(), &[("physics/popVelocity", num(0.0))]].concat(), 1.0); + let b2 = with(&[short.as_slice(), &[("physics/popVelocity", num(20.0))]].concat(), 1.0); + assert_ne!(a, b2); + // Wobble changes the drawn bubbles. + assert_ne!(with(&[("physics/wobbleAmount", num(0.0))], 1.0), with(&[("physics/wobbleAmount", num(3.0))], 1.0)); + // User Defined texture: bubbles show the texture layer (green). + let tex = with(&[("rendering/bubbleTexture", Value::Enum(5)), ("rendering/bubbleTextureLayer", Value::Layer(Some(2)))], 1.0); + assert!(tex.data.iter().any(|p| p[1] > 0.5 && p[0] < 0.1)); + // Environment map: reflections tint the bubbles. + let refl = with(&[("rendering/environmentMap", Value::Layer(Some(2))), ("rendering/reflectionStrength", num(1.0))], 1.0); + let g = |i: &Image| i.data.iter().map(|p| p[1] - p[0]).sum::(); + assert!(g(&refl) > g(&still) + 1.0); + // Zoom Producer Point zooms about the producer instead of the layer centre. + let off = [("producer/producerPoint", pt(8.0, 8.0)), ("zoom", num(2.0))]; + let z1 = with(&off, 1.0); + let z2 = with(&[off.as_slice(), &[("producer/zoomProducerPoint", Value::Bool(true))]].concat(), 1.0); + assert!((cx(&z2) - 8.0).abs() < 2.0 && cx(&z1) < 4.0, "{} vs {}", cx(&z2), cx(&z1)); +} diff --git a/crates/effects/src/sim3.rs b/crates/effects/src/sim3.rs index 35c758faa..88a16d981 100644 --- a/crates/effects/src/sim3.rs +++ b/crates/effects/src/sim3.rs @@ -575,7 +575,8 @@ fn mapper_from(ctx: &EffectCtx, group: &str, ephemeral: bool) -> Option /// Draw a map layer frame as a particle: centred on (x, y) buffer px, rotated `angle` degrees /// and scaled, over `out`. -fn blit_layer(out: &mut Image, lp: &crate::LayerPixels, x: f64, y: f64, angle: f64, scale: [f64; 2], buf_scale: f64, alpha: f32) { +fn blit_layer(out: &mut Image, lp: &crate::LayerPixels, position: [f64; 2], angle: f64, scale: [f64; 2], buf_scale: f64, alpha: f32) { + let [x, y] = position; let (w, h) = (lp.size[0] * scale[0].abs() * buf_scale, lp.size[1] * scale[1].abs() * buf_scale); let r = (w * w + h * h).sqrt() * 0.5 + 1.0; let (x0, x1) = ((x - r).floor().max(0.0) as i64, (x + r).ceil().min(out.width as f64) as i64); @@ -925,7 +926,7 @@ impl PgPlan { pub fn finish(&self, mut b: Buf) -> Buf { let mut fx = splat(b.img.width, b.img.height, &self.sprites.sprites, self.sprites.acc); for q in &self.blits { - blit_layer(&mut fx, &self.frames[q.frame], q.x, q.y, q.angle, q.scale, b.scale, q.alpha); + blit_layer(&mut fx, &self.frames[q.frame], [q.x, q.y], q.angle, q.scale, b.scale, q.alpha); } // Particles replace the layer's own pixels. b.img = combine(&b.img, &fx, 5); @@ -1176,379 +1177,4 @@ pub fn specs() -> Vec { } #[cfg(test)] -mod tests { - use super::*; - use crate::{EffectEnv, MaskShape, run_fx}; - - fn run(vals: &[(&str, Value)], t: f64) -> Image { - run_fx("ec.sim.particleplayground", vals, Image::filled(80, 60, [0.2, 0.2, 0.2, 1.0]), t, EffectEnv::default()).img - } - - fn cannon(t: f64) -> Image { - run(&[("cannon/cannonPosition", Value::Vec2([40.0, 50.0])), ("cannon/velocity", num(60.0)), ("gravity/gravityForce", num(30.0))], t) - } - - #[test] - fn cannon_stream_is_seek_consistent() { - let a = cannon(1.0); - // Particles replace the layer: some coverage, mostly transparent, red. - let cov: f32 = a.data.iter().map(|p| p[3]).sum(); - assert!(cov > 5.0, "{cov}"); - assert!(a.data.iter().any(|p| p[3] > 0.5 && p[0] > 0.5 && p[1] < 0.1)); - // Seek back and forth: the same frame every time. - let _ = cannon(2.0); - let b = cannon(1.0); - assert_eq!(a.data, b.data); - let c = cannon(0.5); - assert_ne!(a.data, c.data); - // Nothing has been fired at time 0. - let z = cannon(0.0); - assert!(z.data.iter().all(|p| p[3] == 0.0)); - } - - #[test] - fn grid_falls_with_gravity_and_bounces_off_a_wall() { - let grid = |t: f64, env: EffectEnv| { - run_fx( - "ec.sim.particleplayground", - &[ - ("cannonEnabled", Value::Bool(false)), - ("grid/gridPosition", Value::Vec2([40.0, 10.0])), - ("grid/gridWidth", num(40.0)), - ("grid/gridHeight", num(0.0)), - ("grid/particlesAcross", num(5.0)), - ("grid/particlesDown", num(1.0)), - ("gravity/gravityForce", num(100.0)), - ("wall/wallBoundary", num(1.0)), - ], - Image::new(80, 60), - t, - env, - ) - .img - }; - // Alpha-weighted mean height of the particles. - let row_of = |img: &Image| { - let (mut s, mut n) = (0.0f64, 0.0f64); - for y in 0..60 { - for x in 0..80 { - let a = img.get(x, y)[3] as f64; - s += (y as f64 + 0.5) * a; - n += a; - } - } - (s / n.max(1e-9)).round() as i64 - }; - let t0 = grid(0.0, EffectEnv::default()); - let t1 = grid(0.5, EffectEnv::default()); - assert_eq!(row_of(&t0), 10); - // ½ g t² = 12.5 px. - assert!((row_of(&t1) - 22).abs() <= 2, "{}", row_of(&t1)); - // A wall (mask 1) whose floor is at y = 15 keeps them above it. - let masks = [MaskShape { name: "w".into(), points: vec![[0.0, 0.0], [80.0, 0.0], [80.0, 15.0], [0.0, 15.0]], closed: true, inverted: false }]; - let env = EffectEnv { masks: &masks, ..Default::default() }; - let w = grid(1.5, env); - assert!(row_of(&w) <= 16, "{}", row_of(&w)); - } - - #[test] - fn repel_spreads_particles() { - let spread = |force: f64| { - let img = run( - &[ - ("cannonEnabled", Value::Bool(false)), - ("grid/gridPosition", Value::Vec2([40.0, 30.0])), - ("grid/gridWidth", num(6.0)), - ("grid/gridHeight", num(6.0)), - ("grid/particlesAcross", num(3.0)), - ("grid/particlesDown", num(3.0)), - ("gravity/gravityForce", num(0.0)), - ("repel/repelForce", num(force)), - ("repel/repelForceRadius", num(20.0)), - ], - 0.5, - ); - let (mut x0, mut x1) = (80, 0); - for y in 0..60 { - for x in 0..80 { - if img.get(x, y)[3] > 0.3 { - x0 = x0.min(x); - x1 = x1.max(x); - } - } - } - x1 - x0 - }; - assert!(spread(0.2) > spread(0.0) + 4, "{} vs {}", spread(0.2), spread(0.0)); - } - - #[test] - fn property_mapper_and_layer_map_read_layers() { - // Without a host, the layer-based features are inert (deterministic output). - let a = run( - &[ - ("persistentPropertyMapper/mapRedTo", Value::Enum(8)), - ("persistentPropertyMapper/redMin", num(-50.0)), - ("persistentPropertyMapper/redMax", num(50.0)), - ], - 0.7, - ); - let b = run( - &[ - ("persistentPropertyMapper/mapRedTo", Value::Enum(8)), - ("persistentPropertyMapper/redMin", num(-50.0)), - ("persistentPropertyMapper/redMax", num(50.0)), - ], - 0.7, - ); - assert_eq!(a.data, b.data); - let st = - Pg { persistent: Some(mapper(Image::filled(4, 4, [1.0, 0.0, 0.5, 1.0]), [(15, 0, 0.0, 120.0), (0, 0, 0.0, 1.0), (9, 0, 0.0, 2.0)])), ..bare(1) }; - let mut s = PgState::default(); - s.parts.push(PgParticle::new([1.0, 1.0], [0.0; 2], [1.0; 4], 2.0, 0, SRC_GRID)); - st.step(&mut s); - // Red 1 → X speed 120 px/s; blue 0.5 → scale 1. - assert!((s.parts[0].v[0] - 120.0).abs() < 1e-3); - assert!((s.parts[0].scale[0] - 1.0).abs() < 1e-3); - } - - /// A Pg with nothing switched on. - fn bare(seed: u32) -> Pg { - Pg { - cannon: false, - pos: [0.0; 2], - barrel_angle: 0.0, - barrel_radius: 0.0, - rate: 0.0, - dir_spread: 0.0, - vel: 0.0, - vel_spread: 0.0, - color: [1.0; 4], - radius: 2.0, - cannon_text: vec![], - pex_radius: 0.0, - pex_dispersion: 0.0, - pex_affects: Affects::default(), - gravity: [0.0; 2], - grav_spread: 0.0, - grav_affects: Affects::default(), - repel: 0.0, - repel_radius: 0.0, - repel_affects: Affects::default(), - wall: None, - wall_affects: Affects::default(), - persistent: None, - ephemeral: None, - seed, - bounds: [-1e6, -1e6, 1e6, 1e6], - } - } - - fn mapper(img: Image, chans: [(u32, u32, f32, f32); 3]) -> Mapper { - Mapper { map: MapImg { img, k: [1.0; 2], o: [0.0; 2] }, chans, affects: Affects::default() } - } - - #[test] - fn barrel_radius_square_or_disc() { - let pg = |r: f32| Pg { cannon: true, barrel_radius: r, ..bare(3) }; - let (sq, disc) = (pg(10.0), pg(-10.0)); - let mut corner = false; - for id in 0..400 { - let a = sq.spawn(id).p; - assert!(a[0].abs() <= 10.0 && a[1].abs() <= 10.0); - // A square barrel fills its corners (both offsets in use, not a line). - corner |= a[0].abs() > 7.5 && a[1].abs() > 7.5; - let b = disc.spawn(id).p; - assert!(b[0].hypot(b[1]) <= 10.0 + 1e-4); - } - assert!(corner); - // Radius 0: every particle starts at the cannon position. - assert_eq!(pg(0.0).spawn(5).p, [0.0, 0.0]); - } - - #[test] - fn mapper_drives_position_and_opacity_in_ae_order() { - let st = - Pg { persistent: Some(mapper(Image::filled(4, 4, [1.0, 0.5, 0.25, 1.0]), [(12, 0, 0.0, 30.0), (20, 0, 0.0, 1.0), (4, 0, 0.0, 1.0)])), ..bare(1) }; - assert_eq!(MAP_TARGETS[12], "X"); - assert_eq!(MAP_TARGETS[20], "Opacity"); - assert_eq!(MAP_TARGETS[26], "Scale Speed"); - let mut s = PgState::default(); - s.parts.push(PgParticle::new([1.0, 1.0], [0.0; 2], [1.0; 4], 2.0, 0, SRC_GRID)); - st.step(&mut s); - assert!((s.parts[0].p[0] - 30.0).abs() < 1e-3, "{:?}", s.parts[0].p); - assert!((s.parts[0].c[3] - 0.5).abs() < 1e-3); - assert!((s.parts[0].friction - 0.25).abs() < 1e-3); - } - - #[test] - fn ephemeral_mapper_holds_for_one_step_with_operators() { - // Ephemeral X Speed = 60 px/s (Set): the particle moves, but keeps no velocity. - let st = Pg { ephemeral: Some(mapper(Image::filled(4, 4, [1.0, 1.0, 1.0, 1.0]), [(15, 0, 0.0, 60.0), (0, 0, 0.0, 1.0), (0, 0, 0.0, 1.0)])), ..bare(1) }; - let mut s = PgState::default(); - s.parts.push(PgParticle::new([1.0, 1.0], [0.0; 2], [1.0; 4], 2.0, 0, SRC_GRID)); - st.step(&mut s); - assert!((s.parts[0].p[0] - 2.0).abs() < 1e-4, "{:?}", s.parts[0].p); - assert!(s.parts[0].v[0].abs() < 1e-4); - // Operators: Add / Multiply / Max on the particle's own value. - let op = |o: u32, cur: f32| { - let m = mapper(Image::filled(4, 4, [1.0, 1.0, 1.0, 1.0]), [(21, o, 0.0, 3.0), (0, 0, 0.0, 1.0), (0, 0, 0.0, 1.0)]); - let mut q = PgParticle::new([1.0, 1.0], [0.0; 2], [1.0; 4], 2.0, 0, SRC_GRID); - q.mass = cur; - m.apply(&mut q); - q.mass - }; - assert_eq!(MAP_OPERATORS[1], "Add"); - assert!((op(1, 2.0) - 5.0).abs() < 1e-5); - assert!((op(4, 2.0) - 6.0).abs() < 1e-5); - assert!((op(6, 5.0) - 5.0).abs() < 1e-5); - assert!((op(2, 5.0) - 2.0).abs() < 1e-5, "Difference"); - } - - #[test] - fn affects_select_by_source_age_and_characters() { - let mut q = PgParticle::new([0.0; 2], [0.0; 2], [1.0; 4], 2.0, 0, SRC_CANNON); - let a = Affects { from: 2, ..Default::default() }; - assert_eq!(a.weight(&q), 0.0, "grid only"); - q.src = SRC_GRID; - assert_eq!(a.weight(&q), 1.0); - let older = Affects { age: 1.0, ..Default::default() }; - q.age = 0.5; - assert_eq!(older.weight(&q), 0.0); - q.age = 1.5; - assert_eq!(older.weight(&q), 1.0); - let younger = Affects { age: -1.0, feather: 1.0, ..Default::default() }; - q.age = 1.0; - assert!((younger.weight(&q) - 0.5).abs() < 1e-5, "feathered"); - let chars = Affects { chars: vec!['A', 'B'], ..Default::default() }; - q.ch = 'B' as u32; - assert_eq!(chars.weight(&q), 1.0); - q.ch = 'C' as u32; - assert_eq!(chars.weight(&q), 0.0); - // Gravity restricted to the cannon leaves grid particles in place. - let st = Pg { gravity: [0.0, 100.0], grav_affects: Affects { from: 1, ..Default::default() }, ..bare(1) }; - let mut s = PgState::default(); - s.parts.push(PgParticle::new([5.0, 5.0], [0.0; 2], [1.0; 4], 2.0, 0, SRC_GRID)); - s.parts.push(PgParticle::new([5.0, 5.0], [0.0; 2], [1.0; 4], 2.0, 1, SRC_CANNON)); - for _ in 0..10 { - st.step(&mut s); - } - assert_eq!(s.parts[0].p, [5.0, 5.0]); - assert!(s.parts[1].p[1] > 5.0); - } - - #[test] - fn particle_exploder_bursts_big_particles() { - let st = Pg { pex_radius: 2.0, pex_dispersion: 30.0, ..bare(1) }; - let mut s = PgState::default(); - s.parts.push(PgParticle::new([50.0, 50.0], [0.0; 2], [1.0; 4], 6.0, 0, SRC_GRID)); - s.next_id = 1; - st.step(&mut s); - assert_eq!(s.parts.len(), 9, "(6/2)² new particles"); - assert!(s.parts.iter().all(|q| q.r == 2.0 && q.src == SRC_PARTICLE_EXPLODER)); - st.step(&mut s); - assert_eq!(s.parts.len(), 9, "new particles do not burst again"); - // Lifespan from a mapper removes particles once they are older. - let life = Pg { persistent: Some(mapper(Image::filled(4, 4, [1.0; 4]), [(22, 0, 0.0, 0.05), (0, 0, 0.0, 1.0), (0, 0, 0.0, 1.0)])), ..bare(1) }; - let mut s = PgState::default(); - s.parts.push(PgParticle::new([1.0, 1.0], [0.0; 2], [1.0; 4], 2.0, 0, SRC_GRID)); - for _ in 0..2 { - life.step(&mut s); - } - assert_eq!(s.parts.len(), 1); - for _ in 0..3 { - life.step(&mut s); - } - assert!(s.parts.is_empty()); - } - - #[test] - fn text_particles_draw_characters() { - let grid = |text: &str| { - run( - &[ - ("cannonEnabled", Value::Bool(false)), - ("grid/gridPosition", Value::Vec2([40.0, 30.0])), - ("grid/gridWidth", num(40.0)), - ("grid/gridHeight", num(0.0)), - ("grid/particlesAcross", num(3.0)), - ("grid/particlesDown", num(1.0)), - ("grid/gridParticleRadius", num(16.0)), - ("gravity/gravityForce", num(0.0)), - ("options/gridText", Value::Str(text.into())), - ], - 0.1, - ) - }; - let dots = grid(""); - let text = grid("I-O"); - assert_ne!(dots.data, text.data); - // "I" is a vertical bar: ink above and below the grid point, not a round dot. - assert!(text.get(20, 26)[3] > 0.3 && text.get(20, 34)[3] > 0.3, "{:?}", text.get(20, 26)); - // A space leaves its grid point empty. - let gap = grid("I O"); - assert!(gap.get(40, 30)[3] < 0.05); - // Cannon text: particles carry the characters in order. - let pg = Pg { cannon_text: vec!['A', 'B'], ..bare(1) }; - assert_eq!(pg.spawn(0).ch, 'A' as u32); - assert_eq!(pg.spawn(3).ch, 'B' as u32); - } - - #[test] - fn layer_map_shows_the_map_layer_at_its_time_offset() { - /// A 6×6 map layer whose colour encodes the comp time it was fetched at. - struct Clock; - impl crate::EffectHost for Clock { - fn layer(&self, _: u64, _: bool) -> Option { - None - } - fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { - None - } - fn layer_at(&self, _: u64, t: f64, _: bool) -> Option { - let v = (t as f32 / 4.0).clamp(0.0, 1.0); - Some(crate::LayerPixels { buf: Buf { img: Image::filled(6, 6, [v, 0.0, 1.0 - v, 1.0]), offset: [0.0; 2], scale: 1.0 }, size: [6.0, 6.0] }) - } - } - let render = |kind: u32, offset: f64| { - let vals = [ - ("cannonEnabled", Value::Bool(false)), - ("grid/gridPosition", Value::Vec2([40.0, 30.0])), - ("grid/particlesAcross", num(1.0)), - ("grid/particlesDown", num(1.0)), - ("gravity/gravityForce", num(0.0)), - ("layerMap/layerMapLayer", Value::Layer(Some(9))), - ("layerMap/timeOffsetType", Value::Enum(kind)), - ("layerMap/timeOffset", num(offset)), - ]; - let env = EffectEnv { host: Some(&Clock), comp_time: 1.0, frame_rate: 10.0, ..Default::default() }; - run_fx("ec.sim.particleplayground", &vals, Image::filled(80, 60, [0.2, 0.2, 0.2, 1.0]), 1.0, env).img - }; - // The particle is a 6×6 copy of the map layer. - let rel = render(0, 1.0); - assert!(rel.get(40, 30)[3] > 0.99 && rel.get(46, 30)[3] < 0.01); - assert!((rel.get(40, 30)[0] - 0.5).abs() < 1e-3, "relative: now + 1 s = 2 s"); - let abs = render(1, 3.0); - assert!((abs.get(40, 30)[0] - 0.75).abs() < 1e-3, "absolute: 3 s"); - } - - #[test] - fn cc_hair_grows_from_opaque_pixels() { - let mut img = Image::new(60, 60); - for y in 20..40 { - for x in 20..40 { - img.set(x, y, [0.8, 0.6, 0.4, 1.0]); - } - } - let out = run_fx("ec.sim.cchair", &[("length", num(12.0)), ("density", num(300.0))], img.clone(), 0.0, EffectEnv::default()); - // Hair reaches beyond the square (above it: hair leans up, then droops). - let outside: f32 = (0..60).flat_map(|x| (0..20).map(move |y| (x, y))).map(|(x, y)| out.img.get(x, y)[3]).sum(); - assert!(outside > 1.0, "{outside}"); - // Nothing grows from transparent areas far away. - assert_eq!(out.img.get(2, 58)[3], 0.0); - let again = run_fx("ec.sim.cchair", &[("length", num(12.0)), ("density", num(300.0))], img.clone(), 3.0, EffectEnv::default()); - assert_eq!(out.img.data, again.img.data); - let none = run_fx("ec.sim.cchair", &[("length", num(0.0))], img.clone(), 0.0, EffectEnv::default()); - assert_eq!(none.img.data, img.data); - } -} +mod tests; diff --git a/crates/effects/src/sim3/tests.rs b/crates/effects/src/sim3/tests.rs new file mode 100644 index 000000000..ffce0ae6d --- /dev/null +++ b/crates/effects/src/sim3/tests.rs @@ -0,0 +1,372 @@ +use super::*; +use crate::{EffectEnv, MaskShape, run_fx}; + +fn run(vals: &[(&str, Value)], t: f64) -> Image { + run_fx("ec.sim.particleplayground", vals, Image::filled(80, 60, [0.2, 0.2, 0.2, 1.0]), t, EffectEnv::default()).img +} + +fn cannon(t: f64) -> Image { + run(&[("cannon/cannonPosition", Value::Vec2([40.0, 50.0])), ("cannon/velocity", num(60.0)), ("gravity/gravityForce", num(30.0))], t) +} + +#[test] +fn cannon_stream_is_seek_consistent() { + let a = cannon(1.0); + // Particles replace the layer: some coverage, mostly transparent, red. + let cov: f32 = a.data.iter().map(|p| p[3]).sum(); + assert!(cov > 5.0, "{cov}"); + assert!(a.data.iter().any(|p| p[3] > 0.5 && p[0] > 0.5 && p[1] < 0.1)); + // Seek back and forth: the same frame every time. + let _ = cannon(2.0); + let b = cannon(1.0); + assert_eq!(a.data, b.data); + let c = cannon(0.5); + assert_ne!(a.data, c.data); + // Nothing has been fired at time 0. + let z = cannon(0.0); + assert!(z.data.iter().all(|p| p[3] == 0.0)); +} + +#[test] +fn grid_falls_with_gravity_and_bounces_off_a_wall() { + let grid = |t: f64, env: EffectEnv| { + run_fx( + "ec.sim.particleplayground", + &[ + ("cannonEnabled", Value::Bool(false)), + ("grid/gridPosition", Value::Vec2([40.0, 10.0])), + ("grid/gridWidth", num(40.0)), + ("grid/gridHeight", num(0.0)), + ("grid/particlesAcross", num(5.0)), + ("grid/particlesDown", num(1.0)), + ("gravity/gravityForce", num(100.0)), + ("wall/wallBoundary", num(1.0)), + ], + Image::new(80, 60), + t, + env, + ) + .img + }; + // Alpha-weighted mean height of the particles. + let row_of = |img: &Image| { + let (mut s, mut n) = (0.0f64, 0.0f64); + for y in 0..60 { + for x in 0..80 { + let a = img.get(x, y)[3] as f64; + s += (y as f64 + 0.5) * a; + n += a; + } + } + (s / n.max(1e-9)).round() as i64 + }; + let t0 = grid(0.0, EffectEnv::default()); + let t1 = grid(0.5, EffectEnv::default()); + assert_eq!(row_of(&t0), 10); + // ½ g t² = 12.5 px. + assert!((row_of(&t1) - 22).abs() <= 2, "{}", row_of(&t1)); + // A wall (mask 1) whose floor is at y = 15 keeps them above it. + let masks = [MaskShape { name: "w".into(), points: vec![[0.0, 0.0], [80.0, 0.0], [80.0, 15.0], [0.0, 15.0]], closed: true, inverted: false }]; + let env = EffectEnv { masks: &masks, ..Default::default() }; + let w = grid(1.5, env); + assert!(row_of(&w) <= 16, "{}", row_of(&w)); +} + +#[test] +fn repel_spreads_particles() { + let spread = |force: f64| { + let img = run( + &[ + ("cannonEnabled", Value::Bool(false)), + ("grid/gridPosition", Value::Vec2([40.0, 30.0])), + ("grid/gridWidth", num(6.0)), + ("grid/gridHeight", num(6.0)), + ("grid/particlesAcross", num(3.0)), + ("grid/particlesDown", num(3.0)), + ("gravity/gravityForce", num(0.0)), + ("repel/repelForce", num(force)), + ("repel/repelForceRadius", num(20.0)), + ], + 0.5, + ); + let (mut x0, mut x1) = (80, 0); + for y in 0..60 { + for x in 0..80 { + if img.get(x, y)[3] > 0.3 { + x0 = x0.min(x); + x1 = x1.max(x); + } + } + } + x1 - x0 + }; + assert!(spread(0.2) > spread(0.0) + 4, "{} vs {}", spread(0.2), spread(0.0)); +} + +#[test] +fn property_mapper_and_layer_map_read_layers() { + // Without a host, the layer-based features are inert (deterministic output). + let a = run( + &[ + ("persistentPropertyMapper/mapRedTo", Value::Enum(8)), + ("persistentPropertyMapper/redMin", num(-50.0)), + ("persistentPropertyMapper/redMax", num(50.0)), + ], + 0.7, + ); + let b = run( + &[ + ("persistentPropertyMapper/mapRedTo", Value::Enum(8)), + ("persistentPropertyMapper/redMin", num(-50.0)), + ("persistentPropertyMapper/redMax", num(50.0)), + ], + 0.7, + ); + assert_eq!(a.data, b.data); + let st = Pg { persistent: Some(mapper(Image::filled(4, 4, [1.0, 0.0, 0.5, 1.0]), [(15, 0, 0.0, 120.0), (0, 0, 0.0, 1.0), (9, 0, 0.0, 2.0)])), ..bare(1) }; + let mut s = PgState::default(); + s.parts.push(PgParticle::new([1.0, 1.0], [0.0; 2], [1.0; 4], 2.0, 0, SRC_GRID)); + st.step(&mut s); + // Red 1 → X speed 120 px/s; blue 0.5 → scale 1. + assert!((s.parts[0].v[0] - 120.0).abs() < 1e-3); + assert!((s.parts[0].scale[0] - 1.0).abs() < 1e-3); +} + +/// A Pg with nothing switched on. +fn bare(seed: u32) -> Pg { + Pg { + cannon: false, + pos: [0.0; 2], + barrel_angle: 0.0, + barrel_radius: 0.0, + rate: 0.0, + dir_spread: 0.0, + vel: 0.0, + vel_spread: 0.0, + color: [1.0; 4], + radius: 2.0, + cannon_text: vec![], + pex_radius: 0.0, + pex_dispersion: 0.0, + pex_affects: Affects::default(), + gravity: [0.0; 2], + grav_spread: 0.0, + grav_affects: Affects::default(), + repel: 0.0, + repel_radius: 0.0, + repel_affects: Affects::default(), + wall: None, + wall_affects: Affects::default(), + persistent: None, + ephemeral: None, + seed, + bounds: [-1e6, -1e6, 1e6, 1e6], + } +} + +fn mapper(img: Image, chans: [(u32, u32, f32, f32); 3]) -> Mapper { + Mapper { map: MapImg { img, k: [1.0; 2], o: [0.0; 2] }, chans, affects: Affects::default() } +} + +#[test] +fn barrel_radius_square_or_disc() { + let pg = |r: f32| Pg { cannon: true, barrel_radius: r, ..bare(3) }; + let (sq, disc) = (pg(10.0), pg(-10.0)); + let mut corner = false; + for id in 0..400 { + let a = sq.spawn(id).p; + assert!(a[0].abs() <= 10.0 && a[1].abs() <= 10.0); + // A square barrel fills its corners (both offsets in use, not a line). + corner |= a[0].abs() > 7.5 && a[1].abs() > 7.5; + let b = disc.spawn(id).p; + assert!(b[0].hypot(b[1]) <= 10.0 + 1e-4); + } + assert!(corner); + // Radius 0: every particle starts at the cannon position. + assert_eq!(pg(0.0).spawn(5).p, [0.0, 0.0]); +} + +#[test] +fn mapper_drives_position_and_opacity_in_ae_order() { + let st = Pg { persistent: Some(mapper(Image::filled(4, 4, [1.0, 0.5, 0.25, 1.0]), [(12, 0, 0.0, 30.0), (20, 0, 0.0, 1.0), (4, 0, 0.0, 1.0)])), ..bare(1) }; + assert_eq!(MAP_TARGETS[12], "X"); + assert_eq!(MAP_TARGETS[20], "Opacity"); + assert_eq!(MAP_TARGETS[26], "Scale Speed"); + let mut s = PgState::default(); + s.parts.push(PgParticle::new([1.0, 1.0], [0.0; 2], [1.0; 4], 2.0, 0, SRC_GRID)); + st.step(&mut s); + assert!((s.parts[0].p[0] - 30.0).abs() < 1e-3, "{:?}", s.parts[0].p); + assert!((s.parts[0].c[3] - 0.5).abs() < 1e-3); + assert!((s.parts[0].friction - 0.25).abs() < 1e-3); +} + +#[test] +fn ephemeral_mapper_holds_for_one_step_with_operators() { + // Ephemeral X Speed = 60 px/s (Set): the particle moves, but keeps no velocity. + let st = Pg { ephemeral: Some(mapper(Image::filled(4, 4, [1.0, 1.0, 1.0, 1.0]), [(15, 0, 0.0, 60.0), (0, 0, 0.0, 1.0), (0, 0, 0.0, 1.0)])), ..bare(1) }; + let mut s = PgState::default(); + s.parts.push(PgParticle::new([1.0, 1.0], [0.0; 2], [1.0; 4], 2.0, 0, SRC_GRID)); + st.step(&mut s); + assert!((s.parts[0].p[0] - 2.0).abs() < 1e-4, "{:?}", s.parts[0].p); + assert!(s.parts[0].v[0].abs() < 1e-4); + // Operators: Add / Multiply / Max on the particle's own value. + let op = |o: u32, cur: f32| { + let m = mapper(Image::filled(4, 4, [1.0, 1.0, 1.0, 1.0]), [(21, o, 0.0, 3.0), (0, 0, 0.0, 1.0), (0, 0, 0.0, 1.0)]); + let mut q = PgParticle::new([1.0, 1.0], [0.0; 2], [1.0; 4], 2.0, 0, SRC_GRID); + q.mass = cur; + m.apply(&mut q); + q.mass + }; + assert_eq!(MAP_OPERATORS[1], "Add"); + assert!((op(1, 2.0) - 5.0).abs() < 1e-5); + assert!((op(4, 2.0) - 6.0).abs() < 1e-5); + assert!((op(6, 5.0) - 5.0).abs() < 1e-5); + assert!((op(2, 5.0) - 2.0).abs() < 1e-5, "Difference"); +} + +#[test] +fn affects_select_by_source_age_and_characters() { + let mut q = PgParticle::new([0.0; 2], [0.0; 2], [1.0; 4], 2.0, 0, SRC_CANNON); + let a = Affects { from: 2, ..Default::default() }; + assert_eq!(a.weight(&q), 0.0, "grid only"); + q.src = SRC_GRID; + assert_eq!(a.weight(&q), 1.0); + let older = Affects { age: 1.0, ..Default::default() }; + q.age = 0.5; + assert_eq!(older.weight(&q), 0.0); + q.age = 1.5; + assert_eq!(older.weight(&q), 1.0); + let younger = Affects { age: -1.0, feather: 1.0, ..Default::default() }; + q.age = 1.0; + assert!((younger.weight(&q) - 0.5).abs() < 1e-5, "feathered"); + let chars = Affects { chars: vec!['A', 'B'], ..Default::default() }; + q.ch = 'B' as u32; + assert_eq!(chars.weight(&q), 1.0); + q.ch = 'C' as u32; + assert_eq!(chars.weight(&q), 0.0); + // Gravity restricted to the cannon leaves grid particles in place. + let st = Pg { gravity: [0.0, 100.0], grav_affects: Affects { from: 1, ..Default::default() }, ..bare(1) }; + let mut s = PgState::default(); + s.parts.push(PgParticle::new([5.0, 5.0], [0.0; 2], [1.0; 4], 2.0, 0, SRC_GRID)); + s.parts.push(PgParticle::new([5.0, 5.0], [0.0; 2], [1.0; 4], 2.0, 1, SRC_CANNON)); + for _ in 0..10 { + st.step(&mut s); + } + assert_eq!(s.parts[0].p, [5.0, 5.0]); + assert!(s.parts[1].p[1] > 5.0); +} + +#[test] +fn particle_exploder_bursts_big_particles() { + let st = Pg { pex_radius: 2.0, pex_dispersion: 30.0, ..bare(1) }; + let mut s = PgState::default(); + s.parts.push(PgParticle::new([50.0, 50.0], [0.0; 2], [1.0; 4], 6.0, 0, SRC_GRID)); + s.next_id = 1; + st.step(&mut s); + assert_eq!(s.parts.len(), 9, "(6/2)² new particles"); + assert!(s.parts.iter().all(|q| q.r == 2.0 && q.src == SRC_PARTICLE_EXPLODER)); + st.step(&mut s); + assert_eq!(s.parts.len(), 9, "new particles do not burst again"); + // Lifespan from a mapper removes particles once they are older. + let life = Pg { persistent: Some(mapper(Image::filled(4, 4, [1.0; 4]), [(22, 0, 0.0, 0.05), (0, 0, 0.0, 1.0), (0, 0, 0.0, 1.0)])), ..bare(1) }; + let mut s = PgState::default(); + s.parts.push(PgParticle::new([1.0, 1.0], [0.0; 2], [1.0; 4], 2.0, 0, SRC_GRID)); + for _ in 0..2 { + life.step(&mut s); + } + assert_eq!(s.parts.len(), 1); + for _ in 0..3 { + life.step(&mut s); + } + assert!(s.parts.is_empty()); +} + +#[test] +fn text_particles_draw_characters() { + let grid = |text: &str| { + run( + &[ + ("cannonEnabled", Value::Bool(false)), + ("grid/gridPosition", Value::Vec2([40.0, 30.0])), + ("grid/gridWidth", num(40.0)), + ("grid/gridHeight", num(0.0)), + ("grid/particlesAcross", num(3.0)), + ("grid/particlesDown", num(1.0)), + ("grid/gridParticleRadius", num(16.0)), + ("gravity/gravityForce", num(0.0)), + ("options/gridText", Value::Str(text.into())), + ], + 0.1, + ) + }; + let dots = grid(""); + let text = grid("I-O"); + assert_ne!(dots.data, text.data); + // "I" is a vertical bar: ink above and below the grid point, not a round dot. + assert!(text.get(20, 26)[3] > 0.3 && text.get(20, 34)[3] > 0.3, "{:?}", text.get(20, 26)); + // A space leaves its grid point empty. + let gap = grid("I O"); + assert!(gap.get(40, 30)[3] < 0.05); + // Cannon text: particles carry the characters in order. + let pg = Pg { cannon_text: vec!['A', 'B'], ..bare(1) }; + assert_eq!(pg.spawn(0).ch, 'A' as u32); + assert_eq!(pg.spawn(3).ch, 'B' as u32); +} + +#[test] +fn layer_map_shows_the_map_layer_at_its_time_offset() { + /// A 6×6 map layer whose colour encodes the comp time it was fetched at. + struct Clock; + impl crate::EffectHost for Clock { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn layer_at(&self, _: u64, t: f64, _: bool) -> Option { + let v = (t as f32 / 4.0).clamp(0.0, 1.0); + Some(crate::LayerPixels { buf: Buf { img: Image::filled(6, 6, [v, 0.0, 1.0 - v, 1.0]), offset: [0.0; 2], scale: 1.0 }, size: [6.0, 6.0] }) + } + } + let render = |kind: u32, offset: f64| { + let vals = [ + ("cannonEnabled", Value::Bool(false)), + ("grid/gridPosition", Value::Vec2([40.0, 30.0])), + ("grid/particlesAcross", num(1.0)), + ("grid/particlesDown", num(1.0)), + ("gravity/gravityForce", num(0.0)), + ("layerMap/layerMapLayer", Value::Layer(Some(9))), + ("layerMap/timeOffsetType", Value::Enum(kind)), + ("layerMap/timeOffset", num(offset)), + ]; + let env = EffectEnv { host: Some(&Clock), comp_time: 1.0, frame_rate: 10.0, ..Default::default() }; + run_fx("ec.sim.particleplayground", &vals, Image::filled(80, 60, [0.2, 0.2, 0.2, 1.0]), 1.0, env).img + }; + // The particle is a 6×6 copy of the map layer. + let rel = render(0, 1.0); + assert!(rel.get(40, 30)[3] > 0.99 && rel.get(46, 30)[3] < 0.01); + assert!((rel.get(40, 30)[0] - 0.5).abs() < 1e-3, "relative: now + 1 s = 2 s"); + let abs = render(1, 3.0); + assert!((abs.get(40, 30)[0] - 0.75).abs() < 1e-3, "absolute: 3 s"); +} + +#[test] +fn cc_hair_grows_from_opaque_pixels() { + let mut img = Image::new(60, 60); + for y in 20..40 { + for x in 20..40 { + img.set(x, y, [0.8, 0.6, 0.4, 1.0]); + } + } + let out = run_fx("ec.sim.cchair", &[("length", num(12.0)), ("density", num(300.0))], img.clone(), 0.0, EffectEnv::default()); + // Hair reaches beyond the square (above it: hair leans up, then droops). + let outside: f32 = (0..60).flat_map(|x| (0..20).map(move |y| (x, y))).map(|(x, y)| out.img.get(x, y)[3]).sum(); + assert!(outside > 1.0, "{outside}"); + // Nothing grows from transparent areas far away. + assert_eq!(out.img.get(2, 58)[3], 0.0); + let again = run_fx("ec.sim.cchair", &[("length", num(12.0)), ("density", num(300.0))], img.clone(), 3.0, EffectEnv::default()); + assert_eq!(out.img.data, again.img.data); + let none = run_fx("ec.sim.cchair", &[("length", num(0.0))], img.clone(), 0.0, EffectEnv::default()); + assert_eq!(none.img.data, img.data); +} diff --git a/crates/effects/src/stylize3.rs b/crates/effects/src/stylize3.rs index 403c325cd..e28f9a4fa 100644 --- a/crates/effects/src/stylize3.rs +++ b/crates/effects/src/stylize3.rs @@ -351,7 +351,7 @@ fn hex_cell(x: f64, y: f64, r: f64) -> ((f64, f64), (i64, i64)) { let rr = (2.0 / 3.0 * y) / r; // Cube rounding. let (cx, cz) = (q, rr); - let cy = -cx - cz; + let cy = (-3f64.sqrt() / 3.0 * x - y / 3.0) / r; let (mut rx, ry, mut rz) = (cx.round(), cy.round(), cz.round()); let (dx, dy, dz) = ((rx - cx).abs(), (ry - cy).abs(), (rz - cz).abs()); if dx > dy && dx > dz { diff --git a/crates/effects/src/util.rs b/crates/effects/src/util.rs index ae55d095d..9c81dab10 100644 --- a/crates/effects/src/util.rs +++ b/crates/effects/src/util.rs @@ -6,6 +6,10 @@ use effectcraft_color::{luminance, rgb_to_hsl}; use effectcraft_raster::{Image, Px}; use rayon::prelude::*; +/// Below this many pixels, `Plane::transpose` and `Plane::map_rows` run on the calling thread: +/// the rayon dispatch costs more than the work on small crops (the Match Grain sample boxes). +const PAR_MIN: usize = 4096; + /// A single-channel `f32` image (row-major). #[derive(Clone, Debug, Default, PartialEq)] pub struct Plane { @@ -62,6 +66,15 @@ impl Plane { pub fn transpose(&self) -> Plane { let (w, h) = (self.w, self.h); let mut out = Plane::new(h, w); + if w.saturating_mul(h) < PAR_MIN { + // Small planes: the rayon dispatch costs more than the copy itself. + for (x, col) in out.data.chunks_mut(h.max(1)).enumerate() { + for y in 0..h { + col[y] = self.data[y * w + x]; + } + } + return out; + } out.data.par_chunks_mut(h.max(1)).enumerate().for_each(|(x, col)| { for y in 0..h { col[y] = self.data[y * w + x]; @@ -75,6 +88,11 @@ impl Plane { if self.w == 0 { return out; } + if self.w.saturating_mul(self.h) < PAR_MIN { + // Small planes: the rayon dispatch costs more than the rows themselves. + out.data.chunks_mut(self.w).zip(self.data.chunks(self.w)).for_each(|(o, s)| f(s, o)); + return out; + } out.data.par_chunks_mut(self.w).zip(self.data.par_chunks(self.w)).for_each(|(o, s)| f(s, o)); out } @@ -534,14 +552,29 @@ impl SimCache { /// State after `target` steps. `init` builds step 0; `step(state, i)` advances from step /// `i` to `i + 1`. - pub fn run(&self, key: u64, target: u64, init: impl FnOnce() -> S, mut step: impl FnMut(&mut S, u64)) -> std::sync::Arc { + pub fn run(&self, key: u64, target: u64, init: impl FnOnce() -> S, step: impl FnMut(&mut S, u64)) -> std::sync::Arc { + self.run_where(key, target, init, step, |_, _| true) + } + + /// [`SimCache::run`] for a simulation whose steps also depend on inputs outside `key` (a + /// parameter's values over time): a checkpoint after `s` steps is only resumed from when + /// `valid(s, state)` says it was reached with the same inputs. Fresh checkpoints replace + /// those at the same step. + pub fn run_where( + &self, + key: u64, + target: u64, + init: impl FnOnce() -> S, + mut step: impl FnMut(&mut S, u64), + valid: impl Fn(u64, &S) -> bool, + ) -> std::sync::Arc { use std::sync::Arc; let start = { let mut g = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let tick = g.iter().map(|e| e.used).max().unwrap_or(0) + 1; g.iter_mut().find(|e| e.key == key).and_then(|e| { e.used = tick; - e.checkpoints.iter().filter(|(s, _)| *s <= target).max_by_key(|(s, _)| *s).map(|(s, st)| (*s, st.clone())) + e.checkpoints.iter().filter(|(s, st)| *s <= target && valid(*s, st)).max_by_key(|(s, _)| *s).map(|(s, st)| (*s, st.clone())) }) }; let had_start = start.is_some(); @@ -579,9 +612,8 @@ impl SimCache { let e = &mut g[idx]; e.used = tick; for (s, st) in fresh { - if !e.checkpoints.iter().any(|(c, _)| *c == s) { - e.checkpoints.push((s, st)); - } + e.checkpoints.retain(|(c, _)| *c != s); + e.checkpoints.push((s, st)); } // Keep memory bounded: drop unaligned (exact-target) checkpoints first, then thin. if e.checkpoints.len() > 256 { diff --git a/crates/effects/src/utility.rs b/crates/effects/src/utility.rs index 1d3b3ee4a..e41b42e99 100644 --- a/crates/effects/src/utility.rs +++ b/crates/effects/src/utility.rs @@ -100,17 +100,11 @@ impl Cineon { fn knee(&self) -> f32 { 1.0 - (self.rolloff / 100.0).clamp(0.0, 0.99) } - /// Normalised code value (code / 1023) → linear. + /// Normalized code value (code / 1023) → linear, raised to `1.7 / gamma` + /// ([`effectcraft_project::cineon_log_to_lin`], which Interpret Footage ▸ Cineon Settings + /// also uses with `1 / gamma`). pub fn log_to_lin(&self, v: f32) -> f32 { - let off = self.offset(); - let code = v * 1023.0; - let mut lin = (10f32.powf((code - self.white) * 0.002 / 0.6) - off) / (1.0 - off).max(1e-6); - lin = lin.max(0.0).powf(1.7 / self.gamma.max(0.01)); - let k = self.knee(); - if k < 1.0 && lin > k { - let w = 1.0 - k; - lin = k + w * (1.0 - (-(lin - k) / w).exp()); - } + let lin = effectcraft_project::cineon_log_to_lin(v, self.black, self.white, 1.7, self.gamma, self.rolloff); self.ib + (self.iw - self.ib) * lin } /// Linear → normalised code value. @@ -495,6 +489,87 @@ mod tests { assert!(c.log_to_lin(95.0 / 1023.0).abs() < 1e-4); } + /// Interpret Footage ▸ Cineon Settings and the Cineon Converter's Log to Linear give the same + /// values for the same points and Highlight Rolloff when the converter's Gamma is 1.7 times + /// Cineon Settings' Gamma: the converter's default 1.7 and Cineon Settings' Gamma 1 both give + /// linear light (code 400 is 0.10251). + #[test] + fn cineon_converter_matches_interpret_footage() { + let codes = [0.0f32, 95.0, 250.0, 400.0, 560.0, 685.0, 800.0, 1023.0]; + for (gamma, rolloff) in [(1.0, 20.0), (1.0, 0.0), (1.7, 50.0), (2.4, 50.0)] { + let mut img = Image::new(codes.len() as u32, 1); + for (x, code) in codes.iter().enumerate() { + let v = code / 1023.0; + img.set(x as u32, 0, [v, v, v, 1.0]); + } + let vals = [("conversionType", Value::Enum(1)), ("gamma", num(gamma * 1.7)), ("highlightRolloff", num(rolloff))]; + let out = run("ec.utility.cineon", &vals, img); + let settings = effectcraft_project::CineonSettings { log_conversion: true, gamma: gamma as f32, rolloff: rolloff as f32, ..Default::default() }; + for (x, code) in codes.iter().enumerate() { + let footage = settings.convert(code / 1023.0); + let effect = out.img.data[x][0]; + assert!((footage - effect).abs() < 1e-6, "gamma {gamma}, rolloff {rolloff}, code {code}: {footage} vs {effect}"); + } + } + let linear = effectcraft_project::CineonSettings { gamma: 1.0, rolloff: 0.0, ..Default::default() }; + assert!((linear.convert(400.0 / 1023.0) - 0.10251).abs() < 1e-4); + } + + /// The Cineon Converter gives the same values as before Cineon Settings shared its + /// log-to-linear function, at the default Gamma (1.7) and at Gamma 2.4, for all three + /// conversion types. + #[test] + fn cineon_converter_output_is_unchanged() { + fn log_to_lin(v: f32, gamma: f32, rolloff: f32, black: f32, white: f32) -> f32 { + let off = 10f32.powf((black - white) * 0.002 / 0.6); + let mut lin = (10f32.powf((v * 1023.0 - white) * 0.002 / 0.6) - off) / (1.0 - off).max(1e-6); + lin = lin.max(0.0).powf(1.7 / gamma.max(0.01)); + let k = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99); + if k < 1.0 && lin > k { + let w = 1.0 - k; + lin = k + w * (1.0 - (-(lin - k) / w).exp()); + } + lin + } + fn lin_to_log(v: f32, gamma: f32, rolloff: f32, black: f32, white: f32) -> f32 { + let off = 10f32.powf((black - white) * 0.002 / 0.6); + let mut lin = v; + let k = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99); + if k < 1.0 && lin > k { + let w = 1.0 - k; + lin = k - w * (1.0 - (lin - k) / w).max(1e-6).ln(); + } + lin = lin.max(0.0).powf(gamma.max(0.01) / 1.7); + (white + (lin * (1.0 - off) + off).max(1e-9).log10() * 0.6 / 0.002) / 1023.0 + } + let codes = [0.0f32, 95.0, 250.0, 400.0, 560.0, 685.0, 800.0, 1023.0]; + for gamma in [None, Some(2.4)] { + for kind in 0..3 { + let mut img = Image::new(codes.len() as u32, 1); + for (x, code) in codes.iter().enumerate() { + let v = code / 1023.0; + img.set(x as u32, 0, [v, v, v, 1.0]); + } + let mut vals = vec![("conversionType", Value::Enum(kind))]; + if let Some(g) = gamma { + vals.push(("gamma", num(g))); + } + let out = run("ec.utility.cineon", &vals, img); + let g = gamma.unwrap_or(1.7) as f32; + for (x, code) in codes.iter().enumerate() { + let v = code / 1023.0; + let want = match kind { + 0 => lin_to_log(v, g, 20.0, 95.0, 685.0), + 1 => log_to_lin(v, g, 20.0, 95.0, 685.0), + _ => lin_to_log(log_to_lin(v, g, 20.0, 95.0, 685.0), 1.7, 20.0, 95.0, 685.0), + }; + let got = out.img.data[x][0]; + assert!((want - got).abs() < 1e-6, "gamma {g}, type {kind}, code {code}: {want} vs {got}"); + } + } + } + } + #[test] fn hdr_compression_behaviour() { let mut img = Image::new(2, 1); diff --git a/crates/effects/src/warp_stab.rs b/crates/effects/src/warp_stab.rs index f850bdcf3..8d9fe6f0e 100644 --- a/crates/effects/src/warp_stab.rs +++ b/crates/effects/src/warp_stab.rs @@ -152,15 +152,6 @@ pub fn settings(params: &Params, fps: f64) -> StabSettings { } } -fn fnv(bytes: &[u8]) -> u64 { - let mut h: u64 = 0xcbf2_9ce4_8422_2325; - for b in bytes { - h ^= *b as u64; - h = h.wrapping_mul(0x0100_0000_01b3); - } - h -} - struct KeyHasher(u64); impl Hasher for KeyHasher { fn finish(&self) -> u64 { @@ -183,24 +174,12 @@ fn settings_key(s: &StabSettings) -> u64 { h.finish() } -type AnalysisCache = Mutex>>; type PlanCache = Mutex>>; -/// Parsed analyses by content hash (parsing a long clip's JSON every frame would dominate). +/// Parsed analyses and their content keys; cache hits compare source text without rehashing. fn analysis_cached(json: &str) -> Option<(u64, Arc)> { - static C: OnceLock = OnceLock::new(); - let key = fnv(json.as_bytes()); - let c = C.get_or_init(Default::default); - if let Some(a) = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) { - return Some((key, a.clone())); - } - let a = Arc::new(WarpAnalysis::from_json(json)?); - let mut m = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if m.len() > 8 { - m.clear(); - } - m.insert(key, a.clone()); - Some((key, a)) + static C: crate::analysis_cache::AnalysisCache = crate::analysis_cache::AnalysisCache::new(); + C.get(json, WarpAnalysis::from_json) } fn plans() -> &'static PlanCache { @@ -223,6 +202,19 @@ fn plan_cached(akey: u64, a: &WarpAnalysis, s: &StabSettings) -> Arc { p } +/// Validate and cache the default stabilization plan after analysis. Use the serialized +/// analysis so the cached plan has exactly the same inputs as the first render, including +/// any floating-point round trip through JSON. Other settings still get their own plan. +pub fn prepare_default_plan(json: &str) -> bool { + let Some((key, a)) = analysis_cached(json) else { return false }; + if a.is_empty() { + return false; + } + let fps = if a.frame_duration > 0.0 { 1.0 / a.frame_duration } else { 30.0 }; + let p = plan_cached(key, &a, &StabSettings { fps, ..Default::default() }); + p.warps.len() == a.frames.len() && p.warps.iter().all(|h| h.0.iter().flatten().all(|v| v.is_finite())) +} + /// The stored analysis of an instance (`None` when not analysed). pub fn analysis(params: &Params) -> Option> { let j = s(params, ANALYSIS); @@ -453,7 +445,7 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf { }); let need = need_m.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner); if synth && need.iter().any(|w| *w > 0.0) { - synthesize(ctx, &a, k, &src_of, &crop, sc, &mut out, &need); + synthesize(ctx, &a, k, &SynthesisGrid { src_of: &src_of, crop: &crop, scale: sc }, &mut out, &need); } if b(ctx.params, "advanced/showTrackPoints") { let r = (2.5 * f(ctx.params, "advanced/trackPointSize") / 100.0 * sc).max(0.5); @@ -465,19 +457,16 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf { Buf { img: out, offset: [0.0; 2], scale: sc } } -/// Fill the pixels flagged in `need` (weight = how much synthesized content they take) from -/// neighbouring frames, nearest first. -#[allow(clippy::too_many_arguments)] -fn synthesize( - ctx: &EffectCtx, - a: &WarpAnalysis, - k: usize, - src_of: &(dyn Fn([f64; 2]) -> [f64; 2] + Sync), - crop: &[f64; 4], - sc: f64, - out: &mut Image, - need: &[f32], -) { +/// Maps an output sample into the uncropped source grid shared by neighbouring frames. +struct SynthesisGrid<'a> { + src_of: &'a (dyn Fn([f64; 2]) -> [f64; 2] + Sync), + crop: &'a [f64; 4], + scale: f64, +} + +/// Fill the pixels flagged in `need` from neighbouring frames, nearest first. +fn synthesize(ctx: &EffectCtx, a: &WarpAnalysis, k: usize, grid: &SynthesisGrid<'_>, out: &mut Image, need: &[f32]) { + let SynthesisGrid { src_of, crop, scale: sc } = *grid; let Some(host) = ctx.env.host else { return }; let n = a.frames.len(); let range = ((f(ctx.params, "advanced/synthesizeInputRange").max(0.0) / a.frame_duration.max(1e-6)).round() as usize).min(60); @@ -534,6 +523,7 @@ fn dot(img: &mut Image, c: [f64; 2], r: f64, col: Px) { mod tests { use super::*; use crate::EffectEnv; + use crate::analysis_cache::fnv; use effectcraft_track::stabilize::FrameMotion; fn analysis_json(n: usize, w: f64, h: f64, shift: impl Fn(usize) -> [f64; 2]) -> String { @@ -604,6 +594,61 @@ mod tests { assert_eq!(render(&ctx, Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 }).img.data, img.data); } + #[test] + fn prepared_default_plan_preserves_first_render_and_is_reused() { + let json = analysis_json(25, 64.0, 48.0, |k| [((k * 7) % 5) as f64 - 2.0, k as f64 * 0.1]); + let mut a = WarpAnalysis::from_json(&json).unwrap(); + a.start = 0.012345678901234567; + a.tracks = (0..24) + .map(|id| effectcraft_track::camtrack::Track2D { + id, + start: 0, + pts: (0..25).map(|k| [8.0 + (id % 6) as f32 * 8.0 + ((k * 7) % 5) as f32, 8.0 + (id / 6) as f32 * 8.0 + k as f32 * 0.1]).collect(), + }) + .collect(); + let json = a.to_json(); + let ps = params(&json, 2, 0); + let ctx = EffectCtx { params: &ps, time: a.start, layer_size: a.size, seed: 1, adjustment: false, env: EffectEnv::default() }; + let input = Buf { img: frame([30.0, 22.0]), offset: [0.0; 2], scale: 1.0 }; + // Original flow: validate, discard the plan, then render from the stored analysis. + let _discarded = plan(&a, &StabSettings { fps: 25.0, ..Default::default() }); + let before = render(&ctx, input.clone()); + let key = (fnv(json.as_bytes()), settings_key(&settings(&ps, 25.0))); + plans().lock().unwrap().remove(&key); + assert!(prepare_default_plan(&json)); + let prepared = plans().lock().unwrap().get(&key).unwrap().clone(); + assert!(!prepared.meshes.is_empty(), "exercise the Subspace Warp plan"); + let after = render(&ctx, input); + let (_, _, rendered) = plan_at(&ps, a.start).unwrap(); + assert!(Arc::ptr_eq(&prepared, &rendered)); + assert_eq!(before.offset, after.offset); + assert_eq!(before.scale.to_bits(), after.scale.to_bits()); + for (a, b) in before.img.data.iter().flatten().zip(after.img.data.iter().flatten()) { + assert_eq!(a.to_bits(), b.to_bits()); + } + assert!(!prepare_default_plan("invalid")); + assert!(!prepare_default_plan(&WarpAnalysis::default().to_json())); + } + + #[test] + fn changing_analysis_changes_the_plan_key() { + let json = analysis_json(10, 64.0, 48.0, |k| [k as f64 * 0.1, 0.0]); + let mut ps = params(&json, 2, 0); + let before = summary_key(&ps).unwrap(); + assert_eq!(before[0], fnv(json.as_bytes())); + let mut a = WarpAnalysis::from_json(&json).unwrap(); + a.start = 1.0; + ps.values.insert(ANALYSIS.into(), Value::Str(a.to_json())); + let after = summary_key(&ps).unwrap(); + assert_ne!(before[0], after[0]); + assert_eq!(before[1], after[1]); + assert_eq!(*analysis(&ps).unwrap(), a); + ps.values.insert(ANALYSIS.into(), Value::Str(json)); + assert_eq!(summary_key(&ps).unwrap(), before); + ps.values.insert(ANALYSIS.into(), Value::Str(String::new())); + assert!(analysis(&ps).is_none()); + } + #[test] fn nested_param_lookup() { let mut p = Params::default(); diff --git a/crates/effects/tests/it/sim_golden.rs b/crates/effects/tests/it/sim_golden.rs index 2a3a7c724..388790901 100644 --- a/crates/effects/tests/it/sim_golden.rs +++ b/crates/effects/tests/it/sim_golden.rs @@ -433,26 +433,26 @@ const GOLDEN: &[(&str, u64)] = &[ ("ec.sim.ccstarburst/1/32bpc/t1.5", 0x5c39f8b1bd0e9e43), ("ec.sim.ccstarburst/1/32bpc/t0.8", 0xdf20aa9dcf4b3e22), ("ec.sim.ccstarburst/1/32bpc/t2.2", 0x4fa441737bfcb64d), - ("ec.sim.ccbubbles/0/8bpc/t0.5", 0xdcd187e75eeb2fae), - ("ec.sim.ccbubbles/0/8bpc/t1.5", 0x835ca088747736ed), - ("ec.sim.ccbubbles/0/8bpc/t0.8", 0x5cbb1a6fbb77b41d), - ("ec.sim.ccbubbles/0/8bpc/t2.2", 0x3b168068f5bfa533), - ("ec.sim.ccbubbles/0/32bpc/t0.5", 0x524b84d14edd77e2), - ("ec.sim.ccbubbles/0/32bpc/t1.5", 0xfd7a17f0d40f2a27), - ("ec.sim.ccbubbles/0/32bpc/t0.8", 0x70dd2b2b4ff0f7ce), - ("ec.sim.ccbubbles/0/32bpc/t2.2", 0xae21dd5cc2c2c07c), - ("ec.sim.ccbubbles/0/8bpc/half/t0.5", 0x931b4ca707604220), - ("ec.sim.ccbubbles/0/8bpc/half/t1.5", 0x21c25d4555abebe9), - ("ec.sim.ccbubbles/0/8bpc/half/t0.8", 0x7e05972683359632), - ("ec.sim.ccbubbles/0/8bpc/half/t2.2", 0xadc4f7219dab4921), - ("ec.sim.ccbubbles/1/8bpc/t0.5", 0x89d27a2fe2011067), - ("ec.sim.ccbubbles/1/8bpc/t1.5", 0x2bbfee897bf5bf6e), - ("ec.sim.ccbubbles/1/8bpc/t0.8", 0x26cc704e7b06fffc), - ("ec.sim.ccbubbles/1/8bpc/t2.2", 0x959d331545d96479), - ("ec.sim.ccbubbles/1/32bpc/t0.5", 0xaf486cebfcbf4641), - ("ec.sim.ccbubbles/1/32bpc/t1.5", 0xe150e9fdb558036f), - ("ec.sim.ccbubbles/1/32bpc/t0.8", 0x6395f3967cb24284), - ("ec.sim.ccbubbles/1/32bpc/t2.2", 0x68a55894d86ffb9e), + ("ec.sim.ccbubbles/0/8bpc/t0.5", 0x6cb63df0c062fdf4), + ("ec.sim.ccbubbles/0/8bpc/t1.5", 0x5bb1350114234ac3), + ("ec.sim.ccbubbles/0/8bpc/t0.8", 0xaa824d49f12c4156), + ("ec.sim.ccbubbles/0/8bpc/t2.2", 0x3528577358c4fc90), + ("ec.sim.ccbubbles/0/32bpc/t0.5", 0xdcd8e71222c13a9a), + ("ec.sim.ccbubbles/0/32bpc/t1.5", 0xd09565e124d8f5db), + ("ec.sim.ccbubbles/0/32bpc/t0.8", 0x91242f8e1fd0ad84), + ("ec.sim.ccbubbles/0/32bpc/t2.2", 0x1f9782131e62149d), + ("ec.sim.ccbubbles/0/8bpc/half/t0.5", 0x020755e48bdf8f43), + ("ec.sim.ccbubbles/0/8bpc/half/t1.5", 0x4aac0f1332e6001e), + ("ec.sim.ccbubbles/0/8bpc/half/t0.8", 0xdc2ab5e04c4a95a2), + ("ec.sim.ccbubbles/0/8bpc/half/t2.2", 0x30f78adc5d1dd715), + ("ec.sim.ccbubbles/1/8bpc/t0.5", 0x2f5415c679ff0980), + ("ec.sim.ccbubbles/1/8bpc/t1.5", 0xfcfc354dcd22e6d0), + ("ec.sim.ccbubbles/1/8bpc/t0.8", 0xdf90fd57c217019c), + ("ec.sim.ccbubbles/1/8bpc/t2.2", 0xd365c88c12219802), + ("ec.sim.ccbubbles/1/32bpc/t0.5", 0x50f20497f40bb46d), + ("ec.sim.ccbubbles/1/32bpc/t1.5", 0x74760599ffba220a), + ("ec.sim.ccbubbles/1/32bpc/t0.8", 0x028de52900ed8618), + ("ec.sim.ccbubbles/1/32bpc/t2.2", 0xaf0e21d9a7f625fe), ("ec.sim.ccdrizzle/0/8bpc/t0.5", 0x2bdfbc17d92b65b5), ("ec.sim.ccdrizzle/0/8bpc/t1.5", 0x8c354c46f99e35d3), ("ec.sim.ccdrizzle/0/8bpc/t0.8", 0x891f331ba47ee557), diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 7104bc1cc..826346987 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -39,3 +39,9 @@ rayon = { workspace = true } image = { workspace = true } web-time = { workspace = true } exr = { workspace = true } + +# Native file identity and SAM 2 inference; browsers keep the built-in engines. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +same-file = { workspace = true } +tempfile = { workspace = true } +effectcraft-sam2 = { workspace = true } diff --git a/crates/engine/presets/behaviors.json b/crates/engine/presets/behaviors.json new file mode 100644 index 000000000..71e87db52 --- /dev/null +++ b/crates/engine/presets/behaviors.json @@ -0,0 +1,248 @@ +{ + "format": "effectcraft.animationPresets/1", + "notes": "Original EffectCraft behavior presets (MIT OR Apache-2.0), written from scratch. Each one drives a transform property or opacity with a time-based expression built on the property's own value, so the motion runs for the layer's whole duration and still follows the property's keyframes. Apply to any layer; no effects are added.", + "presets": [ + { + "id": "wiggleSoft", + "name": "Soft Wiggle", + "category": "Behaviors", + "description": "The layer wanders gently around its position.", + "layer": { + "expr": { + "transform/position": "var w = wiggle(1.2, 30); [w[0], w[1]]" + } + } + }, + { + "id": "shake", + "name": "Shake", + "category": "Behaviors", + "description": "A fast, tight shake of the layer's position.", + "layer": { + "expr": { + "transform/position": "var w = wiggle(14, 12); [w[0], w[1]]" + } + } + }, + { + "id": "floatUpDown", + "name": "Float", + "category": "Behaviors", + "description": "The layer bobs up and down like it floats on water.", + "layer": { + "expr": { + "transform/position": "[value[0]+(0), value[1]+(Math.sin(time*2)*18)]" + } + } + }, + { + "id": "sway", + "name": "Sway", + "category": "Behaviors", + "description": "The layer sways left and right.", + "layer": { + "expr": { + "transform/position": "[value[0]+(Math.sin(time*1.6)*40), value[1]+(0)]" + } + } + }, + { + "id": "orbit", + "name": "Orbit", + "category": "Behaviors", + "description": "The layer travels round a circle around its position.", + "layer": { + "expr": { + "transform/position": "[value[0]+(Math.cos(time*2)*80), value[1]+(Math.sin(time*2)*80)]" + } + } + }, + { + "id": "figureEight", + "name": "Figure Eight", + "category": "Behaviors", + "description": "The layer traces a figure eight around its position.", + "layer": { + "expr": { + "transform/position": "[value[0]+(Math.sin(time*1.5)*100), value[1]+(Math.sin(time*3)*45)]" + } + } + }, + { + "id": "bouncingBall", + "name": "Bouncing Ball", + "category": "Behaviors", + "description": "The layer bounces up from its position again and again.", + "layer": { + "expr": { + "transform/position": "[value[0]+(0), value[1]+(-Math.abs(Math.sin(time*3.2))*140)]" + } + } + }, + { + "id": "driftRight", + "name": "Drift Right", + "category": "Behaviors", + "description": "The layer drifts steadily to the right.", + "layer": { + "expr": { + "transform/position": "[value[0]+(time*40), value[1]+(0)]" + } + } + }, + { + "id": "driftDiagonal", + "name": "Drift Diagonal", + "category": "Behaviors", + "description": "The layer drifts slowly down and to the right.", + "layer": { + "expr": { + "transform/position": "[value[0]+(time*30), value[1]+(time*18)]" + } + } + }, + { + "id": "pendulum", + "name": "Pendulum", + "category": "Behaviors", + "description": "The layer swings like a pendulum.", + "layer": { + "expr": { + "transform/rotation": "value + Math.sin(time*2.4)*25" + } + } + }, + { + "id": "wobble", + "name": "Wobble", + "category": "Behaviors", + "description": "The layer rotates with an irregular wobble.", + "layer": { + "expr": { + "transform/rotation": "wiggle(2, 10)" + } + } + }, + { + "id": "spinSlow", + "name": "Spin Slow", + "category": "Behaviors", + "description": "The layer rotates once every ten seconds.", + "layer": { + "expr": { + "transform/rotation": "value + time*36" + } + } + }, + { + "id": "spinFast", + "name": "Spin Fast", + "category": "Behaviors", + "description": "The layer rotates twice a second.", + "layer": { + "expr": { + "transform/rotation": "value + time*720" + } + } + }, + { + "id": "pulse", + "name": "Pulse", + "category": "Behaviors", + "description": "The layer swells and shrinks smoothly.", + "layer": { + "expr": { + "transform/scale": "[value[0]*(1+0.12*Math.sin(time*4)), value[1]*(1+0.12*Math.sin(time*4))]" + } + } + }, + { + "id": "heartbeat", + "name": "Heartbeat", + "category": "Behaviors", + "description": "Two quick beats, then a rest, repeating every second.", + "layer": { + "expr": { + "transform/scale": "var t = time % 1; var b = t < 0.3 ? Math.sin(t/0.3*Math.PI) : (t < 0.6 ? 0.7*Math.sin((t-0.3)/0.3*Math.PI) : 0); var k = 1 + 0.2*b; [value[0]*k, value[1]*k]" + } + } + }, + { + "id": "breathe", + "name": "Breathe", + "category": "Behaviors", + "description": "The layer slowly grows and settles back.", + "layer": { + "expr": { + "transform/scale": "[value[0]*(1+0.06*Math.sin(time*1.1)), value[1]*(1+0.06*Math.sin(time*1.1))]" + } + } + }, + { + "id": "rubberBand", + "name": "Rubber Band", + "category": "Behaviors", + "description": "Width and height stretch against each other.", + "layer": { + "expr": { + "transform/scale": "[value[0]*(1+0.15*Math.sin(time*5)), value[1]*(1-0.15*Math.sin(time*5))]" + } + } + }, + { + "id": "jitterScale", + "name": "Jitter Scale", + "category": "Behaviors", + "description": "The layer's size jitters randomly.", + "layer": { + "expr": { + "transform/scale": "var w = wiggle(8, 6); [w[0], w[1]]" + } + } + }, + { + "id": "blink", + "name": "Blink", + "category": "Behaviors", + "description": "The layer switches on and off twice a second.", + "layer": { + "expr": { + "transform/opacity": "(Math.floor(time*4) % 2 === 0) ? value : 0" + } + } + }, + { + "id": "strobe", + "name": "Strobe", + "category": "Behaviors", + "description": "The layer flashes on for a few frames each beat.", + "layer": { + "expr": { + "transform/opacity": "((time*6) % 1 < 0.25) ? value : 0" + } + } + }, + { + "id": "flicker", + "name": "Flicker", + "category": "Behaviors", + "description": "The layer's opacity flickers like a failing lamp.", + "layer": { + "expr": { + "transform/opacity": "var w = wiggle(14, 45); Math.max(0, Math.min(100, w))" + } + } + }, + { + "id": "fadePulse", + "name": "Fade Pulse", + "category": "Behaviors", + "description": "Opacity breathes between full and a third.", + "layer": { + "expr": { + "transform/opacity": "value*(0.67+0.33*Math.sin(time*2.5))" + } + } + } + ] +} diff --git a/crates/engine/presets/behaviors.json.attribution b/crates/engine/presets/behaviors.json.attribution new file mode 100644 index 000000000..7c71c62e8 --- /dev/null +++ b/crates/engine/presets/behaviors.json.attribution @@ -0,0 +1,7 @@ +asset: behaviors.json +title: EffectCraft Behaviors animation presets (Soft Wiggle, Shake, Float, Sway, Orbit, Figure Eight, Bouncing Ball, Drift Right, Drift Diagonal, Pendulum, Wobble, Spin Slow, Spin Fast, Pulse, Heartbeat, Breathe, Rubber Band, Jitter Scale, Blink, Strobe, Flicker, Fade Pulse) +author: EffectCraft contributors +source: original work +license: MIT OR Apache-2.0 +added: 2026-10-15 by vangel76 (Claude Code agent, gap 7, #1510) +notes: Expressions and values written from scratch for EffectCraft on its own transform properties and expression engine; not derived from any Adobe preset (.ffx) or its values. diff --git a/crates/engine/presets/text_animators.json b/crates/engine/presets/text_animators.json index 20e142268..e82707a52 100644 --- a/crates/engine/presets/text_animators.json +++ b/crates/engine/presets/text_animators.json @@ -77,7 +77,7 @@ "name": "Tracking In", "properties": { "tracking": 0, "opacity": 100 }, "keys": { - "tracking": [[0, 400], [1.6, 0]], + "tracking": [[0, 40], [1.6, 0]], "opacity": [[0, 0], [0.8, 100]] }, "ease": true, diff --git a/crates/engine/src/analysis.rs b/crates/engine/src/analysis.rs new file mode 100644 index 000000000..fa2b8b4ec --- /dev/null +++ b/crates/engine/src/analysis.rs @@ -0,0 +1,6 @@ +pub mod camera_track; +pub mod mask_track; +pub mod models; +pub mod roto; +pub mod tracking; +pub mod warp; diff --git a/crates/engine/src/camera_track.rs b/crates/engine/src/analysis/camera_track.rs similarity index 99% rename from crates/engine/src/camera_track.rs rename to crates/engine/src/analysis/camera_track.rs index adc5aee69..29961f533 100644 --- a/crates/engine/src/camera_track.rs +++ b/crates/engine/src/analysis/camera_track.rs @@ -7,7 +7,7 @@ //! camera"). When only the solve settings changed (Shot Type, Angle of View, Solve Method, //! deleted points) the stored tracks are re-solved without re-tracking. Results are written into //! the effect's hidden parameters as one undo step; edits that change the layer's frames clear -//! them and queue the effect again ([`invalidate`], run after every edit), like After Effects. +//! them and queue the effect again (`invalidate`, run after every edit), like After Effects. //! //! Composition space: the solve's canonical frame maps to comp world by //! [`CameraSolve::world`] with the zoom of the first solved frame (focal length × the layer's diff --git a/crates/engine/src/mask_track.rs b/crates/engine/src/analysis/mask_track.rs similarity index 100% rename from crates/engine/src/mask_track.rs rename to crates/engine/src/analysis/mask_track.rs diff --git a/crates/engine/src/analysis/models.rs b/crates/engine/src/analysis/models.rs new file mode 100644 index 000000000..63550545a --- /dev/null +++ b/crates/engine/src/analysis/models.rs @@ -0,0 +1,1010 @@ +//! Trained models (Settings ▸ Roto Brush and Settings ▸ Face Tracking; `roto.model*` and +//! `face.model*` commands): which models the registry offers (`effectcraft_segment::MODELS`, all +//! open source), which are installed (in the `models` folder), installing one from a file or +//! downloading the official weights (verified against the registry's SHA-256 either way, and kept +//! with a notice naming their authors and licence), choosing one per task, and loading it in the +//! background: into the Roto Brush effect (`effects::roto::set_model`) or the face tracker +//! ([`Models::face`]). +//! +//! Weights are never bundled: the install stays small and nothing is fetched unless asked for. +//! Downloads use the system's `curl` (shipped with Windows 10+, macOS and most Linux systems), +//! so no networking code is linked into EffectCraft; without it, the file can be downloaded in a +//! browser and installed with Install from File. The browser build uses the built-in engines +//! only, for now. + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use effectcraft_segment::face::FaceModel; +use effectcraft_segment::{self as seg, Loaded, ModelInfo, Task}; +use serde_json::{Value, json}; +use web_time::Instant; + +use crate::commands::{CommandSpec, always, b_p, bad, str_p}; +use crate::{EngineError, Result, Session, cmd, query}; + +/// What a task's background worker reports. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Status { + /// What is happening now ("Downloading MobileSAM…"). + pub busy: Option, + /// Model whose load/download owns this lane, for selection-specific waits. + pub busy_model: Option<&'static str>, + /// The model in use. + pub active: Option, + /// The last failure, for the Settings page. + pub error: Option, + /// Model whose operation failed, so Roto status does not report another model's download. + pub error_model: Option<&'static str>, + /// Failure of the selected model's load; downloads cannot block or retry that load. + pub load_error: Option, + /// A finished job's message and whether it failed, for a toast (taken by + /// [`Session::poll_models`]). + pub done: Option<(String, bool)>, + /// Counts selection and selected-weight changes: an older load is stale and its model is discarded. + pub generation: u64, +} + +/// The session's trained models: per task, the worker's status; and the face model in use. +#[derive(Default)] +pub struct Models { + roto: Arc>, + face: Arc>, + face_model: FaceSlot, +} + +impl Models { + fn lane(&self, task: Task) -> &Arc> { + match task { + Task::Mask => &self.roto, + Task::Face => &self.face, + } + } + + pub fn status(&self, task: Task) -> Status { + self.lane(task).lock().map(|s| s.clone()).unwrap_or_default() + } + + /// The face model face tracking uses (`None`: the classic tracker). + pub fn face(&self) -> Option> { + self.face_model.lock().ok().and_then(|m| m.clone()) + } + + /// Activate a model that finished loading for the choice counted as `generation`; `false` (and the model is + /// dropped) when the choice changed meanwhile. + #[cfg(test)] + pub(crate) fn set_active_if_current(&self, task: Task, model: Loaded, generation: u64) -> bool { + activate_if_current(self.lane(task), &self.face_model, task, model, generation) + } + + pub(crate) fn set_active(&self, task: Task, model: Option) { + activate(self.lane(task), &self.face_model, task, model); + } +} + +type FaceSlot = Arc>>>; + +/// Put a loaded model to use (or drop `task`'s): Roto Brush's lives in the effect, the face +/// tracker's in `face`. +fn activate(lane: &Arc>, face: &FaceSlot, task: Task, model: Option) { + let id = model.as_ref().map(|m| match m { + Loaded::Mask(m) => m.info().id.to_string(), + Loaded::Face(m) => m.info().id.to_string(), + }); + match (task, model) { + (Task::Mask, Some(Loaded::Mask(m))) => crate::effects::roto::set_model(Some(m)), + (Task::Mask, _) => crate::effects::roto::set_model(None), + (Task::Face, m) => { + if let Ok(mut slot) = face.lock() { + *slot = match m { + Some(Loaded::Face(f)) => Some(f), + _ => None, + }; + } + } + } + set(lane, |s| s.active = id); +} + +/// [`activate`] a freshly loaded model unless the choice changed since the load started (`generation`); whether it +/// was activated. The check and the activation happen under the lane's lock, so a selection can't slip between them. +fn activate_if_current(lane: &Arc>, face: &FaceSlot, task: Task, model: Loaded, generation: u64) -> bool { + let Ok(mut s) = lane.lock() else { return false }; + if s.generation != generation { + return false; + } + let id = match &model { + Loaded::Mask(m) => m.info().id.to_string(), + Loaded::Face(m) => m.info().id.to_string(), + }; + match (task, model) { + (Task::Mask, Loaded::Mask(m)) => crate::effects::roto::set_model(Some(m)), + (Task::Face, Loaded::Face(f)) => { + if let Ok(mut slot) = face.lock() { + *slot = Some(f); + } + } + _ => return false, + } + s.active = Some(id); + true +} + +fn set(status: &Arc>, f: impl FnOnce(&mut Status)) { + if let Ok(mut s) = status.lock() { + f(&mut s); + } +} + +/// How a task shows in Settings, toasts and the command list. +struct Kind { + /// Command prefix and settings key section. + prefix: &'static str, + label: &'static str, + classic_name: &'static str, + classic_description: &'static str, +} + +const ROTO: Kind = Kind { + prefix: "roto", + label: "Roto Brush", + classic_name: "Classic (graph cut)", + classic_description: "Built in: colour models and graph cuts, propagated by optical flow. Always available.", +}; + +const FACE: Kind = Kind { + prefix: "face", + label: "Face Tracking", + classic_name: "Classic (shape model)", + classic_description: "Built in: a skin colour model, facial feature shapes and a fitted face shape model. Best on front-facing, evenly lit faces. Always available.", +}; + +fn kind(task: Task) -> &'static Kind { + match task { + Task::Mask => &ROTO, + Task::Face => &FACE, + } +} + +/// How long a Roto Brush model may sit unused before its memory is freed. +const ROTO_MODEL_IDLE: Duration = Duration::from_secs(600); + +/// How long a job that needs the Roto Brush model waits for it to load (or download) before it +/// gives up. A worker that never reports back must not hang the command. +const ROTO_MODEL_WAIT: Duration = Duration::from_secs(600); + +impl Session { + /// The folder installed models live in: the host's, else `models` next to the settings. + pub fn models_dir(&self) -> Option { + self.models_dir.clone().or_else(|| Some(self.config.as_ref()?.dir()?.join("models"))) + } + + /// Where `m` is installed, if it is (a file of the published size). + pub fn model_path(&self, m: &ModelInfo) -> Option { + let p = self.models_dir()?.join(m.file_name); + std::fs::metadata(&p).ok().filter(|md| md.len() == m.size).map(|_| p) + } + + /// The model chosen for `task` (Settings). + pub fn chosen_model(&self, task: Task) -> &str { + match task { + Task::Mask => &self.prefs.roto.model, + Task::Face => &self.prefs.face.model, + } + } + + fn choose_model(&mut self, task: Task, id: &str) { + let slot = match task { + Task::Mask => &mut self.prefs.roto.model, + Task::Face => &mut self.prefs.face.model, + }; + let changed = slot != id; + if changed { + *slot = id.to_string(); + } + set(self.models.lane(task), |s| { + // A load still running for the previous choice must not activate or report an error. + if changed { + s.generation = s.generation.wrapping_add(1); + } + // Selecting the same model explicitly retries a failed load. + s.error = None; + s.error_model = None; + s.load_error = None; + }); + } + + /// Load (or drop) the models Settings chooses; called whenever settings change. + pub fn apply_models(&mut self) { + for task in [Task::Mask, Task::Face] { + self.apply_model(task, false); + } + } + + /// Load (or drop) `task`'s chosen model; `wait`: before returning (scripts and agents). + fn apply_model(&mut self, task: Task, wait: bool) { + let want = self.chosen_model(task).to_string(); + let lane = self.models.lane(task).clone(); + let st = self.models.status(task); + let info = seg::info(&want).filter(|m| m.task == task); + let Some(info) = info else { + if st.active.is_some() { + self.models.set_active(task, None); + } + return; + }; + if st.active.as_deref() == Some(info.id) || st.busy.is_some() || st.load_error.is_some() { + return; + } + let Some(path) = self.model_path(info) else { + self.models.set_active(task, None); + return; + }; + let (face_slot, label) = (self.models.face_model.clone(), kind(task).label); + let generation = st.generation; + spawn(&lane, label, format!("Loading {}…", info.name), wait, Some(generation), info.id, move |status| { + let m = load_file(&path, info.id)?; + // the choice changed while this loaded: drop the model, `poll_models` loads the new choice + if !activate_if_current(status, &face_slot, task, m, generation) { + return Ok(String::new()); + } + Ok(format!("{label}: {} loaded", info.name)) + }); + } + + /// The Roto Brush model in use (`None`: the classic engine). + pub fn models_roto_active(&self) -> Option { + self.models.status(Task::Mask).active + } + + /// Only installing the chosen model is a retry of that model's failed load. + fn retry_installed_model(&mut self, task: Task, id: &str, wait: bool) -> std::result::Result { + if self.chosen_model(task) != id { + return Ok(false); + } + set(self.models.lane(task), |s| { + s.error = None; + s.error_model = None; + s.load_error = None; + // A load that read the old file must not report failure after these weights replaced it. + s.generation = s.generation.wrapping_add(1); + }); + if self.models.status(task).active.as_deref() == Some(id) { + return Ok(true); + } + if wait { + self.wait_model_worker(task, id)?; + } + self.apply_model(task, wait); + if wait { + let status = self.models.status(task); + if status.active.as_deref() != Some(id) { + return Err(status.load_error.unwrap_or_else(|| format!("{id} is not loaded: try again when the model worker is ready"))); + } + } + Ok(true) + } + + fn wait_model_worker(&self, task: Task, id: &str) -> std::result::Result<(), String> { + let started = Instant::now(); + loop { + let status = self.models.status(task); + let Some(operation) = status.busy else { return Ok(()) }; + if status.busy_model != Some(id) { + return Err(format!("{id} cannot load while another model operation is running ({operation}): try again when it is ready")); + } + if started.elapsed() >= ROTO_MODEL_WAIT { + return Err(format!("{} is still loading after {} minutes", self.chosen_model(task), ROTO_MODEL_WAIT.as_secs() / 60)); + } + std::thread::sleep(Duration::from_millis(5)); + } + } + + /// The Roto Brush model Settings chose, when it is one (not the classic engine). + fn roto_model_info(&self) -> Option<&'static ModelInfo> { + seg::info(self.chosen_model(Task::Mask)).filter(|m| m.task == Task::Mask) + } + + /// Whether the chosen Roto Brush model is still loading or downloading. + pub fn roto_model_loading(&self) -> bool { + let status = self.models.status(Task::Mask); + let chosen = self.chosen_model(Task::Mask); + status.active.as_deref() != Some(chosen) && status.busy.is_some() && status.busy_model == Some(chosen) + } + + /// The Roto Brush model Settings chose and its state (`roto.status`). + pub fn roto_model(&self) -> Value { + let st = self.models.status(Task::Mask); + json!({ + "selected": self.chosen_model(Task::Mask), + "installed": self.roto_model_info().is_none_or(|m| self.model_path(m).is_some()), + "active": st.active, + "loading": self.roto_model_loading(), + "error": st.load_error.or(st.error.filter(|_| st.error_model == Some(self.chosen_model(Task::Mask)))), + }) + } + + /// What `roto.status` says when the chosen model is not the one in use for this layer (`None`: + /// nothing to say). `uses_model`: the layer segments with a model at all (Roto Brush 1.0 does not). + pub fn roto_model_notice(&self, uses_model: bool) -> Option { + let info = self.roto_model_info()?; + if !uses_model { + return Some(format!("{} is selected, but this layer is Roto Brush 1.0: it segments with the classic engine", info.name)); + } + let st = self.models.status(Task::Mask); + if st.active.as_deref() == Some(info.id) { + None + } else if self.model_path(info).is_none() { + Some(format!("{} is selected but not installed: propagate refuses until it is installed or Classic is chosen", info.name)) + } else if let Some(e) = st.load_error { + Some(format!("{} is selected but failed to load ({e}): propagate refuses until it is selected again", info.name)) + } else { + Some(format!("{} is selected but not loaded yet: propagate loads it first with wait: true, and refuses without it", info.name)) + } + } + + /// What `roto.status` says when the saved tracking applies but Settings choose another model (`None`: + /// they agree, or nothing is saved). + pub fn saved_tracking_notice(&self, saved: Option<&str>) -> Option { + let saved = saved?; + let chosen = self.chosen_model(Task::Mask); + if saved == chosen { + return None; + } + let name = |id: &str| seg::info(id).map_or(ROTO.classic_name, |m| m.name); + Some(format!("The saved tracking was made with {}; {} is chosen: Propagate again to replace it", name(saved), name(chosen))) + } + + /// Make sure the Roto Brush model Settings chose is in use before a job that segments with it + /// starts, so the job never runs on the classic engine without saying so. With `wait`, a load + /// under way is waited for and one not started is loaded here; without it, a model that is not + /// ready is refused (and starts loading). A model that is not installed, or failed to load, is + /// refused either way. The classic engine needs nothing. + pub fn ensure_roto_model(&mut self, wait: bool) -> std::result::Result<(), String> { + let Some(info) = self.roto_model_info() else { return Ok(()) }; + if self.models.status(Task::Mask).active.as_deref() == Some(info.id) { + return Ok(()); + } + // A download or load under way (from Settings, or `select` without `wait`) finishes first. + // Nothing is in flight in the browser build, so this never sleeps there. + if wait { + self.wait_model_worker(Task::Mask, info.id)?; + } + if self.model_path(info).is_none() { + if self.models.status(Task::Mask).busy.is_some() { + return Err(format!("{} is still downloading: try again when it is ready, or pass wait: true", info.name)); + } + return Err(format!("{} is selected for Roto Brush but not installed: install or download it, or choose {}", info.name, ROTO.classic_name)); + } + let st = self.models.status(Task::Mask); + if st.active.as_deref() != Some(info.id) && st.busy.is_none() && st.load_error.is_none() { + self.apply_model(Task::Mask, wait); + } + let st = self.models.status(Task::Mask); + if st.active.as_deref() == Some(info.id) { + return Ok(()); + } + if let Some(e) = st.load_error { + return Err(format!("{} failed to load: {e}", info.name)); + } + Err(format!("{} is still loading: try again when it is ready, or pass wait: true", info.name)) + } + + /// Free a Roto Brush model that has been idle for [`ROTO_MODEL_IDLE`] (and is not under a job). The + /// choice stays: it loads again when a job or a stroke needs it. + fn unload_idle_roto_model(&mut self) { + if self.is_roto_running() || self.models.status(Task::Mask).busy.is_some() { + return; + } + let Some(m) = crate::effects::roto::installed_model() else { return }; + if m.idle_secs().is_some_and(|s| s >= ROTO_MODEL_IDLE.as_secs_f64()) { + let name = m.info().name; + drop(m); + self.models.set_active(Task::Mask, None); + self.events.push(crate::Event::Toast { + message: format!("{name} unloaded after {} minutes idle; it loads again when it is used", ROTO_MODEL_IDLE.as_secs() / 60), + error: false, + }); + } + } + + /// Start loading the chosen Roto Brush model again if it was unloaded for being idle (before a stroke uses it). + /// The load runs on the model worker, so the stroke is never blocked on it (the classic engine segments it + /// meanwhile and `poll_models` installs the model when it arrives), and a model that already failed to load is + /// not retried on every stroke: choosing it again clears the error. + pub fn reload_roto_model(&mut self) { + let chosen = self.chosen_model(Task::Mask).to_string(); + let st = self.models.status(Task::Mask); + if chosen != seg::CLASSICAL && st.active.is_none() && st.busy.is_none() && st.load_error.is_none() { + self.apply_model(Task::Mask, false); + } + } + + /// Finish what the model workers did: a toast, and loading a model that just arrived. + pub fn poll_models(&mut self) { + self.unload_idle_roto_model(); + for task in [Task::Mask, Task::Face] { + let done = self.models.lane(task).lock().ok().and_then(|mut s| s.done.take()); + if let Some((message, error)) = done { + // a discarded (stale) load has no message + if !message.is_empty() { + self.events.push(crate::Event::Toast { message, error }); + } + self.apply_model(task, false); + } + } + } +} + +fn load_file(path: &Path, id: &str) -> std::result::Result { + let bytes = std::fs::read(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?; + // SAM 2 runs on candle and is native only; every other model loads through the registry. + #[cfg(not(target_arch = "wasm32"))] + if effectcraft_sam2::mask::SIZES.iter().any(|m| m.id == id) { + return effectcraft_sam2::mask::load(&bytes).map(|m| Loaded::Mask(m)); + } + seg::load(id, &bytes) +} + +/// Run `job` on a worker thread (`wait`: right here), reporting through `status` (desktop; the +/// browser build has no trained models yet). +fn spawn( + status: &Arc>, + label: &'static str, + what: String, + wait: bool, + generation: Option, + model: &'static str, + job: impl FnOnce(&Arc>) -> std::result::Result + Send + 'static, +) { + if cfg!(target_arch = "wasm32") { + set(status, |s| { + let message = "trained models are available in the desktop app".to_string(); + if generation.is_some() { + s.load_error = Some(message.clone()); + } + s.error = Some(message); + s.error_model = Some(model); + }); + return; + } + set(status, |s| { + s.busy = Some(what); + s.busy_model = Some(model); + s.error = None; + s.error_model = None; + }); + let st = status.clone(); + let run = move || { + let r = job(&st); + set(&st, |s| { + s.busy = None; + s.busy_model = None; + if generation.is_some_and(|g| g != s.generation) { + // Keep a completion marker so polling starts the newly selected model. + s.done = Some((String::new(), false)); + return; + } + match r { + Ok(msg) => s.done = Some((msg, false)), + Err(e) => { + s.done = Some((format!("{label} model: {e}"), true)); + if generation.is_some() { + s.load_error = Some(e.clone()); + } + s.error = Some(e); + s.error_model = Some(model); + } + } + }); + }; + if wait { + run(); + } else if let Err(e) = std::thread::Builder::new().name("ec-model".into()).spawn(run) { + set(status, |s| { + s.busy = None; + s.busy_model = None; + let message = format!("cannot start: {e}"); + if generation.is_some() { + s.load_error = Some(message.clone()); + } + s.error = Some(message); + s.error_model = Some(model); + }); + } +} + +/// The notice kept next to `m`'s weights. +fn notice_path(dir: &Path, m: &ModelInfo) -> PathBuf { + dir.join(format!("{}.NOTICE.txt", m.file_name)) +} + +/// Copy verified weights into the models folder (atomically), with their notice. +fn install_bytes(dir: &Path, m: &ModelInfo, bytes: &[u8]) -> std::result::Result { + if bytes.len() as u64 != m.size || seg::sha256::hex(bytes) != m.sha256 { + return Err(format!("this is not the published {} file (size or SHA-256 differs)", m.name)); + } + use crate::config::FileOps; + let path = dir.join(m.file_name); + let files = crate::config::StdFiles; + files.write(&path, bytes).map_err(|e| format!("cannot write {}: {e}", path.display()))?; + files.write(¬ice_path(dir, m), seg::notice(m).as_bytes()).map_err(|e| format!("cannot write the notice: {e}"))?; + Ok(path) +} + +fn models_for_platform(task: Task, wasm: bool) -> impl Iterator { + seg::models(task).filter(move |m| !wasm || !m.id.starts_with("sam2.1-hiera-")) +} + +fn target_models(task: Task) -> impl Iterator { + models_for_platform(task, cfg!(target_arch = "wasm32")) +} + +fn model_p(p: &Value, cmd: &str, task: Task) -> Result<&'static ModelInfo> { + let id = str_p(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?; + target_models(task).find(|m| m.id == id).ok_or_else(|| bad(cmd, format!("unknown model `{id}` (see {}.models)", kind(task).prefix))) +} + +fn list(s: &mut Session, task: Task) -> Result { + let k = kind(task); + let st = s.models.status(task); + let chosen = s.chosen_model(task).to_string(); + let mut v = vec![json!({ + "id": seg::CLASSICAL, + "name": k.classic_name, + "description": k.classic_description, + "licence": "MIT OR Apache-2.0", + "installed": true, + "selected": chosen == seg::CLASSICAL, + "active": st.active.is_none(), + })]; + for m in target_models(task) { + #[cfg(not(target_arch = "wasm32"))] + let (recommended, hint) = (effectcraft_sam2::mask::recommended().id == m.id, effectcraft_sam2::mask::hint(m.id)); + #[cfg(target_arch = "wasm32")] + let (recommended, hint) = (false, None::); + v.push(json!({ + "recommended": recommended, + "hint": hint, + "id": m.id, + "name": m.name, + "description": m.description, + "authors": m.authors, + "licence": m.licence, + "licenceUrl": m.licence_url, + "homepage": m.homepage, + "url": m.url, + "source": m.source, + "sourceFile": m.source.rsplit('/').next().unwrap_or(m.file_name), + "size": m.size, + "sha256": m.sha256, + "installed": s.model_path(m).is_some(), + "selected": chosen == m.id, + "active": st.active.as_deref() == Some(m.id), + })); + } + Ok(json!({"models": v, "busy": st.busy, "error": st.error, "folder": s.models_dir().map(|d| d.display().to_string())})) +} + +/// After a `wait`: the job's failure, as the command's. +fn waited(s: &Session, task: Task, cmd: &str) -> Result<()> { + match s.models.status(task).error { + Some(e) => Err(bad(cmd, e)), + None => Ok(()), + } +} + +fn select(s: &mut Session, p: &Value, task: Task) -> Result { + let cmd = format!("{}.model.select", kind(task).prefix); + let id = str_p(p, "id").ok_or_else(|| bad(&cmd, "missing `id`"))?; + if id != seg::CLASSICAL { + model_p(p, &cmd, task)?; + } + let wait = b_p(p, "wait").unwrap_or(false); + if wait && id != seg::CLASSICAL && s.models.status(task).active.as_deref() != Some(id) { + s.wait_model_worker(task, id).map_err(|error| bad(&cmd, error))?; + } + s.choose_model(task, id); + if wait { + s.apply_model(task, true); + waited(s, task, &cmd)?; + } + s.save_prefs(); + s.prefs_changed(); + Ok(json!({"model": id, "active": s.models.status(task).active})) +} + +fn install(s: &mut Session, p: &Value, task: Task) -> Result { + let cmd = format!("{}.model.install", kind(task).prefix); + let path = str_p(p, "path").ok_or_else(|| bad(&cmd, "missing `path` (the downloaded weights file)"))?; + let dir = s.models_dir().ok_or_else(|| bad(&cmd, "no settings folder to install into"))?; + let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?; + // Which model it is: the one asked for, or the one whose checksum it has. + let m = match str_p(p, "id") { + Some(_) => model_p(p, &cmd, task)?, + None => { + let h = seg::sha256::hex(&bytes); + target_models(task) + .find(|m| m.sha256 == h) + .ok_or_else(|| bad(&cmd, "this file is not a supported published model in the registry (SHA-256 differs)"))? + } + }; + let dest = install_bytes(&dir, m, &bytes).map_err(|e| bad(&cmd, e))?; + let wait = b_p(p, "wait").unwrap_or(false); + s.retry_installed_model(task, m.id, wait).map_err(|error| bad(&cmd, error))?; + Ok(json!({"id": m.id, "path": dest.display().to_string()})) +} + +fn download(s: &mut Session, p: &Value, task: Task) -> Result { + let k = kind(task); + let cmd = format!("{}.model.download", k.prefix); + let m = model_p(p, &cmd, task)?; + if m.url.is_empty() { + return Err(bad(&cmd, format!("{} has no app download available: use Install from File with the SHA-256-verified weights", m.name))); + } + let dir = s.models_dir().ok_or_else(|| bad(&cmd, "no settings folder to install into"))?; + if s.models.status(task).busy.is_some() { + return Err(bad(&cmd, "a model is already downloading or loading")); + } + let size = if m.size >= 10_000_000 { format!("{} MB", m.size / 1_000_000) } else { format!("{:.1} MB", m.size as f64 / 1e6) }; + let wait = b_p(p, "wait").unwrap_or(false); + spawn(s.models.lane(task), k.label, format!("Downloading {} ({size})…", m.name), wait, None, m.id, move |_| { + std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?; + let tmp = dir.join(format!("{}.part", m.file_name)); + let out = std::process::Command::new("curl") + .args(["-fsSL", "--retry", "2", "--connect-timeout", "20", "-o"]) + .arg(&tmp) + .arg(m.url) + .output() + .map_err(|e| format!("downloading needs curl ({e}); or download {} in a browser and use Install from File", m.url))?; + if !out.status.success() { + let _ = std::fs::remove_file(&tmp); + return Err(format!("download failed: {}", String::from_utf8_lossy(&out.stderr).trim())); + } + let bytes = std::fs::read(&tmp).map_err(|e| format!("cannot read the download: {e}"))?; + let _ = std::fs::remove_file(&tmp); + install_bytes(&dir, m, &bytes)?; + Ok(format!("{}: {} downloaded and verified", k.label, m.name)) + }); + if wait { + waited(s, task, &cmd)?; + s.apply_model(task, true); + waited(s, task, &cmd)?; + return Ok(json!({"id": m.id, "installed": true})); + } + Ok(json!({"id": m.id, "downloading": true})) +} + +fn remove(s: &mut Session, p: &Value, task: Task) -> Result { + let m = model_p(p, &format!("{}.model.remove", kind(task).prefix), task)?; + let Some(path) = s.model_path(m) else { return Ok(json!({"removed": false})) }; + if s.models.status(task).active.as_deref() == Some(m.id) { + s.models.set_active(task, None); + } + std::fs::remove_file(&path).map_err(|e| EngineError::Other(format!("cannot remove {}: {e}", path.display())))?; + if let Some(dir) = path.parent() { + let _ = std::fs::remove_file(notice_path(dir, m)); + } + Ok(json!({"removed": true})) +} + +pub fn specs() -> Vec { + vec![ + query!("roto.models", "Roto Brush Models", "{}", |s, _| list(s, Task::Mask)), + cmd!( + "roto.model.select", + "Use Roto Brush Model", + [], + None, + "{id: classical|mobilesam|sam2.1-hiera-tiny|sam2.1-hiera-small|sam2.1-hiera-base-plus|sam2.1-hiera-large, wait?} (Roto Brush 2.0 / 3.0 use it; wait: loaded before returning)", + always, + |s, p| select(s, p, Task::Mask) + ), + cmd!( + "roto.model.install", + "Install Roto Brush Model", + [], + None, + "{path: weights file, id?, wait?} — verified against the registry's SHA-256", + always, + |s, p| install(s, p, Task::Mask) + ), + cmd!( + "roto.model.download", + "Download Roto Brush Model", + [], + None, + "{id, wait?} — the official weights, verified (desktop; uses the system curl)", + always, + |s, p| download(s, p, Task::Mask) + ), + cmd!("roto.model.remove", "Remove Roto Brush Model", [], None, "{id}", always, |s, p| remove(s, p, Task::Mask)), + query!("face.models", "Face Tracking Models", "{}", |s, _| list(s, Task::Face)), + cmd!( + "face.model.select", + "Use Face Tracking Model", + [], + None, + "{id: classical|mediapipe-face, wait?} (face tracking uses it; wait: loaded before returning)", + always, + |s, p| select(s, p, Task::Face) + ), + cmd!( + "face.model.install", + "Install Face Tracking Model", + [], + None, + "{path: model file, id?, wait?} — verified against the registry's SHA-256", + always, + |s, p| install(s, p, Task::Face) + ), + cmd!( + "face.model.download", + "Download Face Tracking Model", + [], + None, + "{id, wait?} — the official model, verified (desktop; uses the system curl)", + always, + |s, p| download(s, p, Task::Face) + ), + cmd!("face.model.remove", "Remove Face Tracking Model", [], None, "{id}", always, |s, p| remove(s, p, Task::Face)), + ] +} + +#[cfg(test)] +mod availability_tests { + use super::*; + + /// SAM 2 cannot start a network operation; file installation still verifies the published hash. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn sam2_is_install_from_file_only() { + let mut session = Session::default(); + let listed = session.execute("roto.models", json!({})).unwrap(); + for model in [seg::SAM2_TINY, seg::SAM2_SMALL, seg::SAM2_BASE_PLUS, seg::SAM2_LARGE] { + let error = session.execute("roto.model.download", json!({"id": model.id})).unwrap_err(); + assert!(error.to_string().contains("Install from File")); + assert!(session.models.status(Task::Mask).busy.is_none()); + assert!(install_bytes(Path::new("unused"), &model, b"invalid weights").unwrap_err().contains("SHA-256 differs")); + let row = listed["models"].as_array().unwrap().iter().find(|row| row["id"] == model.id).unwrap(); + assert_eq!(row["source"], model.source); + assert_eq!(row["sourceFile"], "model.safetensors"); + } + } + + /// A waiting selection cannot claim completion while unrelated work prevents its model loading. + #[test] + fn waiting_selection_refuses_an_unrelated_worker_unless_the_model_is_active() { + crate::tests::roto::hold_roto_cache_test_lock(); + for active in [false, true] { + let mut session = Session::default(); + session.choose_model(Task::Mask, if active { "mobilesam" } else { seg::CLASSICAL }); + set(session.models.lane(Task::Mask), |status| { + status.active = active.then(|| "mobilesam".into()); + status.busy = Some("Downloading SAM 2".into()); + status.busy_model = Some("sam2.1-hiera-tiny"); + }); + let result = session.execute("roto.model.select", json!({"id": "mobilesam", "wait": true})); + if active { + assert_eq!(result.unwrap()["active"], "mobilesam"); + } else { + assert!(result.unwrap_err().to_string().contains("another model operation")); + assert_eq!(session.chosen_model(Task::Mask), seg::CLASSICAL, "a refused selection must not change settings"); + } + assert!(session.models.status(Task::Mask).busy.is_some()); + } + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn stale_model_worker_completions_do_not_poison_the_current_choice() { + for failure in [false, true] { + let lane = Arc::new(Mutex::new(Status::default())); + spawn(&lane, "Roto Brush", "Loading old choice".into(), true, Some(0), "mobilesam", move |status| { + set(status, |s| s.generation += 1); + if failure { Err("old choice failed".into()) } else { Ok("old choice loaded".into()) } + }); + let status = lane.lock().unwrap(); + assert!(status.busy.is_none()); + assert!(status.error.is_none(), "an obsolete worker must not block the current model"); + assert!(status.load_error.is_none()); + assert_eq!(status.done, Some((String::new(), false)), "polling must still retry the current choice"); + } + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn current_load_and_download_failures_remain_actionable() { + for generation in [Some(0), None] { + let lane = Arc::new(Mutex::new(Status::default())); + spawn(&lane, "Roto Brush", "Working".into(), true, generation, "mobilesam", move |status| { + if generation.is_none() { + set(status, |s| s.generation += 1); + } + Err("current operation failed".into()) + }); + let status = lane.lock().unwrap(); + assert!(status.busy.is_none()); + assert_eq!(status.error.as_deref(), Some("current operation failed")); + assert_eq!(status.load_error.as_deref(), generation.map(|_| "current operation failed")); + assert_eq!(status.done, Some(("Roto Brush model: current operation failed".into(), true))); + } + } + + #[test] + fn choosing_a_model_clears_its_previous_load_error_even_on_retry() { + let mut session = Session::default(); + for id in ["mobilesam", "mobilesam", seg::CLASSICAL] { + let previous = session.chosen_model(Task::Mask).to_string(); + let generation = session.models.status(Task::Mask).generation; + set(session.models.lane(Task::Mask), |s| { + s.error = Some("previous load failed".into()); + s.load_error = s.error.clone(); + }); + session.choose_model(Task::Mask, id); + let status = session.models.status(Task::Mask); + assert!(status.error.is_none()); + assert!(status.load_error.is_none()); + assert_eq!(status.generation, generation + u64::from(previous != id)); + } + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn selected_active_model_is_ready_despite_an_unrelated_download_error() { + let dir = std::env::temp_dir().join(format!("ec-model-ready-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let info = seg::info("mobilesam").unwrap(); + let file = std::fs::File::create(dir.join(info.file_name)).unwrap(); + file.set_len(info.size).unwrap(); + drop(file); + let mut session = Session { models_dir: Some(dir.clone()), ..Default::default() }; + session.choose_model(Task::Mask, info.id); + set(session.models.lane(Task::Mask), |s| { + s.active = Some(info.id.into()); + s.error = Some("another model could not be downloaded".into()); + }); + assert_eq!(session.ensure_roto_model(false), Ok(())); + assert!(session.roto_model()["error"].is_null()); + set(session.models.lane(Task::Mask), |status| { + status.error_model = Some("mobilesam"); + status.error = Some("selected model download failed".into()); + }); + assert_eq!(session.roto_model()["error"], "selected model download failed"); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn an_active_model_and_selected_install_do_not_wait_for_other_downloads() { + crate::tests::roto::hold_roto_cache_test_lock(); + let mut session = Session::default(); + session.choose_model(Task::Mask, "mobilesam"); + set(session.models.lane(Task::Mask), |status| { + status.active = Some("mobilesam".into()); + status.busy = Some("Downloading SAM 2".into()); + status.busy_model = Some("sam2.1-hiera-tiny"); + }); + assert_eq!(session.ensure_roto_model(true), Ok(())); + assert_eq!(session.retry_installed_model(Task::Mask, "mobilesam", true), Ok(true)); + let status = session.models.status(Task::Mask); + assert!(status.busy.is_some(), "the unrelated operation must still be running"); + assert!(!session.roto_model_loading()); + assert_eq!(session.roto_model()["loading"], false); + } + + #[test] + fn waiting_on_an_inactive_model_refuses_unrelated_work_without_waiting() { + crate::tests::roto::hold_roto_cache_test_lock(); + let mut session = Session::default(); + session.choose_model(Task::Mask, "mobilesam"); + set(session.models.lane(Task::Mask), |status| { + status.busy = Some("Downloading SAM 2".into()); + status.busy_model = Some("sam2.1-hiera-tiny"); + }); + assert!(session.ensure_roto_model(true).unwrap_err().contains("another model operation")); + assert!(session.retry_installed_model(Task::Mask, "mobilesam", true).unwrap_err().contains("another model operation")); + assert!(session.models.status(Task::Mask).busy.is_some()); + assert!(!session.roto_model_loading()); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn downloads_do_not_block_an_inactive_model_or_retry_a_failed_load() { + crate::tests::roto::hold_roto_cache_test_lock(); + let dir = std::env::temp_dir().join(format!("ec-model-download-state-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let info = seg::info("mobilesam").unwrap(); + std::fs::File::create(dir.join(info.file_name)).unwrap().set_len(info.size).unwrap(); + let mut session = Session { models_dir: Some(dir.clone()), ..Default::default() }; + session.choose_model(Task::Mask, info.id); + let lane = session.models.lane(Task::Mask).clone(); + spawn(&lane, "Roto Brush", "Downloading another model".into(), true, None, "sam2.1-hiera-tiny", |_| Err("download failed".into())); + assert_eq!(session.models.status(Task::Mask).error.as_deref(), Some("download failed")); + assert!(session.models.status(Task::Mask).load_error.is_none()); + let error = session.ensure_roto_model(true).unwrap_err(); + assert!(!error.contains("download failed"), "the installed model must be loaded despite a download failure"); + let failure = session.models.status(Task::Mask).load_error; + assert!(failure.is_some(), "the synthetic weights were actually loaded and rejected"); + for download_failed in [true, false] { + spawn(&lane, "Roto Brush", "Downloading another model".into(), true, None, "sam2.1-hiera-tiny", move |_| { + if download_failed { Err("download failed".into()) } else { Ok("downloaded".into()) } + }); + session.poll_models(); + session.reload_roto_model(); + let status = session.models.status(Task::Mask); + assert_eq!(status.load_error, failure, "a download must not clear the selected model's load failure"); + assert!(status.busy.is_none(), "polling and strokes must not retry a failed model"); + assert!(session.ensure_roto_model(true).unwrap_err().contains(failure.as_deref().unwrap())); + assert!(session.roto_model_notice(true).unwrap().contains(failure.as_deref().unwrap())); + } + crate::effects::roto::set_model(None); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn installing_an_unselected_model_keeps_the_selected_models_load_failure() { + crate::tests::roto::hold_roto_cache_test_lock(); + let mut session = Session::default(); + session.choose_model(Task::Mask, "mobilesam"); + set(session.models.lane(Task::Mask), |s| { + s.error = Some("selected model failed".into()); + s.load_error = s.error.clone(); + }); + let before = session.models.status(Task::Mask); + for wait in [false, true] { + assert!(!session.retry_installed_model(Task::Mask, "sam2.1-hiera-tiny", wait).unwrap()); + assert_eq!(session.models.status(Task::Mask), before); + } + // Installing the selected weights is an explicit retry, even if the installed path + // has since gone away (no load is started from this session's missing model folder). + assert!(session.retry_installed_model(Task::Mask, "mobilesam", false).unwrap()); + assert!(session.models.status(Task::Mask).load_error.is_none()); + assert!(session.models.status(Task::Mask).error.is_none()); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn installing_selected_weights_with_wait_finishes_the_replacement_load() { + crate::tests::roto::hold_roto_cache_test_lock(); + let dir = std::env::temp_dir().join(format!("ec-model-install-running-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let info = seg::info("mobilesam").unwrap(); + std::fs::File::create(dir.join(info.file_name)).unwrap().set_len(info.size).unwrap(); + let mut session = Session { models_dir: Some(dir.clone()), ..Default::default() }; + session.choose_model(Task::Mask, info.id); + let generation = session.models.status(Task::Mask).generation; + let lane = session.models.lane(Task::Mask).clone(); + spawn(&lane, "Roto Brush", "Loading the previous file".into(), false, Some(generation), info.id, move |status| { + let started = Instant::now(); + while status.lock().unwrap().generation == generation { + if started.elapsed() > Duration::from_secs(3) { + return Err("test did not start the replacement".into()); + } + std::thread::sleep(Duration::from_millis(1)); + } + Err("previous file failed".into()) + }); + assert!(session.retry_installed_model(Task::Mask, info.id, true).unwrap_err().contains("SHA-256")); + let status = session.models.status(Task::Mask); + assert!(status.busy.is_none()); + assert!( + status.load_error.as_ref().is_some_and(|error| error.contains("SHA-256")), + "wait must finish loading the replacement weights and report their actual validation error" + ); + assert_ne!(status.error.as_deref(), Some("previous file failed")); + crate::effects::roto::set_model(None); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn browser_models_exclude_native_trackers_and_keep_portable_models() { + let browser: Vec<_> = models_for_platform(Task::Mask, true).map(|m| m.id).collect(); + assert_eq!(browser, ["mobilesam"]); + let desktop: Vec<_> = models_for_platform(Task::Mask, false).map(|m| m.id).collect(); + assert_eq!(desktop, ["mobilesam", "sam2.1-hiera-tiny", "sam2.1-hiera-small", "sam2.1-hiera-base-plus", "sam2.1-hiera-large"]); + for wasm in [false, true] { + assert_eq!(models_for_platform(Task::Face, wasm).map(|m| m.id).collect::>(), ["mediapipe-face"]); + } + } +} diff --git a/crates/engine/src/roto.rs b/crates/engine/src/analysis/roto.rs similarity index 81% rename from crates/engine/src/roto.rs rename to crates/engine/src/analysis/roto.rs index 49f53d39b..67b0afc68 100644 --- a/crates/engine/src/roto.rs +++ b/crates/engine/src/analysis/roto.rs @@ -6,7 +6,7 @@ //! `wait`; in the browser the background is a Web Worker whose segmentations stream back, //! [`crate::offload`]), frame by frame outward from the base frame, rendering the effect's input //! (`Renderer::layer_input`, source → masks → the effects above it). `roto.freeze` runs the same -//! pass and stores the final mattes in the effect (one undo step). [`sync`], run after every +//! pass and stores the final mattes in the effect (one undo step). `sync`, run after every //! edit, keeps each instance's hidden Input Key equal to a signature of what its frames are made //! of (the layer's source and timing, its masks and the effects above it) and drops frozen //! mattes made from other frames. @@ -22,7 +22,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use effectcraft_effects::object_matte as om; -use effectcraft_effects::roto::{self as fx, FROZEN, FrozenCache, INPUT_KEY}; +use effectcraft_effects::roto::{self as fx, FROZEN, FrozenCache, INPUT_KEY, TRACKED}; use effectcraft_effects::{Params, flatten_params}; use effectcraft_keyframe::Value; use effectcraft_project::{GroupKind, ItemId, ItemKind, Layer, LayerId, Project, PropGroup, Uid}; @@ -51,6 +51,9 @@ pub struct RotoOptions { pub boundary_color: [f64; 3], /// Start propagating in the background after each stroke. pub auto_propagate: bool, + /// The object new strokes belong to (0 is the first). With a tracker every object is followed on its own + /// and their mattes are united; the classic engine treats all foreground strokes as one object. + pub object: u32, } impl Default for RotoOptions { @@ -63,10 +66,14 @@ impl Default for RotoOptions { overlay_opacity: 50.0, boundary_color: [1.0, 0.25, 0.75], auto_propagate: true, + object: 0, } } } +/// The highest object id (SAM 2 follows up to 32 objects in one pass). +pub const MAX_OBJECT_ID: u32 = 31; + pub const VIEWS: [&str; 4] = ["alphaBoundary", "alpha", "alphaOverlay", "none"]; /// What a job does. @@ -112,6 +119,10 @@ pub struct RotoProgress { pub error: Option, /// The job works on an Object Matte (else a Roto Brush & Refine Edge). pub object_matte: bool, + /// What the model's tracker found, when one followed the object (SAM 2). + pub tracked: Option, + /// Why the tracker was not used, when a tracking model was chosen but the classic engine ran. + pub tracker_note: Option, } impl RotoProgress { @@ -131,6 +142,8 @@ pub struct RotoShared { pub cancel: AtomicBool, pub state: Mutex, frozen: Mutex>, + /// The tracker's frames, as the saved tracking (JSON) to store with the instance. + saved: Mutex>, } pub struct RotoJob { @@ -249,6 +262,7 @@ pub(crate) fn sync(before: &Project, after: &mut Project) -> Vec<(ItemId, LayerI set_str(g, INPUT_KEY, key); if had { set_str(g, FROZEN, String::new()); + set_str(g, TRACKED, String::new()); } } } @@ -279,7 +293,7 @@ pub(crate) fn params_at(p: &Project, cid: ItemId, layer: &Layer, g: &PropGroup, } /// The layer's source size (layer pixels). -pub(crate) fn layer_size(p: &Project, comp: &effectcraft_project::Comp, layer: &Layer) -> [f64; 2] { +pub fn layer_size(p: &Project, comp: &effectcraft_project::Comp, layer: &Layer) -> [f64; 2] { effectcraft_render::effect_bounds(p, comp, layer).0 } @@ -432,10 +446,49 @@ async fn run_work(w: Work, shared: &RotoShared) { s.object_matte = w.object_matte; } let cancel = || shared.cancel.load(Ordering::Relaxed); - let chain = fx::Chain::new(&w.params, w.size, w.fps, 1.0); + let mut chain = fx::Chain::new(&w.params, w.size, w.fps, 1.0); + if w.task == RotoTask::Propagate && !w.object_matte { + chain.prepare_propagation(); + } let model = fx::installed_model(); let key = if w.object_matte { om::frozen_key(&w.params, w.size, w.fps, model.as_deref()) } else { fx::frozen_key(&w.params, w.size, w.fps) }; let mut frozen = FrozenCache { key, ..Default::default() }; + // A tracking model (SAM 2) follows the object through the whole span in one pass; the loop + // below then finds every frame cached. Without one (or if it fails) nothing changes: the + // classic engine runs frame by frame. + if w.task == RotoTask::Propagate && !w.object_matte && chain.has_tracker() { + let mut get = |g: i64| src.get(g); + let mut report = |n: usize| { + let mut s = lock(&shared.state); + s.done = n as u64; + s.elapsed = t0.elapsed().as_secs_f64(); + s.fps = if s.elapsed > 0.0 { s.done as f64 / s.elapsed } else { 0.0 }; + crate::offload::report(s.done, s.total); + }; + let result = chain.track_frames(Some(&w.frames), &mut get, &cancel, &mut report); + if cancel() { + let mut s = lock(&shared.state); + s.cancelled = true; + s.finished = true; + return; + } + // The frames it followed replace the saved tracking (see `poll_roto_job`): none if it failed. + *lock(&shared.saved) = Some(match &result { + Ok(_) => fx::tracks_of(&chain).map(|t| t.to_json()).unwrap_or_default(), + Err(_) => String::new(), + }); + if result.is_err() { + chain.forget_tracking(); + } + let mut s = lock(&shared.state); + match result { + Ok(summary) => s.tracked = summary, + Err(e) => s.tracker_note = Some(format!("tracker unavailable, classic engine used: {e}")), + } + } else if w.task == RotoTask::Propagate && !w.object_matte { + // With the classic engine, Propagate leaves no saved tracking behind. + *lock(&shared.saved) = Some(String::new()); + } for (k, f) in w.frames.iter().enumerate() { if cancel() { let mut s = lock(&shared.state); @@ -450,7 +503,18 @@ async fn run_work(w: Work, shared: &RotoShared) { (RotoTask::Propagate, true) => om::ensure(&w.params, w.size, w.fps, *f, model.clone(), &mut get, &cancel).is_some(), (RotoTask::Freeze, true) => { let img = get(*f); - match img.map(|img| (om::frame_matte(&w.params, w.size, w.fps, *f, 1.0, &img, model.clone(), &mut get, &cancel), img)) { + match img.map(|img| { + ( + om::frame_matte( + &w.params, + &om::MatteFrame { layer_size: w.size, fps: w.fps, frame: *f, scale: 1.0, img: &img }, + model.clone(), + &mut get, + &cancel, + ), + img, + ) + }) { Some((om::FrameMatte::Alpha(a), img)) => { frozen.size = [img.width as usize, img.height as usize]; frozen.frames.insert(*f, fx::freeze_frame(&a, &[])); @@ -581,9 +645,37 @@ impl Session { } } + /// Whether the tracking saved with Roto Brush `effect` covers every frame a Freeze would write. + fn freeze_is_saved(&self, comp: ItemId, layer: LayerId, effect: Uid) -> bool { + let Some(c) = self.project.comp(comp) else { return false }; + let Some(l) = c.layer(layer) else { return false }; + let Some(g) = l.effects().and_then(|f| f.groups().find(|g| g.uid == effect)).filter(|g| is_roto(g)) else { return false }; + let pstat = params_static(g); + let d = fx::data(&pstat); + if d.is_empty() { + return false; + } + let t = comp_time_of(c, l, d.base.unwrap_or(0)); + let params = params_at(&self.project, comp, l, g, t, self.expr.as_deref()); + fx::saved_covers(¶ms, layer_size(&self.project, c, l), c.frame_rate.as_f64(), &job_frames(&d, Direction::Both)) + } + + /// Whether `effect` on `layer` segments with the model Settings chose: Object Matte always, + /// Roto Brush unless it is version 1.0 (the classic engine). + pub(crate) fn uses_roto_model(&self, comp: ItemId, layer: LayerId, effect: Uid) -> bool { + let g = self.project.comp(comp).and_then(|c| c.layer(layer)).and_then(|l| l.effects()).and_then(|f| f.groups().find(|g| g.uid == effect)); + g.is_some_and(|g| is_object_matte(g) || fx::uses_model(¶ms_static(g))) + } + /// Start propagating (or freezing) Roto Brush `effect` on `layer`. `wait`: block until done /// (wasm32 always does). Returns the number of frames. pub fn start_roto(&mut self, comp: ItemId, layer: LayerId, effect: Uid, task: RotoTask, dir: Direction, wait: bool) -> Result { + // The model must be in use before the job starts: otherwise it would run on the classic + // engine without saying so. + // A Freeze whose frames all have saved tracking reads them without the model. + if self.uses_roto_model(comp, layer, effect) && !(task == RotoTask::Freeze && self.freeze_is_saved(comp, layer, effect)) { + self.ensure_roto_model(wait)?; + } if self.is_roto_running() { if !wait { return Err("a Roto Brush job is already running".into()); @@ -686,6 +778,17 @@ impl Session { Ok(n) } + /// Save the tracker's frames in the instance, unless its strokes changed while it tracked. No undo + /// step: the frames are a cache of the strokes and the span (a stale copy is ignored). + fn save_tracks(&mut self, cid: ItemId, lid: LayerId, uid: Uid, strokes: &str, json: String) { + let p = Arc::make_mut(&mut self.project); + if let Some(g) = p.comp_mut(cid).and_then(|c| c.layer_mut(lid)).and_then(|l| l.props.find_group_mut(uid)) + && guard_of(g) == strokes + { + set_str(g, TRACKED, json); + } + } + /// Finish a done job: Freeze writes the frozen mattes (one undo step). fn poll_roto_job(&mut self) -> bool { let Some(job) = &self.roto_job else { return false }; @@ -712,6 +815,10 @@ impl Session { RotoTask::Propagate => { let verb = if job.object_matte { "tracked" } else { "propagated" }; self.events.push(Event::Toast { message: format!("{name}: {verb} {} frame(s) in {:.1} s", st.done, st.elapsed), error: false }); + // The frames the tracker followed are saved with the instance. + if let Some(json) = lock(&job.shared.saved).take() { + self.save_tracks(job.comp, job.layer, job.effect, &job.strokes, json); + } // Rendered frames depend on the cache: redraw. self.bump(); } @@ -746,17 +853,39 @@ impl Session { pub fn poll_roto(&mut self, auto: bool) -> bool { self.poll_models(); let changed = self.poll_roto_job(); - if auto && self.roto_job.is_none() && self.offloaded_roto().is_none() && self.state.roto.auto_propagate { + // Edited instances stay pending while the model they will segment with is still loading. + let loading = self.roto_model_loading(); + if auto && self.roto_job.is_none() && self.offloaded_roto().is_none() && self.state.roto.auto_propagate && !loading { while let Some((c, l, e)) = self.roto_pending.first().copied() { self.roto_pending.remove(0); - if self.start_roto(c, l, e, RotoTask::Propagate, Direction::Both, false).is_ok() { - return true; + match self.start_roto(c, l, e, RotoTask::Propagate, Direction::Both, false) { + Ok(_) => return true, + Err(message) => self.events.push(Event::Toast { message, error: true }), } } } changed } + /// How far to trust the frames a tracker followed (SAM 2), by layer frame: its confidence and + /// whether the object was absent. Frames the classic engine made have no entry. + pub fn roto_trust(&self, comp: ItemId, layer: LayerId, effect: Uid) -> BTreeMap { + let mut out = BTreeMap::new(); + let Some(c) = self.project.comp(comp) else { return out }; + let Some(l) = c.layer(layer) else { return out }; + let Some(g) = l.props.find_group(effect) else { return out }; + if is_object_matte(g) { + return out; + } + let ch = fx::Chain::new(¶ms_static(g), layer_size(&self.project, c, l), c.frame_rate.as_f64(), 1.0); + for f in ch.keys.keys() { + if let Some(t) = ch.trust_at(*f) { + out.insert(*f, t); + } + } + out + } + /// The computed frames of an instance (full resolution), by layer frame. pub fn roto_computed(&self, comp: ItemId, layer: LayerId, effect: Uid) -> BTreeMap { let mut out = BTreeMap::new(); diff --git a/crates/engine/src/tracking.rs b/crates/engine/src/analysis/tracking.rs similarity index 99% rename from crates/engine/src/tracking.rs rename to crates/engine/src/analysis/tracking.rs index 434cdd8af..221b28602 100644 --- a/crates/engine/src/tracking.rs +++ b/crates/engine/src/analysis/tracking.rs @@ -197,7 +197,8 @@ pub(crate) struct Work { } /// The layer's source frame at comp time `t` at 100 % (layer pixels + offset = image pixels). -/// Footage at its native size is used straight from the footage source (no copy). +/// Footage at its native size is used straight from the footage source (no copy unless its +/// Cineon Settings convert it). pub(crate) fn source_frame( r: &Renderer, project: &Project, @@ -207,7 +208,7 @@ pub(crate) fn source_frame( t: Tick, expr: Option<&dyn ExprHost>, ) -> Option<(Arc, [f64; 2])> { - let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None }; + let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None, property_overrides: &[], media_overrides: &[] }; if let effectcraft_project::LayerSource::Footage { item } = &layer.source && let Some(effectcraft_project::ItemKind::Footage(f)) = project.item(*item).map(|i| &i.kind) && f.has_video @@ -215,7 +216,7 @@ pub(crate) fn source_frame( && img.width == f.width && img.height == f.height { - return Some((img, [0.0; 2])); + return Some((effectcraft_render::cineon_frame(img, f), [0.0; 2])); } let buf = r.layer_source(&ctx, layer)?; if (buf.scale - 1.0).abs() > 1e-9 && buf.scale > 0.0 { diff --git a/crates/engine/src/warp.rs b/crates/engine/src/analysis/warp.rs similarity index 96% rename from crates/engine/src/warp.rs rename to crates/engine/src/analysis/warp.rs index 7928a5cab..71ee45ee6 100644 --- a/crates/engine/src/warp.rs +++ b/crates/engine/src/analysis/warp.rs @@ -4,10 +4,10 @@ //! the Warp Stabilizer, `Renderer::layer_input`) for every frame between the layer's In and Out //! points on a background thread, and feeds the frames to `effectcraft_track::stabilize`'s //! analyzer (step 1 of 2, "Analyzing in background"); step 2 ("Stabilizing") derives the -//! stabilization plan once to validate the result. When the job finishes, the analysis is +//! stabilization plan once to validate and cache the result. When the job finishes, the analysis is //! written into the effect's hidden Analysis parameter as one undo step, together with a key of //! what it was made from (the layer's source, In/Out, start, stretch and Time Remap). Edits that -//! change those clear the analysis ([`invalidate`], run after every edit) and queue the effect +//! change those clear the analysis (`invalidate`, run after every edit) and queue the effect //! for re-analysis, which frontends start with [`Session::poll_warp`] (After Effects re-analyses //! automatically too). @@ -21,7 +21,7 @@ use effectcraft_project::{GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSour use effectcraft_raster::Image; use effectcraft_render::{EvalCtx, ExprHost, FootageSource, LayerCache, Renderer}; use effectcraft_time::Tick; -use effectcraft_track::stabilize::{AnalyzeOpts, Analyzer, StabSettings, WarpAnalysis}; +use effectcraft_track::stabilize::{AnalyzeOpts, Analyzer, WarpAnalysis}; use serde::Serialize; use crate::tracking::lock; @@ -245,9 +245,8 @@ async fn run_work(w: WarpWork, shared: &WarpShared) { let lt0 = layer.layer_time(w.times[0]).seconds(); let fd = if w.times.len() > 1 { layer.layer_time(w.times[1]).seconds() - lt0 } else { comp.frame_duration().seconds() }; let a = an.finish(lt0, fd); - // Step 2: derive a plan once (validates the motion and warms nothing else up). - let plan = effectcraft_track::stabilize::plan(&a, &StabSettings { fps: if fd > 0.0 { 1.0 / fd } else { 30.0 }, ..Default::default() }); - if plan.warps.len() != a.frames.len() || plan.warps.iter().any(|h| h.0.iter().flatten().any(|v| !v.is_finite())) { + // Plan from the stored JSON, exactly as rendering does, and retain it for the first render. + if !warp_stab::prepare_default_plan(&a.to_json()) { return fail("the stabilization could not be computed"); } *lock(&shared.result) = Some(a); diff --git a/crates/engine/src/background.rs b/crates/engine/src/background.rs new file mode 100644 index 000000000..d4d404d95 --- /dev/null +++ b/crates/engine/src/background.rs @@ -0,0 +1,3 @@ +pub mod jobs; +pub mod offload; +pub mod remote; diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/background/jobs.rs similarity index 100% rename from crates/engine/src/jobs.rs rename to crates/engine/src/background/jobs.rs diff --git a/crates/engine/src/offload.rs b/crates/engine/src/background/offload.rs similarity index 72% rename from crates/engine/src/offload.rs rename to crates/engine/src/background/offload.rs index 135822d57..23cff4da6 100644 --- a/crates/engine/src/offload.rs +++ b/crates/engine/src/background/offload.rs @@ -992,317 +992,4 @@ pub(crate) fn merge_group(base: &PropGroup, live: &PropGroup, result: &PropGroup } #[cfg(test)] -mod tests { - use super::*; - - fn round Deserialize<'de>>(v: &T) -> String { - let a = serde_json::to_string(v).unwrap(); - let back: T = serde_json::from_str(&a).unwrap(); - let b = serde_json::to_string(&back).unwrap(); - assert_eq!(a, b); - a - } - - /// A group with properties `a` (uid 2) and `b` (uid 3) and a sub-group (uid 4) holding `c` - /// (uid 5). - fn group() -> PropGroup { - use effectcraft_keyframe::Value as KV; - use effectcraft_project::{Node, Property}; - let mut sub = PropGroup::new(4, "sub", "Sub"); - sub.children.push(Node::Prop(Property::new(5, "c", "C", KV::Scalar(0.0)))); - let mut g = PropGroup::new(1, "fx", "Effect"); - g.children.push(Node::Prop(Property::new(2, "a", "A", KV::Scalar(0.0)))); - g.children.push(Node::Prop(Property::new(3, "b", "B", KV::Scalar(0.0)))); - g.children.push(Node::Group(sub)); - g - } - - fn set(g: &mut PropGroup, uid: Uid, v: f64) { - g.find_mut(uid).unwrap().value = effectcraft_keyframe::Value::Scalar(v); - } - - fn get(g: &PropGroup, uid: Uid) -> f64 { - match g.find(uid).unwrap().value { - effectcraft_keyframe::Value::Scalar(v) => v, - ref v => panic!("{v:?}"), - } - } - - #[test] - fn analysis_results_merge_with_edits_made_meanwhile() { - use effectcraft_project::Node; - let base = group(); - // The user changes `a` and switches the effect off; the analysis writes `b` and `c`. - let mut live = base.clone(); - set(&mut live, 2, 1.0); - live.enabled = false; - let mut result = base.clone(); - set(&mut result, 3, 2.0); - set(&mut result, 5, 3.0); - let m = merge_group(&base, &live, &result).unwrap(); - assert_eq!((get(&m, 2), get(&m, 3), get(&m, 5), m.enabled), (1.0, 2.0, 3.0, false)); - // Unchanged on either side: the other side wins whole. - assert_eq!(merge_group(&base, &base, &result).unwrap(), result); - assert_eq!(merge_group(&base, &live, &base).unwrap(), live); - // Both changed `b`, differently: a conflict (nothing is applied). The same way: fine. - let mut both = live.clone(); - set(&mut both, 3, 9.0); - assert!(merge_group(&base, &both, &result).is_none()); - set(&mut both, 3, 2.0); - assert_eq!(get(&merge_group(&base, &both, &result).unwrap(), 3), 2.0); - // The analysis adds a child (a result group) while the user edits `a`: both kept. - let mut added = result.clone(); - added.children.push(Node::Group(PropGroup::new(6, "out", "Output"))); - let m = merge_group(&base, &live, &added).unwrap(); - assert!(m.find_group(6).is_some() && get(&m, 2) == 1.0); - // The analysis drops the sub-group the user didn't touch: dropped. - let mut dropped = result.clone(); - dropped.children.retain(|n| !matches!(n, Node::Group(g) if g.uid == 4)); - let m = merge_group(&base, &live, &dropped).unwrap(); - assert!(m.find_group(4).is_none() && get(&m, 2) == 1.0); - // …but if the user changed something in it, that's a conflict. - let mut touched = live.clone(); - set(&mut touched, 5, 7.0); - assert!(merge_group(&base, &touched, &dropped).is_none()); - } - - #[test] - fn protocol_round_trips() { - let mut s = Session::default(); - s.execute("file.openDemoProject", serde_json::json!({})).unwrap(); - let comp = s.active_comp_id().unwrap(); - s.execute("renderQueue.add", serde_json::json!({})).unwrap(); - let item = s.project.render_queue[0].clone(); - let reqs = [ - WorkerJob::Render { items: vec![vec![(item.clone(), "/out.gif".into())]] }, - WorkerJob::Warp { comp, layer: LayerId(3), effect: 9 }, - WorkerJob::WarpPlan { comp, layer: LayerId(3), effect: 9, time: Tick::from_seconds_f64(0.5) }, - WorkerJob::RotoFreeze { comp, layer: LayerId(3), effect: 9 }, - WorkerJob::Camera { comp, layer: LayerId(3), effect: 9 }, - WorkerJob::RotoPropagate { comp, layer: LayerId(3), effect: 9, direction: crate::roto::Direction::Forward }, - WorkerJob::Track { - comp, - layer: LayerId(1), - tracker: 4, - direction: crate::tracking::Direction::Backward, - settings: TrackerSettings::default(), - points: vec![PointValues { - uid: 5, - spec: effectcraft_track::PointSpec { center: [1.0, 2.0], feature_size: [3.0, 4.0], search_offset: [0.5, 0.0], search_size: [9.0, 9.0] }, - attach_offset: [0.25, -1.0], - }], - times: vec![Tick::ZERO, Tick::from_seconds_f64(0.5)], - }, - WorkerJob::MaskTrack { - comp, - layer: LayerId(2), - mask: 7, - direction: crate::tracking::Direction::Forward, - path: ShapePath::ellipse([10.0, 20.0], 5.0, 6.0), - method: MaskMethod::Perspective, - times: vec![Tick::from_seconds_f64(1.0)], - }, - WorkerJob::ContentFill { - plan: Box::new(crate::commands::content_fill::FillPlan { - comp, - layer: LayerId(2), - name: "Clip".into(), - width: 4, - height: 3, - times: vec![Tick::ZERO], - method: "object".into(), - lighting: 0.5, - expand: 1, - reference: Some((LayerId(1), Tick::ZERO)), - dir: "/Fill/Clip_Fill_1".into(), - }), - }, - ]; - for job in reqs { - let kind = job.kind(); - let req = WorkerRequest { id: 42, project: s.project.to_json(), files: footage_files(&s.project), job }; - let t = round(&req); - assert!(t.contains("\"kind\":"), "{kind:?}"); - let back: WorkerRequest = serde_json::from_str(&t).unwrap(); - assert_eq!(back.job.kind(), kind); - assert_eq!(Project::from_json(&back.project).unwrap(), *s.project); - } - let g = s.active_comp().unwrap().layers[0].props.clone(); - for r in [ - WorkerReply::Render { state: JobState { current: Some(3), done: 4, total: 10, items_total: 1, ..Default::default() } }, - WorkerReply::Item { update: ItemUpdate::Started { id: 3, unix: 1_700_000_000 } }, - WorkerReply::Item { update: ItemUpdate::Finished { id: 3, status: RenderStatus::Failed("x".into()), seconds: 1.5, output: Some("/a.gif".into()) } }, - WorkerReply::Progress { done: 1, total: 2 }, - WorkerReply::WarpPlan { - key: [1, u64::MAX], - summary: Box::new(effectcraft_effects::warp_stab::PlanSummary { - warps: vec![[[1.0, 0.0, 2.5], [0.0, 1.0, -1.0], [0.0, 0.0, 1.0]]], - auto_scale: 1.08, - crop: Some([1.0, 2.0, 300.0, 200.0]), - valid_fraction: 0.9, - }), - }, - WorkerReply::Group { comp, layer: LayerId(1), group: Box::new(g), message: "ok".into() }, - WorkerReply::Segs { segs: vec![SegData::new(5, &effectcraft_track::roto::FrameSeg::empty(4, 3))] }, - WorkerReply::Fill { - plan: Box::new(crate::commands::content_fill::FillPlan { - comp, - layer: LayerId(2), - name: "Clip".into(), - width: 4, - height: 3, - times: vec![Tick::ZERO], - method: "object".into(), - lighting: 0.5, - expand: 1, - reference: Some((LayerId(1), Tick::ZERO)), - dir: "/Fill/Clip_Fill_1".into(), - }), - files: vec!["/Fill/Clip_Fill_1/fill_00000.png".into()], - }, - WorkerReply::Failed { error: "boom".into() }, - WorkerReply::Done, - ] { - let t = round(&r); - assert!(t.starts_with("{\"type\":"), "{t}"); - } - } - - /// An [`Offload`] that runs the job right away in a second session, through JSON (what a - /// Web Worker does, minus the threads). - struct Inline; - impl Offload for Inline { - fn start(&self, req: WorkerRequest, inbox: Arc) -> Result<(), String> { - let req: WorkerRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap(); - let mut w = Session { exporter: Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })), ..Default::default() }; - let post: Post = Rc::new(move |r| inbox.push(serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap())); - run_request(&mut w, req, &post); - Ok(()) - } - fn cancel(&self, _: u64) {} - } - - #[test] - fn render_runs_through_the_offload() { - let mut s = Session { - exporter: Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })), - offload: Some(Arc::new(Inline)), - ..Default::default() - }; - s.execute("file.openDemoProject", serde_json::json!({})).unwrap(); - s.execute("renderQueue.add", serde_json::json!({"output": "/x.gif"})).unwrap(); - let ids = s.start_render(false).unwrap(); - // (the inline offload has replied already; replies are applied when polled) - s.poll_render(); - assert!(s.render_job.is_none()); - let it = s.project.render_queue.iter().find(|i| i.id == ids[0]).unwrap(); - assert_eq!(it.status, RenderStatus::Done, "{:?}", it.status); - assert!(it.last_output.as_deref().is_some_and(|o| o.ends_with("x.gif"))); - // `wait` renders inline. - s.execute("renderQueue.add", serde_json::json!({"output": "/y.gif"})).unwrap(); - s.start_render(true).unwrap(); - assert!(s.render_job.is_none() || s.render_job.as_ref().unwrap().remote.is_none()); - } - - /// Runs jobs in a session sharing `footage` (the synthetic clip of the tracking tests). - struct InlineWith(Arc); - impl Offload for InlineWith { - fn start(&self, req: WorkerRequest, inbox: Arc) -> Result<(), String> { - let req: WorkerRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap(); - let mut w = Session { footage: self.0.clone(), ..Default::default() }; - let post: Post = Rc::new(move |r| inbox.push(serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap())); - run_request(&mut w, req, &post); - Ok(()) - } - fn cancel(&self, _: u64) {} - } - - /// Never replies (a job still running); records cancellations. - #[derive(Default)] - struct Hanging(Mutex>); - impl Offload for Hanging { - fn start(&self, _: WorkerRequest, _: Arc) -> Result<(), String> { - Ok(()) - } - fn cancel(&self, id: u64) { - self.0.lock().unwrap().push(id); - } - } - - fn pose(f: u32) -> ([f64; 2], f64, f64) { - ([150.0 + 2.0 * f as f64, 110.0 + 1.0 * f as f64], 0.0, 1.0) - } - - #[test] - fn track_analysis_runs_through_the_offload_as_one_undo_step() { - let (mut s, clip, _) = crate::tests_track::setup(crate::tests_track::patch_frames(pose)); - s.offload = Some(Arc::new(InlineWith(s.footage.clone()))); - s.execute("track.motion", serde_json::json!({})).unwrap(); - s.execute("track.setPoint", serde_json::json!({"point": 1, "center": pose(0).0, "featureSize": [36, 36], "searchSize": [80, 80]})).unwrap(); - let undo = s.history.undo.len(); - s.execute("track.analyze", serde_json::json!({"direction": "forward"})).unwrap(); - assert!(s.is_tracking() && s.track_job.is_none(), "offloaded, not on a thread"); - assert!(s.track_progress().is_some()); - assert!(s.poll_offload()); - assert!(!s.is_tracking() && s.offloaded.is_empty()); - let l = s.active_comp().unwrap().layer(clip).unwrap(); - let tp = l.trackers().next().unwrap().0.track_points().next().unwrap().clone(); - let fc = tp.get("featureCenter").unwrap(); - assert_eq!(fc.keys.len(), 25); - let v = fc.value_at(crate::tests_track::frame_time(12)).as_vec2(); - assert!((v[0] - pose(12).0[0]).abs() < 0.3 && (v[1] - pose(12).0[1]).abs() < 0.3, "{v:?}"); - assert_eq!(s.history.undo.len(), undo + 1); - assert_eq!(s.history.undo.last().unwrap().0, "Analyze Track"); - assert!(s.events.iter().any(|e| matches!(e, Event::Toast { message, error: false } if message.starts_with("Tracked")))); - assert!(s.undo()); - let l = s.active_comp().unwrap().layer(clip).unwrap(); - assert!(l.trackers().next().unwrap().0.track_points().next().unwrap().get("featureCenter").unwrap().keys.is_empty()); - // `wait` runs inline. - s.redo(); - s.execute("track.analyze", serde_json::json!({"direction": "forward", "wait": true})).ok(); - assert!(s.offloaded.is_empty()); - } - - #[test] - fn cancelling_offloaded_jobs() { - let (mut s, _, _) = crate::tests_track::setup(crate::tests_track::patch_frames(pose)); - let off = Arc::new(Hanging::default()); - s.offload = Some(off.clone()); - s.exporter = Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })); - // Analysis: stopping drops the job, nothing is written. - s.execute("track.motion", serde_json::json!({})).unwrap(); - let rev = s.revision; - s.execute("track.analyze", serde_json::json!({})).unwrap(); - assert!(s.is_tracking()); - assert!(s.execute("track.analyze", serde_json::json!({})).is_err(), "one at a time"); - assert!(s.stop_track()); - s.poll_offload(); - assert!(!s.is_tracking()); - assert_eq!(off.0.lock().unwrap().len(), 1); - assert!(s.events.iter().any(|e| matches!(e, Event::Toast { message, .. } if message.contains("stopped")))); - assert!(s.revision > rev); - // Render: the queued items become User Stopped. - s.execute("renderQueue.add", serde_json::json!({"output": "/a.gif"})).unwrap(); - let ids = s.start_render(false).unwrap(); - assert!(s.is_rendering()); - assert!(s.stop_render()); - s.poll_render(); - assert!(!s.is_rendering()); - assert_eq!(off.0.lock().unwrap().len(), 2); - let it = s.project.render_queue.iter().find(|i| i.id == ids[0]).unwrap(); - assert_eq!(it.status, RenderStatus::UserStopped); - } - - #[test] - fn progress_hook_is_scoped() { - report(1, 2); // no hook: nothing happens - let seen = Rc::new(RefCell::new(vec![])); - let s2 = seen.clone(); - effectcraft_render::passes::block_on(with_hook(Box::new(move |d, t| s2.borrow_mut().push((d, t))), async { - report(1, 3); - report(2, 3); - })); - report(3, 3); - assert_eq!(*seen.borrow(), vec![(1, 3), (2, 3)]); - } -} +mod tests; diff --git a/crates/engine/src/background/offload/tests.rs b/crates/engine/src/background/offload/tests.rs new file mode 100644 index 000000000..c08623189 --- /dev/null +++ b/crates/engine/src/background/offload/tests.rs @@ -0,0 +1,309 @@ +use super::*; + +fn round Deserialize<'de>>(v: &T) -> String { + let a = serde_json::to_string(v).unwrap(); + let back: T = serde_json::from_str(&a).unwrap(); + let b = serde_json::to_string(&back).unwrap(); + assert_eq!(a, b); + a +} + +/// A group with properties `a` (uid 2) and `b` (uid 3) and a sub-group (uid 4) holding `c` +/// (uid 5). +fn group() -> PropGroup { + use effectcraft_keyframe::Value as KV; + use effectcraft_project::{Node, Property}; + let mut sub = PropGroup::new(4, "sub", "Sub"); + sub.children.push(Node::Prop(Property::new(5, "c", "C", KV::Scalar(0.0)))); + let mut g = PropGroup::new(1, "fx", "Effect"); + g.children.push(Node::Prop(Property::new(2, "a", "A", KV::Scalar(0.0)))); + g.children.push(Node::Prop(Property::new(3, "b", "B", KV::Scalar(0.0)))); + g.children.push(Node::Group(sub)); + g +} + +fn set(g: &mut PropGroup, uid: Uid, v: f64) { + g.find_mut(uid).unwrap().value = effectcraft_keyframe::Value::Scalar(v); +} + +fn get(g: &PropGroup, uid: Uid) -> f64 { + match g.find(uid).unwrap().value { + effectcraft_keyframe::Value::Scalar(v) => v, + ref v => panic!("{v:?}"), + } +} + +#[test] +fn analysis_results_merge_with_edits_made_meanwhile() { + use effectcraft_project::Node; + let base = group(); + // The user changes `a` and switches the effect off; the analysis writes `b` and `c`. + let mut live = base.clone(); + set(&mut live, 2, 1.0); + live.enabled = false; + let mut result = base.clone(); + set(&mut result, 3, 2.0); + set(&mut result, 5, 3.0); + let m = merge_group(&base, &live, &result).unwrap(); + assert_eq!((get(&m, 2), get(&m, 3), get(&m, 5), m.enabled), (1.0, 2.0, 3.0, false)); + // Unchanged on either side: the other side wins whole. + assert_eq!(merge_group(&base, &base, &result).unwrap(), result); + assert_eq!(merge_group(&base, &live, &base).unwrap(), live); + // Both changed `b`, differently: a conflict (nothing is applied). The same way: fine. + let mut both = live.clone(); + set(&mut both, 3, 9.0); + assert!(merge_group(&base, &both, &result).is_none()); + set(&mut both, 3, 2.0); + assert_eq!(get(&merge_group(&base, &both, &result).unwrap(), 3), 2.0); + // The analysis adds a child (a result group) while the user edits `a`: both kept. + let mut added = result.clone(); + added.children.push(Node::Group(PropGroup::new(6, "out", "Output"))); + let m = merge_group(&base, &live, &added).unwrap(); + assert!(m.find_group(6).is_some() && get(&m, 2) == 1.0); + // The analysis drops the sub-group the user didn't touch: dropped. + let mut dropped = result.clone(); + dropped.children.retain(|n| !matches!(n, Node::Group(g) if g.uid == 4)); + let m = merge_group(&base, &live, &dropped).unwrap(); + assert!(m.find_group(4).is_none() && get(&m, 2) == 1.0); + // …but if the user changed something in it, that's a conflict. + let mut touched = live.clone(); + set(&mut touched, 5, 7.0); + assert!(merge_group(&base, &touched, &dropped).is_none()); +} + +#[test] +fn protocol_round_trips() { + let mut s = Session::default(); + s.execute("file.openDemoProject", serde_json::json!({})).unwrap(); + let comp = s.active_comp_id().unwrap(); + s.execute("renderQueue.add", serde_json::json!({})).unwrap(); + let item = s.project.render_queue[0].clone(); + let reqs = [ + WorkerJob::Render { items: vec![vec![(item.clone(), "/out.gif".into())]] }, + WorkerJob::Warp { comp, layer: LayerId(3), effect: 9 }, + WorkerJob::WarpPlan { comp, layer: LayerId(3), effect: 9, time: Tick::from_seconds_f64(0.5) }, + WorkerJob::RotoFreeze { comp, layer: LayerId(3), effect: 9 }, + WorkerJob::Camera { comp, layer: LayerId(3), effect: 9 }, + WorkerJob::RotoPropagate { comp, layer: LayerId(3), effect: 9, direction: crate::roto::Direction::Forward }, + WorkerJob::Track { + comp, + layer: LayerId(1), + tracker: 4, + direction: crate::tracking::Direction::Backward, + settings: TrackerSettings::default(), + points: vec![PointValues { + uid: 5, + spec: effectcraft_track::PointSpec { center: [1.0, 2.0], feature_size: [3.0, 4.0], search_offset: [0.5, 0.0], search_size: [9.0, 9.0] }, + attach_offset: [0.25, -1.0], + }], + times: vec![Tick::ZERO, Tick::from_seconds_f64(0.5)], + }, + WorkerJob::MaskTrack { + comp, + layer: LayerId(2), + mask: 7, + direction: crate::tracking::Direction::Forward, + path: ShapePath::ellipse([10.0, 20.0], 5.0, 6.0), + method: MaskMethod::Perspective, + times: vec![Tick::from_seconds_f64(1.0)], + }, + WorkerJob::ContentFill { + plan: Box::new(crate::commands::content_fill::FillPlan { + comp, + layer: LayerId(2), + name: "Clip".into(), + width: 4, + height: 3, + times: vec![Tick::ZERO], + method: "object".into(), + lighting: 0.5, + expand: 1, + reference: Some((LayerId(1), Tick::ZERO)), + dir: "/Fill/Clip_Fill_1".into(), + }), + }, + ]; + for job in reqs { + let kind = job.kind(); + let req = WorkerRequest { id: 42, project: s.project.to_json(), files: footage_files(&s.project), job }; + let t = round(&req); + assert!(t.contains("\"kind\":"), "{kind:?}"); + let back: WorkerRequest = serde_json::from_str(&t).unwrap(); + assert_eq!(back.job.kind(), kind); + assert_eq!(Project::from_json(&back.project).unwrap(), *s.project); + } + let g = s.active_comp().unwrap().layers[0].props.clone(); + for r in [ + WorkerReply::Render { state: JobState { current: Some(3), done: 4, total: 10, items_total: 1, ..Default::default() } }, + WorkerReply::Item { update: ItemUpdate::Started { id: 3, unix: 1_700_000_000 } }, + WorkerReply::Item { update: ItemUpdate::Finished { id: 3, status: RenderStatus::Failed("x".into()), seconds: 1.5, output: Some("/a.gif".into()) } }, + WorkerReply::Progress { done: 1, total: 2 }, + WorkerReply::WarpPlan { + key: [1, u64::MAX], + summary: Box::new(effectcraft_effects::warp_stab::PlanSummary { + warps: vec![[[1.0, 0.0, 2.5], [0.0, 1.0, -1.0], [0.0, 0.0, 1.0]]], + auto_scale: 1.08, + crop: Some([1.0, 2.0, 300.0, 200.0]), + valid_fraction: 0.9, + }), + }, + WorkerReply::Group { comp, layer: LayerId(1), group: Box::new(g), message: "ok".into() }, + WorkerReply::Segs { segs: vec![SegData::new(5, &effectcraft_track::roto::FrameSeg::empty(4, 3))] }, + WorkerReply::Fill { + plan: Box::new(crate::commands::content_fill::FillPlan { + comp, + layer: LayerId(2), + name: "Clip".into(), + width: 4, + height: 3, + times: vec![Tick::ZERO], + method: "object".into(), + lighting: 0.5, + expand: 1, + reference: Some((LayerId(1), Tick::ZERO)), + dir: "/Fill/Clip_Fill_1".into(), + }), + files: vec!["/Fill/Clip_Fill_1/fill_00000.png".into()], + }, + WorkerReply::Failed { error: "boom".into() }, + WorkerReply::Done, + ] { + let t = round(&r); + assert!(t.starts_with("{\"type\":"), "{t}"); + } +} + +/// An [`Offload`] that runs the job right away in a second session, through JSON (what a +/// Web Worker does, minus the threads). +struct Inline; +impl Offload for Inline { + fn start(&self, req: WorkerRequest, inbox: Arc) -> Result<(), String> { + let req: WorkerRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap(); + let mut w = Session { exporter: Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })), ..Default::default() }; + let post: Post = Rc::new(move |r| inbox.push(serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap())); + run_request(&mut w, req, &post); + Ok(()) + } + fn cancel(&self, _: u64) {} +} + +#[test] +fn render_runs_through_the_offload() { + let mut s = + Session { exporter: Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })), offload: Some(Arc::new(Inline)), ..Default::default() }; + s.execute("file.openDemoProject", serde_json::json!({})).unwrap(); + s.execute("renderQueue.add", serde_json::json!({"output": "/x.gif"})).unwrap(); + let ids = s.start_render(false).unwrap(); + // (the inline offload has replied already; replies are applied when polled) + s.poll_render(); + assert!(s.render_job.is_none()); + let it = s.project.render_queue.iter().find(|i| i.id == ids[0]).unwrap(); + assert_eq!(it.status, RenderStatus::Done, "{:?}", it.status); + assert!(it.last_output.as_deref().is_some_and(|o| o.ends_with("x.gif"))); + // `wait` renders inline. + s.execute("renderQueue.add", serde_json::json!({"output": "/y.gif"})).unwrap(); + s.start_render(true).unwrap(); + assert!(s.render_job.is_none() || s.render_job.as_ref().unwrap().remote.is_none()); +} + +/// Runs jobs in a session sharing `footage` (the synthetic clip of the tracking tests). +struct InlineWith(Arc); +impl Offload for InlineWith { + fn start(&self, req: WorkerRequest, inbox: Arc) -> Result<(), String> { + let req: WorkerRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap(); + let mut w = Session { footage: self.0.clone(), ..Default::default() }; + let post: Post = Rc::new(move |r| inbox.push(serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap())); + run_request(&mut w, req, &post); + Ok(()) + } + fn cancel(&self, _: u64) {} +} + +/// Never replies (a job still running); records cancellations. +#[derive(Default)] +struct Hanging(Mutex>); +impl Offload for Hanging { + fn start(&self, _: WorkerRequest, _: Arc) -> Result<(), String> { + Ok(()) + } + fn cancel(&self, id: u64) { + self.0.lock().unwrap().push(id); + } +} + +fn pose(f: u32) -> ([f64; 2], f64, f64) { + ([150.0 + 2.0 * f as f64, 110.0 + 1.0 * f as f64], 0.0, 1.0) +} + +#[test] +fn track_analysis_runs_through_the_offload_as_one_undo_step() { + let (mut s, clip, _) = crate::tests::track::setup(crate::tests::track::patch_frames(pose)); + s.offload = Some(Arc::new(InlineWith(s.footage.clone()))); + s.execute("track.motion", serde_json::json!({})).unwrap(); + s.execute("track.setPoint", serde_json::json!({"point": 1, "center": pose(0).0, "featureSize": [36, 36], "searchSize": [80, 80]})).unwrap(); + let undo = s.history.undo.len(); + s.execute("track.analyze", serde_json::json!({"direction": "forward"})).unwrap(); + assert!(s.is_tracking() && s.track_job.is_none(), "offloaded, not on a thread"); + assert!(s.track_progress().is_some()); + assert!(s.poll_offload()); + assert!(!s.is_tracking() && s.offloaded.is_empty()); + let l = s.active_comp().unwrap().layer(clip).unwrap(); + let tp = l.trackers().next().unwrap().0.track_points().next().unwrap().clone(); + let fc = tp.get("featureCenter").unwrap(); + assert_eq!(fc.keys.len(), 25); + let v = fc.value_at(crate::tests::track::frame_time(12)).as_vec2(); + assert!((v[0] - pose(12).0[0]).abs() < 0.3 && (v[1] - pose(12).0[1]).abs() < 0.3, "{v:?}"); + assert_eq!(s.history.undo.len(), undo + 1); + assert_eq!(s.history.undo.last().unwrap().0, "Analyze Track"); + assert!(s.events.iter().any(|e| matches!(e, Event::Toast { message, error: false } if message.starts_with("Tracked")))); + assert!(s.undo()); + let l = s.active_comp().unwrap().layer(clip).unwrap(); + assert!(l.trackers().next().unwrap().0.track_points().next().unwrap().get("featureCenter").unwrap().keys.is_empty()); + // `wait` runs inline. + s.redo(); + s.execute("track.analyze", serde_json::json!({"direction": "forward", "wait": true})).ok(); + assert!(s.offloaded.is_empty()); +} + +#[test] +fn cancelling_offloaded_jobs() { + let (mut s, _, _) = crate::tests::track::setup(crate::tests::track::patch_frames(pose)); + let off = Arc::new(Hanging::default()); + s.offload = Some(off.clone()); + s.exporter = Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })); + // Analysis: stopping drops the job, nothing is written. + s.execute("track.motion", serde_json::json!({})).unwrap(); + let rev = s.revision; + s.execute("track.analyze", serde_json::json!({})).unwrap(); + assert!(s.is_tracking()); + assert!(s.execute("track.analyze", serde_json::json!({})).is_err(), "one at a time"); + assert!(s.stop_track()); + s.poll_offload(); + assert!(!s.is_tracking()); + assert_eq!(off.0.lock().unwrap().len(), 1); + assert!(s.events.iter().any(|e| matches!(e, Event::Toast { message, .. } if message.contains("stopped")))); + assert!(s.revision > rev); + // Render: the queued items become User Stopped. + s.execute("renderQueue.add", serde_json::json!({"output": "/a.gif"})).unwrap(); + let ids = s.start_render(false).unwrap(); + assert!(s.is_rendering()); + assert!(s.stop_render()); + s.poll_render(); + assert!(!s.is_rendering()); + assert_eq!(off.0.lock().unwrap().len(), 2); + let it = s.project.render_queue.iter().find(|i| i.id == ids[0]).unwrap(); + assert_eq!(it.status, RenderStatus::UserStopped); +} + +#[test] +fn progress_hook_is_scoped() { + report(1, 2); // no hook: nothing happens + let seen = Rc::new(RefCell::new(vec![])); + let s2 = seen.clone(); + effectcraft_render::passes::block_on(with_hook(Box::new(move |d, t| s2.borrow_mut().push((d, t))), async { + report(1, 3); + report(2, 3); + })); + report(3, 3); + assert_eq!(*seen.borrow(), vec![(1, 3), (2, 3)]); +} diff --git a/crates/engine/src/remote.rs b/crates/engine/src/background/remote.rs similarity index 54% rename from crates/engine/src/remote.rs rename to crates/engine/src/background/remote.rs index 7d592abe2..6539ac767 100644 --- a/crates/engine/src/remote.rs +++ b/crates/engine/src/background/remote.rs @@ -9,7 +9,7 @@ //! added, changed or removed; arrays element-wise when their length is unchanged, else //! replaced). Lossless (unlike JSON Merge Patch, `null` values survive). //! - [`Mirror`] (page side): what the worker holds; turns the next project revision into a -//! [`FrameMsg::Sync`] (a full project the first time, a diff afterwards). +//! [`FrameMsg::Project`] the first time and [`FrameMsg::Patch`] for subsequent diffs. //! - [`FrameServer`] (worker side): applies syncs, relayed Roto Brush segmentations and render //! requests, and renders frames as premultiplied RGBA8 ([`rgba8_premultiplied`]). //! @@ -301,7 +301,7 @@ impl FrameServer { } /// Identity of virtual footage files, used when native metadata cannot describe the path. - /// Called from [`FrameServer::load`], not from the UI thread's cache lookup. + /// Called from `FrameServer::load`, not from the UI thread's cache lookup. pub fn set_virtual_id(&mut self, id: Option Option<(u64, u64)> + Send + Sync>>) { self.virtual_id = id; } @@ -472,331 +472,4 @@ impl FrameServer { } #[cfg(test)] -mod tests { - use super::*; - use crate::Session; - use serde_json::json; - - fn round Deserialize<'de>>(v: &T) -> T { - serde_json::from_str(&serde_json::to_string(v).unwrap()).unwrap() - } - - #[test] - fn diff_and_patch_round_trip() { - let a = json!({"a": 1, "b": [1, 2, {"c": null}], "d": {"e": "x", "f": [1]}, "g": null}); - let b = json!({"a": 2, "b": [1, 3, {"c": 4}], "d": {"f": [1, 2]}, "g": null, "h": {"i": null}}); - let ops = diff(&a, &b); - assert!(!ops.is_empty()); - let mut x = a.clone(); - patch(&mut x, &round(&ops)).unwrap(); - assert_eq!(x, b); - // Removal and the reverse direction. - let mut y = b.clone(); - patch(&mut y, &diff(&b, &a)).unwrap(); - assert_eq!(y, a); - assert!(diff(&a, &a).is_empty()); - // A whole-document change. - let mut z = json!(1); - patch(&mut z, &diff(&json!(1), &json!([1]))).unwrap(); - assert_eq!(z, json!([1])); - // A patch for another document fails cleanly. - assert!(patch(&mut json!(3), &diff(&a, &b)).is_err()); - } - - fn server(s: &Session) -> FrameServer { - FrameServer::new(s.footage.clone(), s.expr.clone()) - } - - /// Pixels the page itself would render. - fn local(s: &Session, comp: ItemId, t: Tick, opts: RenderOpts) -> Vec { - let mut r = Renderer::new(&s.project, s.footage.as_ref(), opts); - r.expr = s.expr.as_deref(); - rgba8_premultiplied(&r.comp_frame(comp, t)) - } - - /// Layer buffers through the disk store: a worker's slow buffers queue up as entries; the - /// page plans what another worker fetches before its frame (recent misses on disk); that - /// worker's mid-render lookups find them and the pixels match. - #[test] - fn layer_buffers_go_through_the_disk_store() { - use effectcraft_render::disk_cache::{DiskIndex, Kind, LayerPrefetch}; - let mut s = Session::default(); - s.execute("file.openDemoProject", json!({})).unwrap(); - let comp = s.active_comp_id().unwrap(); - let opts = RenderOpts { scale: 0.25, ..Default::default() }; - let t = Tick::from_seconds_f64(1.0); - let req = |id: u64, layers: bool, prefetch: Vec| { - round(&FrameMsg::Render { id, revision: s.revision, comp, time: t, opts: Box::new(opts), disk: None, layers, prefetch }) - }; - let worker = || { - let mut w = server(&s); - let store = Arc::new(PrefetchStore::new(64 << 20).with_min_ms(0)); - w.set_layer_store(Some(store.clone())); - let mut m = Mirror::default(); - assert!(w.handle(round(&m.sync(s.revision, &s.project).unwrap())).is_empty()); - (w, store) - }; - let unhex = |v: &[String]| v.iter().map(|k| u128::from_str_radix(k, 16).unwrap()).collect::>(); - // Worker A: everything misses; its buffers queue up for the disk. - let (mut a, store_a) = worker(); - let out = a.handle(req(1, true, vec![])); - let (FrameReply::Frame { layer_misses, layer_hits, .. }, Some(px_a)) = &out[0] else { panic!("{:?}", out[0].0) }; - assert!(!layer_misses.is_empty() && layer_hits.is_empty()); - let writes = store_a.take_writes(); - assert!(!writes.is_empty()); - assert!(writes.iter().all(|(k, _)| (*k >> 64) as u64 == a.salt()), "keys are salted per project"); - let mut index = DiskIndex::default(); - let mut disk = std::collections::HashMap::new(); - for (k, e) in writes { - index.insert(Kind::Layer, k, e.len() as u64); - disk.insert(k, e); - } - index.insert(Kind::Frame, 1, 10); - // The page: recent misses that are on disk, most recent first. - let mut plan = LayerPrefetch::default(); - plan.note_misses(unhex(layer_misses)); - let keys = plan.plan(&index, 64); - assert!(!keys.is_empty() && keys.len() <= index.len()); - // Worker B fetches them before its first frame: its lookups hit. - let (mut b, store_b) = worker(); - for k in &keys { - assert!(store_b.provide(*k, &disk[k])); - } - assert!(!store_b.provide(1, b"damaged")); - let out = b.handle(req(1, true, keys.iter().map(|k| format!("{k:032x}")).collect())); - let (FrameReply::Frame { layer_misses: mb, layer_hits: hb, .. }, Some(px_b)) = &out[0] else { panic!("{:?}", out[0].0) }; - assert_eq!(unhex(hb).len(), keys.len(), "every fetched buffer was used"); - assert!(mb.len() < layer_misses.len()); - assert_eq!(px_a, px_b); - assert_eq!(*px_b, local(&s, comp, t, opts)); - // Nothing new to write: what B rendered itself was rendered by A too. - assert!(store_b.take_writes().iter().all(|(k, _)| disk.contains_key(k))); - // Without `layers` the store is not consulted. - let (mut c, store_c) = worker(); - c.handle(req(1, false, vec![])); - assert!(store_c.take_misses().is_empty() && store_c.take_writes().is_empty()); - // Purge empties the store. - b.handle(round(&FrameMsg::Purge)); - assert!(!store_b.has(keys[0])); - // LRU across frames and layers in the shared index: a touched layer outlives the frame. - index.touch(Kind::Layer, keys[0]); - let order = index.lru_order(); - assert_eq!(order.last(), Some(&(Kind::Layer, keys[0]))); - assert!(order.iter().position(|e| *e == (Kind::Frame, 1)).unwrap() < order.len() - 1); - } - - #[test] - fn replica_follows_edits_through_diffs_and_renders_the_same_frames() { - let mut s = Session::default(); - s.execute("file.openDemoProject", json!({})).unwrap(); - let comp = s.active_comp_id().unwrap(); - let opts = RenderOpts { scale: 0.25, guides: true, ..Default::default() }; - let mut m = Mirror::default(); - let mut w = server(&s); - let send = |m: FrameMsg, w: &mut FrameServer| w.handle(round(&m)); - // First sync: the whole project. - let first = m.sync(s.revision, &s.project).unwrap(); - assert!(matches!(first, FrameMsg::Project { .. })); - assert!(send(first, &mut w).is_empty()); - assert!(m.sync(s.revision, &s.project).is_none(), "nothing new"); - let t = Tick::from_seconds_f64(1.0); - let render = |w: &mut FrameServer, rev: u64, id: u64| { - w.handle(round(&FrameMsg::Render { id, revision: rev, comp, time: t, opts: Box::new(opts), disk: None, layers: false, prefetch: vec![] })) - }; - let out = render(&mut w, s.revision, 1); - let (FrameReply::Frame { id, width, height, .. }, Some(px)) = &out[0] else { panic!("{:?}", out[0].0) }; - assert_eq!((*id, *width as usize * *height as usize * 4), (1, px.len())); - assert_eq!(*px, local(&s, comp, t, opts)); - // An edit travels as a small diff; the replica renders the edited frame. - s.execute("layer.newSolid", json!({"color": "#ff8800", "name": "Orange"})).unwrap(); - let msg = m.sync(s.revision, &s.project).unwrap(); - let FrameMsg::Patch { ops, .. } = &msg else { panic!("expected a patch") }; - assert!(!ops.is_empty() && serde_json::to_string(ops).unwrap().len() < s.project.to_json().len() / 2); - assert!(send(msg, &mut w).is_empty()); - assert_eq!(**w.project().unwrap(), *s.project); - let out = render(&mut w, s.revision, 2); - assert_eq!(out[0].1.as_deref(), Some(&local(&s, comp, t, opts)[..])); - // A request for another revision is refused as stale. - let out = render(&mut w, s.revision + 7, 3); - assert!(matches!(out[0].0, FrameReply::Failed { id: 3, stale: true, .. })); - // A patch from the wrong base asks for a resync; the mirror then sends the project. - let bad = FrameMsg::Patch { from: 999, revision: 1000, ops: vec![] }; - assert!(matches!(send(bad, &mut w)[0].0, FrameReply::Resync { .. })); - m.reset(); - assert!(matches!(m.sync(s.revision, &s.project), Some(FrameMsg::Project { .. }))); - } - - #[test] - fn frame_messages_round_trip() { - for m in [ - FrameMsg::Purge, - FrameMsg::Segs { segs: vec![SegData { key: 7, data: "AAAA".into() }] }, - FrameMsg::Render { - id: 3, - revision: 4, - comp: ItemId(2), - time: Tick::from_seconds_f64(0.5), - opts: Box::new(RenderOpts { scale: 0.5, draft: true, roi: Some([1.0, 2.0, 3.0, 4.0]), ..Default::default() }), - disk: Some(format!("{:032x}", 0xabcdu128)), - layers: true, - prefetch: vec![format!("{:032x}", 7u128)], - }, - ] { - let a = serde_json::to_string(&m).unwrap(); - assert_eq!(serde_json::to_string(&round(&m)).unwrap(), a); - assert!(a.starts_with("{\"type\":"), "{a}"); - } - for r in [ - FrameReply::Frame { id: 1, width: 2, height: 3, ms: 4.5, passes: 2, gpu: true, layer_misses: vec!["0a".into()], layer_hits: vec![] }, - FrameReply::Stored { key: "00ff".into(), bytes: 9, layer: false }, - FrameReply::Stored { key: "00fe".into(), bytes: 8, layer: true }, - FrameReply::Info { gpu: Some("Adapter (BrowserWebGpu)".into()), error: None }, - FrameReply::Failed { id: 1, error: "x".into(), stale: true }, - FrameReply::Resync { error: "y".into() }, - ] { - assert_eq!(round(&r), r); - } - } - - /// An accelerator with deferred readbacks: a GPU effect chain is "read back" one - /// [`Deferred::settle`] after it was first asked for (computed with the CPU effects, so the - /// finished frame equals the CPU render); until then it misses and returns its input. - #[derive(Default)] - struct Deferred { - ready: std::sync::Mutex>, - asked: std::sync::Mutex>, - gate: Arc, - chains: std::sync::atomic::AtomicUsize, - auto: effectcraft_render::AutoPick, - } - - impl Deferred { - fn settle(&self) { - let asked: Vec = self.asked.lock().unwrap().drain().collect(); - self.ready.lock().unwrap().extend(asked); - } - } - - impl Accelerator for Deferred { - fn name(&self) -> String { - "deferred".into() - } - fn comp_frame(&self, _: &Renderer, _: ItemId, _: Tick) -> Option { - None - } - fn supports_effect(&self, id: &str) -> bool { - effectcraft_effects::GPU_EFFECTS.contains(&id) - } - fn effects(&self, chain: &[effectcraft_render::FxStep], buf: &effectcraft_effects::Buf, levels: Option) -> Option { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - for s in chain { - s.spec.id.hash(&mut h); - s.ctx.time.to_bits().hash(&mut h); - } - (buf.img.width, buf.img.height).hash(&mut h); - for p in &buf.img.data { - for c in p { - c.to_bits().hash(&mut h); - } - } - let key = h.finish(); - if !self.ready.lock().unwrap().contains(&key) { - self.asked.lock().unwrap().insert(key); - self.gate.store(true, std::sync::atomic::Ordering::Relaxed); - return Some(buf.clone()); - } - self.chains.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let mut b = buf.clone(); - for s in chain { - b = effectcraft_effects::apply(s.spec, &s.ctx, b); - if let Some(l) = levels { - effectcraft_render::color::quantize(&mut b.img, l); - } - } - Some(b) - } - fn auto_pick(&self) -> Option<&effectcraft_render::AutoPick> { - Some(&self.auto) - } - fn frame_begin(&self) { - self.ready.lock().unwrap().clear(); - } - fn pass_begin(&self) { - self.gate.store(false, std::sync::atomic::Ordering::Relaxed); - } - fn pass_missed(&self) -> bool { - self.gate.load(std::sync::atomic::Ordering::Relaxed) - } - fn miss_gate(&self) -> Option> { - Some(self.gate.clone()) - } - } - - #[test] - fn deferred_readbacks_render_in_passes() { - let mut s = Session::default(); - s.execute("file.openDemoProject", json!({})).unwrap(); - let comp = s.active_comp_id().unwrap(); - s.execute("layer.newSolid", json!({"color": "#3388ff", "name": "Blue", "width": 200, "height": 120})).unwrap(); - s.execute("effect.apply", json!({"effect": "ec.blur.gaussian"})).unwrap(); - s.execute("effect.apply", json!({"effect": "ec.stylize.glow"})).unwrap(); - s.execute("layer.newSolid", json!({"color": "#ffcc00", "name": "Yellow", "width": 80, "height": 80})).unwrap(); - s.execute("effect.apply", json!({"effect": "ec.color.levels"})).unwrap(); - let opts = RenderOpts { scale: 0.25, backend: Backend::Gpu, ..Default::default() }; - let t = Tick::from_seconds_f64(1.0); - let mut w = server(&s); - let acc = Arc::new(Deferred::default()); - w.set_accel(Some(acc.clone())); - let mut m = Mirror::default(); - w.handle(round(&m.sync(s.revision, &s.project).unwrap())); - let req = |id| round(&FrameMsg::Render { id, revision: s.revision, comp, time: t, opts: Box::new(opts), disk: None, layers: false, prefetch: vec![] }); - assert!(w.handle(req(1)).is_empty() && w.waiting(), "the first pass misses"); - let mut out = vec![]; - for _ in 0..10 { - acc.settle(); - out = w.resume(); - if !w.waiting() { - break; - } - } - let (FrameReply::Frame { id: 1, passes, gpu: true, .. }, Some(px)) = &out[0] else { panic!("{:?}", out.first().map(|o| &o.0)) }; - assert!(*passes >= 2, "{passes}"); - assert!(acc.chains.load(std::sync::atomic::Ordering::Relaxed) >= 2); - // The same pixels as the CPU: no placeholder survived, in the frame or the layer cache. - assert_eq!(*px, local(&s, comp, t, RenderOpts { backend: Backend::Cpu, ..opts })); - let out = w.handle(req(2)); - let out = if out.is_empty() { - acc.settle(); - w.resume() - } else { - out - }; - assert_eq!(out[0].1.as_deref(), Some(&px[..])); - // CPU requests never touch the accelerator; Auto times whole frames. - let cpu = RenderOpts { backend: Backend::Cpu, ..opts }; - let out = - w.handle(round(&FrameMsg::Render { id: 3, revision: s.revision, comp, time: t, opts: Box::new(cpu), disk: None, layers: false, prefetch: vec![] })); - assert!(matches!(out[0].0, FrameReply::Frame { id: 3, passes: 1, gpu: false, .. })); - for id in 4..12 { - let auto = RenderOpts { backend: Backend::Auto, ..opts }; - let mut out = w.handle(round(&FrameMsg::Render { - id, - revision: s.revision, - comp, - time: t, - opts: Box::new(auto), - disk: None, - layers: false, - prefetch: vec![], - })); - while w.waiting() { - acc.settle(); - out = w.resume(); - } - assert_eq!(out[0].1.as_deref(), Some(&px[..]), "frame {id}"); - } - let st = acc.auto.stats(effectcraft_render::AutoKey::new(comp, opts.scale, false)).unwrap(); - assert!(st.cpu_frames > 0 && st.gpu_frames > 0, "{st:?}"); - } -} +mod tests; diff --git a/crates/engine/src/background/remote/tests.rs b/crates/engine/src/background/remote/tests.rs new file mode 100644 index 000000000..85f7a1807 --- /dev/null +++ b/crates/engine/src/background/remote/tests.rs @@ -0,0 +1,318 @@ +use super::*; +use crate::Session; +use serde_json::json; + +fn round Deserialize<'de>>(v: &T) -> T { + serde_json::from_str(&serde_json::to_string(v).unwrap()).unwrap() +} + +#[test] +fn diff_and_patch_round_trip() { + let a = json!({"a": 1, "b": [1, 2, {"c": null}], "d": {"e": "x", "f": [1]}, "g": null}); + let b = json!({"a": 2, "b": [1, 3, {"c": 4}], "d": {"f": [1, 2]}, "g": null, "h": {"i": null}}); + let ops = diff(&a, &b); + assert!(!ops.is_empty()); + let mut x = a.clone(); + patch(&mut x, &round(&ops)).unwrap(); + assert_eq!(x, b); + // Removal and the reverse direction. + let mut y = b.clone(); + patch(&mut y, &diff(&b, &a)).unwrap(); + assert_eq!(y, a); + assert!(diff(&a, &a).is_empty()); + // A whole-document change. + let mut z = json!(1); + patch(&mut z, &diff(&json!(1), &json!([1]))).unwrap(); + assert_eq!(z, json!([1])); + // A patch for another document fails cleanly. + assert!(patch(&mut json!(3), &diff(&a, &b)).is_err()); +} + +fn server(s: &Session) -> FrameServer { + FrameServer::new(s.footage.clone(), s.expr.clone()) +} + +/// Pixels the page itself would render. +fn local(s: &Session, comp: ItemId, t: Tick, opts: RenderOpts) -> Vec { + let mut r = Renderer::new(&s.project, s.footage.as_ref(), opts); + r.expr = s.expr.as_deref(); + rgba8_premultiplied(&r.comp_frame(comp, t)) +} + +/// Layer buffers through the disk store: a worker's slow buffers queue up as entries; the +/// page plans what another worker fetches before its frame (recent misses on disk); that +/// worker's mid-render lookups find them and the pixels match. +#[test] +fn layer_buffers_go_through_the_disk_store() { + use effectcraft_render::disk_cache::{DiskIndex, Kind, LayerPrefetch}; + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + let comp = s.active_comp_id().unwrap(); + let opts = RenderOpts { scale: 0.25, ..Default::default() }; + let t = Tick::from_seconds_f64(1.0); + let req = |id: u64, layers: bool, prefetch: Vec| { + round(&FrameMsg::Render { id, revision: s.revision, comp, time: t, opts: Box::new(opts), disk: None, layers, prefetch }) + }; + let worker = || { + let mut w = server(&s); + let store = Arc::new(PrefetchStore::new(64 << 20).with_min_ms(0)); + w.set_layer_store(Some(store.clone())); + let mut m = Mirror::default(); + assert!(w.handle(round(&m.sync(s.revision, &s.project).unwrap())).is_empty()); + (w, store) + }; + let unhex = |v: &[String]| v.iter().map(|k| u128::from_str_radix(k, 16).unwrap()).collect::>(); + // Worker A: everything misses; its buffers queue up for the disk. + let (mut a, store_a) = worker(); + let out = a.handle(req(1, true, vec![])); + let (FrameReply::Frame { layer_misses, layer_hits, .. }, Some(px_a)) = &out[0] else { panic!("{:?}", out[0].0) }; + assert!(!layer_misses.is_empty() && layer_hits.is_empty()); + let writes = store_a.take_writes(); + assert!(!writes.is_empty()); + assert!(writes.iter().all(|(k, _)| (*k >> 64) as u64 == a.salt()), "keys are salted per project"); + let mut index = DiskIndex::default(); + let mut disk = std::collections::HashMap::new(); + for (k, e) in writes { + index.insert(Kind::Layer, k, e.len() as u64); + disk.insert(k, e); + } + index.insert(Kind::Frame, 1, 10); + // The page: recent misses that are on disk, most recent first. + let mut plan = LayerPrefetch::default(); + plan.note_misses(unhex(layer_misses)); + let keys = plan.plan(&index, 64); + assert!(!keys.is_empty() && keys.len() <= index.len()); + // Worker B fetches them before its first frame: its lookups hit. + let (mut b, store_b) = worker(); + for k in &keys { + assert!(store_b.provide(*k, &disk[k])); + } + assert!(!store_b.provide(1, b"damaged")); + let out = b.handle(req(1, true, keys.iter().map(|k| format!("{k:032x}")).collect())); + let (FrameReply::Frame { layer_misses: mb, layer_hits: hb, .. }, Some(px_b)) = &out[0] else { panic!("{:?}", out[0].0) }; + assert_eq!(unhex(hb).len(), keys.len(), "every fetched buffer was used"); + assert!(mb.len() < layer_misses.len()); + assert_eq!(px_a, px_b); + assert_eq!(*px_b, local(&s, comp, t, opts)); + // Nothing new to write: what B rendered itself was rendered by A too. + assert!(store_b.take_writes().iter().all(|(k, _)| disk.contains_key(k))); + // Without `layers` the store is not consulted. + let (mut c, store_c) = worker(); + c.handle(req(1, false, vec![])); + assert!(store_c.take_misses().is_empty() && store_c.take_writes().is_empty()); + // Purge empties the store. + b.handle(round(&FrameMsg::Purge)); + assert!(!store_b.has(keys[0])); + // LRU across frames and layers in the shared index: a touched layer outlives the frame. + index.touch(Kind::Layer, keys[0]); + let order = index.lru_order(); + assert_eq!(order.last(), Some(&(Kind::Layer, keys[0]))); + assert!(order.iter().position(|e| *e == (Kind::Frame, 1)).unwrap() < order.len() - 1); +} + +#[test] +fn replica_follows_edits_through_diffs_and_renders_the_same_frames() { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + let comp = s.active_comp_id().unwrap(); + let opts = RenderOpts { scale: 0.25, guides: true, ..Default::default() }; + let mut m = Mirror::default(); + let mut w = server(&s); + let send = |m: FrameMsg, w: &mut FrameServer| w.handle(round(&m)); + // First sync: the whole project. + let first = m.sync(s.revision, &s.project).unwrap(); + assert!(matches!(first, FrameMsg::Project { .. })); + assert!(send(first, &mut w).is_empty()); + assert!(m.sync(s.revision, &s.project).is_none(), "nothing new"); + let t = Tick::from_seconds_f64(1.0); + let render = |w: &mut FrameServer, rev: u64, id: u64| { + w.handle(round(&FrameMsg::Render { id, revision: rev, comp, time: t, opts: Box::new(opts), disk: None, layers: false, prefetch: vec![] })) + }; + let out = render(&mut w, s.revision, 1); + let (FrameReply::Frame { id, width, height, .. }, Some(px)) = &out[0] else { panic!("{:?}", out[0].0) }; + assert_eq!((*id, *width as usize * *height as usize * 4), (1, px.len())); + assert_eq!(*px, local(&s, comp, t, opts)); + // An edit travels as a small diff; the replica renders the edited frame. + s.execute("layer.newSolid", json!({"color": "#ff8800", "name": "Orange"})).unwrap(); + let msg = m.sync(s.revision, &s.project).unwrap(); + let FrameMsg::Patch { ops, .. } = &msg else { panic!("expected a patch") }; + assert!(!ops.is_empty() && serde_json::to_string(ops).unwrap().len() < s.project.to_json().len() / 2); + assert!(send(msg, &mut w).is_empty()); + assert_eq!(**w.project().unwrap(), *s.project); + let out = render(&mut w, s.revision, 2); + assert_eq!(out[0].1.as_deref(), Some(&local(&s, comp, t, opts)[..])); + // A request for another revision is refused as stale. + let out = render(&mut w, s.revision + 7, 3); + assert!(matches!(out[0].0, FrameReply::Failed { id: 3, stale: true, .. })); + // A patch from the wrong base asks for a resync; the mirror then sends the project. + let bad = FrameMsg::Patch { from: 999, revision: 1000, ops: vec![] }; + assert!(matches!(send(bad, &mut w)[0].0, FrameReply::Resync { .. })); + m.reset(); + assert!(matches!(m.sync(s.revision, &s.project), Some(FrameMsg::Project { .. }))); +} + +#[test] +fn frame_messages_round_trip() { + for m in [ + FrameMsg::Purge, + FrameMsg::Segs { segs: vec![SegData { key: 7, data: "AAAA".into() }] }, + FrameMsg::Render { + id: 3, + revision: 4, + comp: ItemId(2), + time: Tick::from_seconds_f64(0.5), + opts: Box::new(RenderOpts { scale: 0.5, draft: true, roi: Some([1.0, 2.0, 3.0, 4.0]), ..Default::default() }), + disk: Some(format!("{:032x}", 0xabcdu128)), + layers: true, + prefetch: vec![format!("{:032x}", 7u128)], + }, + ] { + let a = serde_json::to_string(&m).unwrap(); + assert_eq!(serde_json::to_string(&round(&m)).unwrap(), a); + assert!(a.starts_with("{\"type\":"), "{a}"); + } + for r in [ + FrameReply::Frame { id: 1, width: 2, height: 3, ms: 4.5, passes: 2, gpu: true, layer_misses: vec!["0a".into()], layer_hits: vec![] }, + FrameReply::Stored { key: "00ff".into(), bytes: 9, layer: false }, + FrameReply::Stored { key: "00fe".into(), bytes: 8, layer: true }, + FrameReply::Info { gpu: Some("Adapter (BrowserWebGpu)".into()), error: None }, + FrameReply::Failed { id: 1, error: "x".into(), stale: true }, + FrameReply::Resync { error: "y".into() }, + ] { + assert_eq!(round(&r), r); + } +} + +/// An accelerator with deferred readbacks: a GPU effect chain is "read back" one +/// [`Deferred::settle`] after it was first asked for (computed with the CPU effects, so the +/// finished frame equals the CPU render); until then it misses and returns its input. +#[derive(Default)] +struct Deferred { + ready: std::sync::Mutex>, + asked: std::sync::Mutex>, + gate: Arc, + chains: std::sync::atomic::AtomicUsize, + auto: effectcraft_render::AutoPick, +} + +impl Deferred { + fn settle(&self) { + let asked: Vec = self.asked.lock().unwrap().drain().collect(); + self.ready.lock().unwrap().extend(asked); + } +} + +impl Accelerator for Deferred { + fn name(&self) -> String { + "deferred".into() + } + fn comp_frame(&self, _: &Renderer, _: ItemId, _: Tick) -> Option { + None + } + fn supports_effect(&self, id: &str) -> bool { + effectcraft_effects::GPU_EFFECTS.contains(&id) + } + fn effects(&self, chain: &[effectcraft_render::FxStep], buf: &effectcraft_effects::Buf, levels: Option) -> Option { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + for s in chain { + s.spec.id.hash(&mut h); + s.ctx.time.to_bits().hash(&mut h); + } + (buf.img.width, buf.img.height).hash(&mut h); + for p in &buf.img.data { + for c in p { + c.to_bits().hash(&mut h); + } + } + let key = h.finish(); + if !self.ready.lock().unwrap().contains(&key) { + self.asked.lock().unwrap().insert(key); + self.gate.store(true, std::sync::atomic::Ordering::Relaxed); + return Some(buf.clone()); + } + self.chains.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let mut b = buf.clone(); + for s in chain { + b = effectcraft_effects::apply(s.spec, &s.ctx, b); + if let Some(l) = levels { + effectcraft_render::color::quantize(&mut b.img, l); + } + } + Some(b) + } + fn auto_pick(&self) -> Option<&effectcraft_render::AutoPick> { + Some(&self.auto) + } + fn frame_begin(&self) { + self.ready.lock().unwrap().clear(); + } + fn pass_begin(&self) { + self.gate.store(false, std::sync::atomic::Ordering::Relaxed); + } + fn pass_missed(&self) -> bool { + self.gate.load(std::sync::atomic::Ordering::Relaxed) + } + fn miss_gate(&self) -> Option> { + Some(self.gate.clone()) + } +} + +#[test] +fn deferred_readbacks_render_in_passes() { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + let comp = s.active_comp_id().unwrap(); + s.execute("layer.newSolid", json!({"color": "#3388ff", "name": "Blue", "width": 200, "height": 120})).unwrap(); + s.execute("effect.apply", json!({"effect": "ec.blur.gaussian"})).unwrap(); + s.execute("effect.apply", json!({"effect": "ec.stylize.glow"})).unwrap(); + s.execute("layer.newSolid", json!({"color": "#ffcc00", "name": "Yellow", "width": 80, "height": 80})).unwrap(); + s.execute("effect.apply", json!({"effect": "ec.color.levels"})).unwrap(); + let opts = RenderOpts { scale: 0.25, backend: Backend::Gpu, ..Default::default() }; + let t = Tick::from_seconds_f64(1.0); + let mut w = server(&s); + let acc = Arc::new(Deferred::default()); + w.set_accel(Some(acc.clone())); + let mut m = Mirror::default(); + w.handle(round(&m.sync(s.revision, &s.project).unwrap())); + let req = |id| round(&FrameMsg::Render { id, revision: s.revision, comp, time: t, opts: Box::new(opts), disk: None, layers: false, prefetch: vec![] }); + assert!(w.handle(req(1)).is_empty() && w.waiting(), "the first pass misses"); + let mut out = vec![]; + for _ in 0..10 { + acc.settle(); + out = w.resume(); + if !w.waiting() { + break; + } + } + let (FrameReply::Frame { id: 1, passes, gpu: true, .. }, Some(px)) = &out[0] else { panic!("{:?}", out.first().map(|o| &o.0)) }; + assert!(*passes >= 2, "{passes}"); + assert!(acc.chains.load(std::sync::atomic::Ordering::Relaxed) >= 2); + // The same pixels as the CPU: no placeholder survived, in the frame or the layer cache. + assert_eq!(*px, local(&s, comp, t, RenderOpts { backend: Backend::Cpu, ..opts })); + let out = w.handle(req(2)); + let out = if out.is_empty() { + acc.settle(); + w.resume() + } else { + out + }; + assert_eq!(out[0].1.as_deref(), Some(&px[..])); + // CPU requests never touch the accelerator; Auto times whole frames. + let cpu = RenderOpts { backend: Backend::Cpu, ..opts }; + let out = + w.handle(round(&FrameMsg::Render { id: 3, revision: s.revision, comp, time: t, opts: Box::new(cpu), disk: None, layers: false, prefetch: vec![] })); + assert!(matches!(out[0].0, FrameReply::Frame { id: 3, passes: 1, gpu: false, .. })); + for id in 4..12 { + let auto = RenderOpts { backend: Backend::Auto, ..opts }; + let mut out = + w.handle(round(&FrameMsg::Render { id, revision: s.revision, comp, time: t, opts: Box::new(auto), disk: None, layers: false, prefetch: vec![] })); + while w.waiting() { + acc.settle(); + out = w.resume(); + } + assert_eq!(out[0].1.as_deref(), Some(&px[..]), "frame {id}"); + } + let st = acc.auto.stats(effectcraft_render::AutoKey::new(comp, opts.scale, false)).unwrap(); + assert!(st.cpu_frames > 0 && st.gpu_frames > 0, "{st:?}"); +} diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs new file mode 100644 index 000000000..1168c431b --- /dev/null +++ b/crates/engine/src/cmd.rs @@ -0,0 +1,297 @@ +//! The command registry. Ids follow After Effects' menu structure; every menu item, panel button, +//! shortcut and viewer/timeline gesture maps to one of these. + +mod align; +mod align_data; +mod anim; +mod anim_presets; +pub mod anim_tools; +mod animation; +pub(crate) mod app_more; +mod autotrace; +mod batch; +mod blender; +mod camera_cmds; +mod comp; +pub(crate) mod comp_more; +pub mod content_fill; +mod create; +mod edit; +mod effect; +pub use effect::CryptoProbeJob; +pub(crate) use effect::{CryptoProbeSlot, crypto_probe_job, crypto_probe_ready, keep_crypto_probe, kept_crypto_probe}; +pub mod essential; +pub mod expr_tools; +mod file; +pub(crate) mod file_more; +mod focus; +pub mod footage_panel; +pub(crate) mod frame_export; +mod frontend; +mod help; +mod import_project; +mod key_labels; +mod key_transform; +mod keys_more; +mod layer; +mod layer_menu; +mod layer_time; +pub(crate) mod link; +mod liquify; +mod lottie; +pub mod markers; +mod mask; +pub mod mask_interp; +pub(crate) mod model3d; +mod nav_more; +pub mod paint; +pub mod panels_cmds; +pub(crate) mod path_nulls; +mod paths; +mod project_items; +pub(crate) mod prop; +mod prop_groups; +mod proxy; +pub mod puppet; +mod query; +mod quick_offset; +mod relink; +pub(crate) mod relink_moved; +mod render_queue; +pub(crate) mod rig3d; +pub mod roto_cmds; +mod scene_detect; +pub mod scripts; +mod settings; +pub mod shape_stroke; +pub mod shape_tool; +mod stubs; +mod styles; +mod text_anim; +pub mod text_edit; +mod three_d; +pub mod time; +mod timeline; +mod track; +mod view; +pub mod viewer_cmds; +pub(crate) mod vpe; +pub(crate) mod vr_editor; +mod warp_cmds; +pub(crate) mod watch_folder; +#[cfg(test)] +pub(crate) use app_more::report_text as report_text_for_tests; +pub(crate) use app_more::view_command; +#[cfg(test)] +pub(crate) use file::open as open_for_tests; +#[cfg(test)] +pub(crate) use keys_more::amplitudes as amplitudes_for_tests; +#[cfg(test)] +pub(crate) use mask::split_segment as split_segment_for_tests; + +use std::sync::OnceLock; + +use effectcraft_project::{Comp, ItemId, Layer, LayerId}; +use effectcraft_time::Tick; +use serde_json::Value; + +use crate::{EngineError, Result, Session}; + +pub type Run = fn(&mut Session, &Value) -> Result; +pub type Enabled = fn(&Session) -> std::result::Result<(), String>; + +/// Metadata + implementation for one command. +pub struct CommandSpec { + pub id: &'static str, + pub label: &'static str, + /// Menu placement, e.g. `["Layer", "New"]`. Empty = not in menus. + pub menu: &'static [&'static str], + /// Default shortcut (egui-style names, `Cmd` = ⌘ on macOS / Ctrl elsewhere). + pub shortcut: Option<&'static str>, + /// Further default shortcuts (the menus and tooltips show `shortcut`). + pub alt_shortcuts: &'static [&'static str], + /// Parameter description for agents and the MCP schema. + pub params: &'static str, + pub enabled: Enabled, + pub run: Run, + /// Record in the journal (false for pure queries). + pub journal: bool, +} + +#[macro_export] +macro_rules! cmd { + // Several default shortcuts: `["PageDown", "Cmd+ArrowRight"]`. + ($id:literal, $label:literal, [$($m:literal),*], [$sc:literal, $($alt:literal),+], $params:literal, $en:expr, $run:expr) => { + $crate::commands::CommandSpec { alt_shortcuts: &[$($alt),+], ..$crate::cmd!($id, $label, [$($m),*], Some($sc), $params, $en, $run) } + }; + ($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { + $crate::commands::CommandSpec { + id: $id, + label: $label, + menu: &[$($m),*], + shortcut: $sc, + alt_shortcuts: &[], + params: $params, + enabled: $en, + run: $run, + journal: true, + } + }; +} +#[macro_export] +macro_rules! query { + ($id:literal, $label:literal, $params:literal, $run:expr) => { + $crate::commands::CommandSpec { + id: $id, + label: $label, + menu: &[], + shortcut: None, + alt_shortcuts: &[], + params: $params, + enabled: $crate::commands::always, + run: $run, + journal: false, + } + }; +} + +mod enabled; +mod params; +mod schema; + +use super::commands; +pub use enabled::always; +pub(crate) use enabled::{has_comp, has_keys, has_layers, has_project_selection, not_yet, not_yet_run}; +pub use params::match_path_of; +pub(crate) use params::{ + b_p, bad, comp_id, f_p, frontend, layer_mut, layer_p, layers_or, layers_p, merge_p, popup_options, resolve_effect_path, resolve_layer, selectable, + selected_leaf_props, str_p, time_p, unlocked, visible_p, +}; +pub use schema::{accepted_params, check_params, params_schema}; + +pub fn command_specs() -> &'static [CommandSpec] { + static SPECS: OnceLock> = OnceLock::new(); + SPECS.get_or_init(|| { + let mut v = Vec::new(); + v.extend(file::specs()); + v.extend(crate::models::specs()); + v.extend(edit::specs()); + v.extend(comp::specs()); + v.extend(layer::specs()); + v.extend(styles::specs()); + v.extend(three_d::specs()); + v.extend(model3d::specs()); + v.extend(rig3d::specs()); + v.extend(text_anim::specs()); + v.extend(text_edit::specs()); + v.extend(layer_time::specs()); + v.extend(prop::specs()); + v.extend(prop_groups::specs()); + v.extend(project_items::specs()); + v.extend(anim::specs()); + v.extend(anim_tools::specs()); + v.extend(link::specs()); + v.extend(markers::specs()); + v.extend(mask::specs()); + v.extend(paths::specs()); + v.extend(effect::specs()); + v.extend(time::specs()); + v.extend(nav_more::specs()); + v.extend(render_queue::specs()); + v.extend(help::specs()); + v.extend(query::specs()); + v.extend(batch::specs()); + v.extend(layer_menu::specs()); + v.extend(create::specs()); + v.extend(animation::specs()); + v.extend(anim_presets::specs()); + v.extend(view::specs()); + v.extend(viewer_cmds::specs()); + v.extend(shape_stroke::specs()); + v.extend(shape_tool::specs()); + v.extend(focus::specs()); + v.extend(key_labels::specs()); + v.extend(key_transform::specs()); + v.extend(quick_offset::specs()); + v.extend(file_more::specs()); + v.extend(relink::specs()); + v.extend(relink_moved::specs()); + v.extend(lottie::specs()); + v.extend(import_project::specs()); + v.extend(timeline::specs()); + v.extend(comp_more::specs()); + v.extend(frame_export::specs()); + v.extend(watch_folder::specs()); + v.extend(vpe::specs()); + v.extend(path_nulls::specs()); + v.extend(vr_editor::specs()); + v.extend(frontend::specs()); + v.extend(track::specs()); + v.extend(mask_interp::specs()); + v.extend(warp_cmds::specs()); + v.extend(camera_cmds::specs()); + v.extend(roto_cmds::specs()); + v.extend(paint::specs()); + v.extend(puppet::specs()); + v.extend(liquify::specs()); + v.extend(settings::specs()); + v.extend(app_more::specs()); + v.extend(keys_more::specs()); + v.extend(essential::specs()); + v.extend(expr_tools::specs()); + v.extend(proxy::specs()); + v.extend(scripts::specs()); + v.extend(panels_cmds::specs()); + v.extend(footage_panel::specs()); + v.extend(autotrace::specs()); + v.extend(scene_detect::specs()); + v.extend(align_data::specs()); + v.extend(align::specs()); + v.extend(content_fill::specs()); + v.extend(stubs::specs()); + v.extend(crate::storage::specs()); + v.extend(crate::learn::specs()); + v.extend(crate::templates::specs()); + v.extend(crate::preview::specs()); + v + }) +} + +/// Set `footage` as the proxy of `item` (File ▸ Create Proxy's post-render action). +pub(crate) fn proxy_set(s: &mut Session, item: ItemId, footage: effectcraft_project::Footage) -> Result<()> { + proxy::set_proxy_footage(s, &[item], footage) +} + +/// (id, name) of the text animation presets. +pub fn text_preset_list() -> Vec<(String, String)> { + text_anim::preset_names() +} + +/// (id, name, category) of the built-in animation presets (Transitions…). +pub fn builtin_preset_list() -> Vec<(String, String, String)> { + anim_presets::preset_names() +} + +pub fn find(id: &str) -> Option<&'static CommandSpec> { + // Every command execution looks its spec up: index the ~2,000 ids once. + static INDEX: OnceLock> = OnceLock::new(); + let index = INDEX.get_or_init(|| { + let mut m = std::collections::HashMap::new(); + for (i, c) in command_specs().iter().enumerate() { + m.entry(c.id).or_insert(i); + } + m + }); + index.get(id).map(|&i| &command_specs()[i]) +} + +/// Font families available to text layers (bundled + scanned system fonts). +/// Text animation presets: (id, name), for menus. +pub fn text_presets() -> Vec<(String, String)> { + commands::text_preset_list() +} + +/// Built-in animation presets (Effects & Presets ▸ * Animation Presets): (id, name, category). +pub fn animation_presets() -> Vec<(String, String, String)> { + commands::builtin_preset_list() +} diff --git a/crates/engine/src/commands/align.rs b/crates/engine/src/cmd/align.rs similarity index 100% rename from crates/engine/src/commands/align.rs rename to crates/engine/src/cmd/align.rs diff --git a/crates/engine/src/commands/align_data.rs b/crates/engine/src/cmd/align_data.rs similarity index 96% rename from crates/engine/src/commands/align_data.rs rename to crates/engine/src/cmd/align_data.rs index 38d9e7841..99ed3d113 100644 --- a/crates/engine/src/commands/align_data.rs +++ b/crates/engine/src/cmd/align_data.rs @@ -72,9 +72,9 @@ pub fn parse_stamp(v: &Value, fps: f64) -> Option { } // ISO 8601: date [T| ] time [zone]. if s.len() >= 10 && s.as_bytes()[4] == b'-' && s.as_bytes()[7] == b'-' { - let (y, m, d) = (s[0..4].parse::().ok()?, s[5..7].parse::().ok()?, s[8..10].parse::().ok()?); + let (y, m, d) = (s.get(0..4)?.parse::().ok()?, s.get(5..7)?.parse::().ok()?, s.get(8..10)?.parse::().ok()?); let mut secs = days_from_civil(y, m, d)? as f64 * 86_400.0; - let rest = s[10..].trim_start_matches(['T', 't', ' ']); + let rest = s.get(10..)?.trim_start_matches(['T', 't', ' ']); if !rest.is_empty() { // Zone: Z or ±hh:mm / ±hhmm. let (time, zone) = match rest.find(['Z', 'z', '+']).or_else(|| rest.rfind('-')) { @@ -229,6 +229,12 @@ pub fn specs() -> Vec { mod tests { use super::*; + #[test] + fn multibyte_date_is_rejected() { + assert_eq!(parse_stamp(&json!("2026-05-0é"), 25.0), None); + assert_eq!(parse_stamp(&json!("2026-05-01é"), 25.0), None); + } + #[test] fn parses_timestamps() { let p = |v: Value| parse_stamp(&v, 25.0).unwrap(); diff --git a/crates/engine/src/commands/anim.rs b/crates/engine/src/cmd/anim.rs similarity index 93% rename from crates/engine/src/commands/anim.rs rename to crates/engine/src/cmd/anim.rs index 325845115..e23fc736f 100644 --- a/crates/engine/src/commands/anim.rs +++ b/crates/engine/src/cmd/anim.rs @@ -45,6 +45,7 @@ fn copy(s: &mut Session, _: &Value) -> Result { clip.push(KeyClip { layer: lid, path, keys }); } let n: usize = clip.iter().map(|c| c.keys.len()).sum(); + s.state.keyframe_text = crate::keyframe_text::text(&s.project, comp, &s.state.selected_keys, &[]); s.state.key_clipboard = clip; s.state.clip_is_keys = true; Ok(json!(n)) @@ -55,6 +56,10 @@ fn compatible(a: &Property, b: &effectcraft_keyframe::Value) -> bool { a.value.kind_name() == b.kind_name() && a.value.components().len() == b.components().len() } +pub(crate) fn locked_selection(comp: &effectcraft_project::Comp, selected: &[KeyRef]) -> Vec { + selected.iter().filter(|k| comp.layer(k.layer).is_some_and(|l| l.switches.locked)).copied().collect() +} + /// Paste copied keyframes at the current time into the target layers' properties. fn paste(s: &mut Session, p: &Value) -> Result { let clip = s.state.key_clipboard.clone(); @@ -89,6 +94,9 @@ fn paste(s: &mut Session, p: &Value) -> Result { let mut plan: Vec<(LayerId, Uid, usize)> = vec![]; for (ti, lid) in targets.iter().enumerate() { let Some(l) = comp.layer(*lid) else { continue }; + if l.switches.locked { + continue; + } for (ci, c) in clip.iter().enumerate() { if paired && sources.get(ti) != Some(&c.layer) { continue; @@ -110,11 +118,12 @@ fn paste(s: &mut Session, p: &Value) -> Result { } } if plan.is_empty() { - return Err(bad("keys.paste", "no compatible property to paste into (select a property of the same type)")); + return Err(bad("keys.paste", "no compatible unlocked property to paste into (unlock the target or select a property of the same type)")); } s.edit("Paste Keyframes", merge_p(p), |proj, st| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - let mut sel = vec![]; + let mut sel = locked_selection(comp, &st.selected_keys); + let retained = sel.len(); for (lid, uid, ci) in &plan { let Some(l) = comp.layer_mut(*lid) else { continue }; let times: Vec = clip[*ci].keys.iter().map(|k| l.layer_time(t + k.time)).collect(); @@ -136,8 +145,9 @@ fn paste(s: &mut Session, p: &Value) -> Result { } retime_roving(&mut pr.keys, pr.spatial); } + let pasted = sel.len().saturating_sub(retained); st.selected_keys = sel; - Ok(json!(st.selected_keys.len())) + Ok(json!(pasted)) }) } @@ -175,6 +185,9 @@ pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, L let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let t = time_p(s, p, Some(comp)); let target = comp.layer(lid).ok_or(EngineError::NoComp)?; + if target.switches.locked { + return Err(bad("keys.paste", "the target layer is locked")); + } let mut order: Vec = (0..path.vertices.len()).collect(); if path.closed && !order.is_empty() { order.push(0); @@ -202,6 +215,9 @@ pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, L }) .collect(); s.edit("Paste Keyframes", merge_p(p), |proj, st| { + let comp = proj.comp(cid).ok_or(EngineError::NoComp)?; + let mut selected = locked_selection(comp, &st.selected_keys); + let retained = selected.len(); let l = super::layer_mut(proj, cid, lid)?; let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.paste", "no property"))?; let mut sel = vec![]; @@ -216,8 +232,10 @@ pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, L // Re-timing keeps the keys' order, so the pasted keys are found again by index. let pasted: Vec = pr.keys.iter().enumerate().filter(|(_, k)| sel.contains(&k.time)).map(|(i, _)| i).collect(); retime_roving(&mut pr.keys, pr.spatial); - st.selected_keys = pasted.iter().filter_map(|&i| pr.keys.get(i)).map(|k| KeyRef { layer: lid, prop: uid, time: k.time }).collect(); - Ok(json!(st.selected_keys.len())) + selected.extend(pasted.iter().filter_map(|&i| pr.keys.get(i)).map(|k| KeyRef { layer: lid, prop: uid, time: k.time })); + let count = selected.len().saturating_sub(retained); + st.selected_keys = selected; + Ok(json!(count)) }) } @@ -296,6 +314,9 @@ fn set_key(s: &mut Session, p: &Value) -> Result { let base = KeyTimeBase::from_params(p, "keys.set")?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let layer = comp.layer(lid).ok_or(EngineError::NoComp)?; + if layer.switches.locked { + return Err(bad("keys.set", "the layer is locked")); + } let t = key_time(layer, comp.frame_rate, p, "time", base, "keys.set", false)?.ok_or_else(|| bad("keys.set", "missing `time` (layer time by default)"))?; let new_t = key_time(layer, comp.frame_rate, p, "newTime", base, "keys.set", true)?; let value = p.get("value").cloned(); @@ -349,6 +370,9 @@ fn set_ease(s: &mut Session, p: &Value) -> Result { let influence = f_p(p, "influence").map(|v| (v / 100.0).clamp(0.001, 1.0)); s.edit("Keyframe Velocity", merge_p(p), |proj, _| { let l = super::layer_mut(proj, cid, lid)?; + if l.switches.locked { + return Err(bad("keys.setEase", "the layer is locked")); + } let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.setEase", "no property"))?; let i = nearest_key(&pr.keys, t).ok_or_else(|| bad("keys.setEase", "no keyframes"))?; let spatial = pr.spatial; @@ -495,7 +519,13 @@ fn time_reverse(s: &mut Session, _: &Value) -> Result { } let mut new_sel = vec![]; for ((lid, uid), times) in groups { - let Some(pr) = comp.layer_mut(lid).and_then(|l| l.props.find_mut(uid)) else { continue }; + let Some(layer) = comp.layer_mut(lid) else { continue }; + // A locked layer's keys stay as they are, and stay selected. + if layer.switches.locked { + new_sel.extend(times.iter().map(|time| KeyRef { layer: lid, prop: uid, time: *time })); + continue; + } + let Some(pr) = layer.props.find_mut(uid) else { continue }; let (mut picked, rest): (Vec, Vec) = pr.keys.drain(..).partition(|k| times.contains(&k.time)); effectcraft_keyframe::time_reverse(&mut picked); new_sel.extend(picked.iter().map(|k| KeyRef { layer: lid, prop: uid, time: k.time })); diff --git a/crates/engine/src/commands/anim_presets.rs b/crates/engine/src/cmd/anim_presets.rs similarity index 95% rename from crates/engine/src/commands/anim_presets.rs rename to crates/engine/src/cmd/anim_presets.rs index fc3f95143..88022a0e3 100644 --- a/crates/engine/src/commands/anim_presets.rs +++ b/crates/engine/src/cmd/anim_presets.rs @@ -1,6 +1,7 @@ //! EffectCraft's own animation preset library (Effects & Presets ▸ * Animation Presets): the //! Transitions category (original presets built on our Transition effects and layer opacity) and -//! the Backgrounds category (generators, noise and colour effects driven by expressions). +//! the Backgrounds category (generators, noise and colour effects driven by expressions) and the +//! Behaviors category (transform and opacity driven by expressions, no effects). //! //! Each preset adds effects (with values and keyframes) and/or keys layer properties, starting //! at the current time, on every selected layer. The library is data @@ -26,13 +27,17 @@ pub const TRANSITIONS_JSON: &str = include_str!("../../presets/transitions.json" /// The original background presets (`presets/backgrounds.json`). pub const BACKGROUNDS_JSON: &str = include_str!("../../presets/backgrounds.json"); +/// The original behavior presets (`presets/behaviors.json`): expression-driven motion on a layer's +/// transform and opacity, no effects. +pub const BEHAVIORS_JSON: &str = include_str!("../../presets/behaviors.json"); + /// Keys per animated property, so a hostile inline preset can't allocate without bound. const MAX_KEYS: usize = 256; /// Longest expression text an inline preset may carry. const MAX_EXPR: usize = 4096; fn presets() -> Vec { - [TRANSITIONS_JSON, BACKGROUNDS_JSON] + [TRANSITIONS_JSON, BACKGROUNDS_JSON, BEHAVIORS_JSON] .iter() .flat_map(|src| serde_json::from_str::(src).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default()) .collect() diff --git a/crates/engine/src/commands/anim_tools.rs b/crates/engine/src/cmd/anim_tools.rs similarity index 98% rename from crates/engine/src/commands/anim_tools.rs rename to crates/engine/src/cmd/anim_tools.rs index 06e0d341b..33ee39ae2 100644 --- a/crates/engine/src/commands/anim_tools.rs +++ b/crates/engine/src/cmd/anim_tools.rs @@ -213,7 +213,8 @@ fn sample_curve( fn grouped_selection(s: &Session) -> std::collections::BTreeMap<(LayerId, Uid), Vec> { let mut g: std::collections::BTreeMap<(LayerId, Uid), Vec> = Default::default(); - for k in &s.state.selected_keys { + let Some(comp) = s.active_comp() else { return g }; + for k in super::prop::editable_keys(comp, &s.state.selected_keys) { g.entry((k.layer, k.prop)).or_default().push(k.time); } for v in g.values_mut() { @@ -273,7 +274,7 @@ fn run_on_selection( s.edit(label, None, |proj, st| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let fr = comp.frame_rate; - let mut sel = vec![]; + let mut sel = super::anim::locked_selection(comp, &st.selected_keys); for ((lid, uid), times) in groups { let Some(l) = comp.layer_mut(lid) else { continue }; let lc = l.clone(); diff --git a/crates/engine/src/commands/animation.rs b/crates/engine/src/cmd/animation.rs similarity index 84% rename from crates/engine/src/commands/animation.rs rename to crates/engine/src/cmd/animation.rs index 1bdf3fed7..2c2f23f1a 100644 --- a/crates/engine/src/commands/animation.rs +++ b/crates/engine/src/cmd/animation.rs @@ -10,11 +10,16 @@ use effectcraft_project::{GroupKind, Layer, LayerId, LayerSource, Node, Project, use effectcraft_time::Tick; use serde_json::{Value, json}; +use super::effect::{effect_time_error, retime_effect}; use super::{CommandSpec, bad, frontend, has_comp, has_keys, has_layers, layer_mut, layers_p, match_path_of, resolve_effect_path, selected_leaf_props, str_p}; use crate::{EngineError, KeyRef, Result, Session, cmd}; /// Preset file format version tag (`*.ecpreset`, JSON). pub const PRESET_TAG: &str = "effectcraftAnimationPreset"; +/// The preset format version that `anim.savePreset` writes. +pub const PRESET_VERSION: u64 = 2; +/// The first preset version that stores effect keys relative to the earliest key (#1971). +const RELATIVE_EFFECT_KEYS: u64 = 2; fn has_selected_props(s: &Session) -> std::result::Result<(), String> { has_comp(s)?; @@ -45,13 +50,27 @@ fn selected_effects(s: &Session) -> Vec<(LayerId, PropGroup)> { .collect() } +/// The earliest key anywhere in `groups`, nested controls included. +fn earliest_key<'a>(groups: impl IntoIterator) -> Option { + let mut first: Option = None; + for g in groups { + g.walk("", &mut |_, pr| { + if let Some(t) = pr.keys.iter().map(|k| k.time).min() { + first = Some(first.map_or(t, |f| f.min(t))); + } + }); + } + first +} + // ---------------------------------------------------------------- presets fn save_preset(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad("anim.savePreset", "missing `path`"))?.to_string(); + s.refuse_export(&path)?; let comp = s.active_comp().ok_or(EngineError::NoComp)?; let t = s.time(); - let effects: Vec<(LayerId, PropGroup)> = selected_effects(s); + let mut effects: Vec<(LayerId, PropGroup)> = selected_effects(s); let effect_uids: Vec = effects.iter().map(|(_, g)| g.uid).collect(); let mut props = vec![]; for (lid, uid) in selected_leaf_props(s) { @@ -67,7 +86,11 @@ fn save_preset(s: &mut Session, p: &Value) -> Result { return Err(bad("anim.savePreset", "select properties or effects to save")); } // Key times are stored relative to the earliest key so the preset applies at the CTI. - let first = props.iter().flat_map(|(_, _, pr)| pr.keys.iter().map(|k| k.time)).min().unwrap_or(Tick::ZERO); + let first = + props.iter().flat_map(|(_, _, pr)| pr.keys.iter().map(|k| k.time)).chain(earliest_key(effects.iter().map(|(_, g)| g))).min().unwrap_or(Tick::ZERO); + for (_, g) in &mut effects { + retime_effect(g, 1.0, "anim.savePreset", |t| Ok(Tick(t.0.checked_sub(first.0).ok_or_else(|| effect_time_error("anim.savePreset"))?)))?; + } let props_json: Vec = props .iter() .map(|(_, mp, pr)| { @@ -82,12 +105,27 @@ fn save_preset(s: &mut Session, p: &Value) -> Result { let name = str_p(p, "name") .map(str::to_string) .unwrap_or_else(|| std::path::Path::new(&path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default()); - let doc = json!({PRESET_TAG: 1, "name": name, "properties": props_json, "effects": fx_json}); + let doc = json!({PRESET_TAG: PRESET_VERSION, "name": name, "properties": props_json, "effects": fx_json}); let text = serde_json::to_string_pretty(&doc).unwrap_or_default(); - s.services.write_file(&path, text.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; + s.write_output(&path, text.as_bytes())?; Ok(json!({"path": path, "properties": props.len(), "effects": effects.len()})) } +/// Rewrite an effect property path in a preset (`effects/[#n]/`) to the current +/// parameter id when it names a parameter that the effect has since renamed or moved into a +/// twirl-down group. Any other path comes back unchanged. +fn upgrade_effect_path(path: &str) -> String { + let Some((seg, tail)) = path.strip_prefix("effects/").and_then(|r| r.split_once('/')) else { return path.to_string() }; + let effect = match seg.rsplit_once('#') { + Some((m, n)) if !m.is_empty() && n.parse::().is_ok() => m, + _ => seg, + }; + match effectcraft_effects::find(effect).and_then(|spec| effectcraft_effects::migrate::current_param_id(spec, tail)) { + Some(id) if id != tail => format!("effects/{seg}/{id}"), + _ => path.to_string(), + } +} + fn apply_preset(s: &mut Session, p: &Value) -> Result { let doc: Value = match (p.get("preset"), str_p(p, "path")) { (Some(v), _) => v.clone(), @@ -110,14 +148,22 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result { s.save_prefs(); s.prefs_revision += 1; } - let effects: Vec = + let mut effects: Vec = doc["effects"].as_array().map(|a| a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect()).unwrap_or_default(); + // Older presets stored effect keys at the source layer's times. + if doc[PRESET_TAG].as_u64().unwrap_or(1) < RELATIVE_EFFECT_KEYS + && let Some(first) = earliest_key(&effects) + { + for g in &mut effects { + retime_effect(g, 1.0, "anim.applyPreset", |t| Ok(Tick(t.0.checked_sub(first.0).ok_or_else(|| effect_time_error("anim.applyPreset"))?)))?; + } + } let props: Vec<(String, Property)> = doc["properties"] .as_array() .map(|a| { a.iter() .filter_map(|v| { - let path = v["path"].as_str()?.to_string(); + let path = upgrade_effect_path(v["path"].as_str()?); let value: KV = serde_json::from_value(v["value"].clone()).ok()?; let keys: Vec = serde_json::from_value(v["keys"].clone()).unwrap_or_default(); let expr = serde_json::from_value(v["expression"].clone()).ok().flatten(); @@ -130,16 +176,30 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result { }) .unwrap_or_default(); let t = s.time(); + // Effect bounds per layer size the point defaults of parameters added since the preset was saved. + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let sizes: BTreeMap = + ids.iter().filter_map(|lid| comp.layer(*lid).map(|l| (*lid, effectcraft_render::effect_bounds(&s.project, comp, l).0))).collect(); let mut applied = 0; let mut skipped = vec![]; s.edit("Apply Animation Preset", None, |proj, _| { let mut next = proj.next_id; for lid in &ids { + let size = sizes.get(lid).copied().unwrap_or([100.0, 100.0]); let l = layer_mut(proj, cid, *lid)?; let lt = l.layer_time(t); if let Some(fx) = l.props.sub_mut("effects") { for g in &effects { let mut g = g.clone(); + // Effects saved by an earlier version get the upgrade a project gets on open. + let spec = match &g.kind { + GroupKind::Effect { effect } => effectcraft_effects::find(effect), + _ => None, + }; + if let Some(spec) = spec { + effectcraft_effects::migrate::upgrade_instance(spec, &mut g, &mut Ids(&mut next), size); + } + retime_effect(&mut g, 1.0, "anim.applyPreset", |t| Ok(Tick(t.0.checked_add(lt.0).ok_or_else(|| effect_time_error("anim.applyPreset"))?)))?; g.reassign_uids(&mut next); next += 1; fx.children.push(Node::Group(g)); @@ -184,7 +244,7 @@ fn add_keyframe(s: &mut Session, _: &Value) -> Result { } let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; let t = s.time(); - let n = s.edit("Add Keyframe", None, |proj, _| { + let n = s.edit("Add Keyframe", None, |proj, state| { let mut n = 0; for (lid, uid) in &targets { let l = layer_mut(proj, cid, *lid)?; @@ -200,6 +260,9 @@ fn add_keyframe(s: &mut Session, _: &Value) -> Result { pr.set_value_at(lt, v); } n += 1; + if pr.match_id == "type" { + super::prop::sync_polystar_property(proj, state, cid, *lid, *uid, t)?; + } } Ok(n) })?; @@ -209,7 +272,8 @@ fn add_keyframe(s: &mut Session, _: &Value) -> Result { /// Selected keys grouped by (layer, property). fn keys_by_prop(s: &Session) -> BTreeMap<(LayerId, Uid), Vec> { let mut m: BTreeMap<(LayerId, Uid), Vec> = BTreeMap::new(); - for k in &s.state.selected_keys { + let Some(comp) = s.active_comp() else { return m }; + for k in super::prop::editable_keys(comp, &s.state.selected_keys) { m.entry((k.layer, k.prop)).or_default().push(k.time); } m diff --git a/crates/engine/src/commands/app_more.rs b/crates/engine/src/cmd/app_more.rs similarity index 98% rename from crates/engine/src/commands/app_more.rs rename to crates/engine/src/cmd/app_more.rs index b95dd4375..ea4230852 100644 --- a/crates/engine/src/commands/app_more.rs +++ b/crates/engine/src/cmd/app_more.rs @@ -152,8 +152,9 @@ fn clear_recent_presets(s: &mut Session, _: &Value) -> Result { fn save_copy_xml(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad("file.saveCopyAsXml", "missing `path` (.ecprojx)"))?; - let xml = crate::xml_project::to_xml(&s.project.to_file_json_with_view(&s.saved_view())?).map_err(EngineError::Other)?; - s.services.write_file(path, xml.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; + s.refuse_export(path)?; + let xml = crate::xml_project::to_xml(&s.project.to_file_json_with_view(&s.saved_view(), false)?).map_err(EngineError::Other)?; + s.write_output(path, xml.as_bytes())?; s.toast(format!("Saved an XML copy to {path}")); Ok(json!({"path": path, "bytes": xml.len()})) } @@ -240,7 +241,7 @@ fn replace_with_precomp(s: &mut Session, p: &Value) -> Result { let ids: Vec = proj.items.keys().copied().filter(|c| *c != comp).collect(); for cid in ids { if let Some(c) = proj.comp_mut(cid) { - for l in c.layers.iter_mut().filter(|l| is_user(l)) { + for l in c.layers.iter_mut_where(|l| is_user(l)) { l.source = LayerSource::Comp { item: comp }; n += 1; } @@ -354,7 +355,7 @@ fn assign_view_shortcut(s: &mut Session, p: &Value) -> Result { reassign(s, view_command(cur), &slot, "view.assign3dShortcut") } -/// Window ▸ Assign Shortcut to "" Workspace: Shift+F10, Shift+F11 or Shift+F12. +/// Window ▸ Assign Shortcut to `""` Workspace: Shift+F10, Shift+F11 or Shift+F12. /// The frontend fills in the current workspace. fn assign_workspace_shortcut(s: &mut Session, p: &Value) -> Result { let slot = slot_p(p, "window.assignWorkspaceShortcut", true)?; @@ -668,6 +669,9 @@ fn extract_cubemap(s: &mut Session, p: &Value) -> Result { Ok(r) } +/// The specs for the commands in this module: history steps, recent footage and presets, Save +/// a Copy As XML, footage replacement and Execute File, 3D-view and workspace shortcuts, +/// logging and the system report, VR environment/cubemap creation and System Information. pub fn specs() -> Vec { vec![ cmd!("edit.history", "History", [], None, "{steps?: n (undo n steps), redo?: n}", has_history, history), diff --git a/crates/engine/src/commands/autotrace.rs b/crates/engine/src/cmd/autotrace.rs similarity index 86% rename from crates/engine/src/commands/autotrace.rs rename to crates/engine/src/cmd/autotrace.rs index 7f33fcdf7..8dabfd013 100644 --- a/crates/engine/src/commands/autotrace.rs +++ b/crates/engine/src/cmd/autotrace.rs @@ -64,8 +64,9 @@ fn trace_frame(img: &Image, offset: [f64; 2], ch: Channel, invert: bool, blur: f let mut out = trace(&field, img.width as usize, img.height as usize, o); for t in &mut out { for v in &mut t.path.vertices { - v[0] += offset[0]; - v[1] += offset[1]; + // Source buffers are layer pixels + offset: subtract it to get back to layer space. + v[0] -= offset[0]; + v[1] -= offset[1]; } } out @@ -211,3 +212,33 @@ pub fn specs() -> Vec { run )] } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn trace_frame_converts_image_pixels_back_to_layer_space() { + // A synthetic cropped source avoids any dependency on installed fonts. + let mut img = Image::new(32, 32); + for y in 8..24 { + for x in 8..24 { + img.set(x, y, [1.0; 4]); + } + } + let opts = TraceOpts { corner_roundness: 0.0, ..Default::default() }; + for offset in [[0.0, 0.0], [2.0, 58.0], [-12.5, 7.25]] { + let outlines = trace_frame(&img, offset, Channel::Alpha, false, 0.0, &opts); + assert_eq!(outlines.len(), 1); + let vertices = &outlines[0].path.vertices; + for axis in 0..2 { + // image = layer + offset, so the known image bounds map back + // to [8 - offset, 24 - offset] in layer space. + let lo = vertices.iter().map(|v| v[axis]).fold(f64::INFINITY, f64::min); + let hi = vertices.iter().map(|v| v[axis]).fold(f64::NEG_INFINITY, f64::max); + assert!((lo - (8.0 - offset[axis])).abs() < 0.01, "{offset:?}: {lo}"); + assert!((hi - (24.0 - offset[axis])).abs() < 0.01, "{offset:?}: {hi}"); + } + } + } +} diff --git a/crates/engine/src/commands/batch.rs b/crates/engine/src/cmd/batch.rs similarity index 77% rename from crates/engine/src/commands/batch.rs rename to crates/engine/src/cmd/batch.rs index e1fc37140..5931fe09d 100644 --- a/crates/engine/src/commands/batch.rs +++ b/crates/engine/src/cmd/batch.rs @@ -9,8 +9,9 @@ use serde_json::{Value, json}; use super::{CommandSpec, always, bad, str_p}; use crate::{EngineError, Result, Session}; -/// Commands that can't run inside a batch (they replace the project or nest batches). -const NOT_IN_BATCH: &[&str] = &["engine.batch", "file.runScript", "script.run"]; +/// Commands that can't run inside a batch (they nest batches), besides the commands that run +/// scripts ([`super::scripts::RUNS_SCRIPTS`]). +const NOT_IN_BATCH: &[&str] = &["engine.batch"]; /// Resolve a `$N` / `$N.key.0.key` reference against the results so far. fn reference(s: &str, results: &[Value]) -> Option> { @@ -51,6 +52,17 @@ fn substitute(v: &Value, results: &[Value]) -> Result { }) } +/// `engine.batch`: runs `steps` (`[{command|id, params?}]`) as engine commands, substituting +/// `$N` / `$N.key.0` strings in each step's params with earlier results, and returns +/// `{steps, results}`. +/// +/// `engine.batch`, `file.runScript` and `script.run` are refused inside a batch. Successful +/// steps are folded into one undo step named `label` (default "Batch"). With the default +/// `atomic: true`, a failing step restores the project, history and editor state, drops the +/// events the batch published, sanitizes the editor against the failing intermediate state (so +/// no pre-batch IME token is revived) and keeps the higher process IME revision; with +/// `atomic: false` the steps before the failure stay applied. Either way the error names the +/// failing step and command. fn batch(s: &mut Session, p: &Value) -> Result { let steps = p.get("steps").and_then(Value::as_array).ok_or_else(|| bad("engine.batch", "missing `steps`: [{command, params?}, …]"))?.clone(); let label = str_p(p, "label").unwrap_or("Batch").to_string(); @@ -67,7 +79,7 @@ fn batch(s: &mut Session, p: &Value) -> Result { .or_else(|| step.get("id")) .and_then(Value::as_str) .ok_or_else(|| bad("engine.batch", "each step needs `command` (a command id)"))?; - if NOT_IN_BATCH.contains(&id) { + if NOT_IN_BATCH.contains(&id) || super::scripts::RUNS_SCRIPTS.contains(&id) { return Err(bad("engine.batch", format!("`{id}` can't run inside a batch"))); } let params = substitute(step.get("params").unwrap_or(&json!({})), results)?; @@ -138,6 +150,32 @@ mod tests { use crate::Session; + /// Failed atomic steps discard their UI events while preserving events queued beforehand. + #[test] + fn atomic_batch_event_rollback_preserves_prior_events() { + for atomic in [true, false] { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "First", "width": 100, "height": 100})).unwrap(); + let first = s.active_comp_id().unwrap(); + s.execute("comp.new", json!({"name": "Second", "width": 100, "height": 100})).unwrap(); + let second = s.active_comp_id().unwrap(); + s.drain_events(); + s.execute("comp.open", json!({"comp": first.0})).unwrap(); + let result = s.execute_checked( + "engine.batch", + json!({"atomic": atomic, "steps": [ + {"command": "comp.open", "params": {"comp": second.0}}, + {"command": "no.suchCommand", "params": {}} + ]}), + ); + assert!(result.is_err()); + assert_eq!(s.active_comp_id(), Some(if atomic { first } else { second })); + let opened: Vec<_> = + s.drain_events().into_iter().filter_map(|event| if let crate::Event::OpenComp(id) = event { Some(id) } else { None }).collect(); + assert_eq!(opened, if atomic { vec![first] } else { vec![first, second] }); + } + } + #[test] fn batch_refs_one_undo_step_and_rollback() { let mut s = Session::default(); diff --git a/crates/engine/src/cmd/blender.rs b/crates/engine/src/cmd/blender.rs new file mode 100644 index 000000000..e6c678101 --- /dev/null +++ b/crates/engine/src/cmd/blender.rs @@ -0,0 +1,295 @@ +//! Camera-solve interchange through Blender's public Python API. The generated script is +//! original MIT OR Apache-2.0 code; no Blender implementation is read or embedded. + +use std::path::Path; + +use effectcraft_effects::camera_tracker as ct; +use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Uid}; +use effectcraft_render::EvalCtx; +use effectcraft_time::Tick; +use effectcraft_track::camtrack::{CameraSolve, linalg}; +use serde_json::{Value, json}; + +mod validate; + +use super::{b_p, bad, f_p, str_p}; +use crate::{EngineError, Result, Session}; + +const CMD: &str = "camera.exportBlender"; +const MAX_FRAMES: usize = 100_000; +const MAX_POINTS: usize = 100_000; +const MAX_JSON: usize = 64 * 1024 * 1024; +const MAX_FRAME: i64 = 1_048_574; + +fn number(p: &Value, key: &str, default: f64, min: f64, max: f64) -> Result { + let v = match p.get(key) { + None => default, + Some(_) => f_p(p, key).ok_or_else(|| bad(CMD, format!("{key}: expected a number")))?, + }; + if !v.is_finite() || !(min..=max).contains(&v) { + return Err(bad(CMD, format!("{key}: expected {min}..{max}"))); + } + Ok(v) +} + +fn finite(values: impl IntoIterator) -> Result<()> { + if values.into_iter().any(|v| !v.is_finite() || v.abs() > 1e12) { + return Err(bad(CMD, "invalid or excessive numeric camera/scene data; re-analyze the tracker")); + } + Ok(()) +} + +/// Right-handed EffectCraft world (right, down, forward) → Blender (right, forward, up). +fn position(p: [f64; 3], scale: f64) -> [f64; 3] { + [p[0] * scale, p[2] * scale, -p[1] * scale] +} + +fn media_path(s: &Session, path: &str) -> Result { + if path.is_empty() || path.contains('\0') { + return Err(bad(CMD, "the source footage has no valid file path; relink it before exporting")); + } + let path = Path::new(path); + if path.is_absolute() || cfg!(target_arch = "wasm32") { + return Ok(path.to_string_lossy().into_owned()); + } + let cwd = std::env::current_dir().map_err(|e| bad(CMD, format!("cannot resolve footage path: {e}")))?; + let base = s.path.as_deref().and_then(|p| Path::new(p).parent()).map(|p| cwd.join(p)).unwrap_or(cwd); + Ok(base.join(path).to_string_lossy().into_owned()) +} + +pub(super) fn export(s: &mut Session, p: &Value, cid: ItemId, lid: LayerId, uid: Uid) -> Result { + if s.running_for_script() && !s.prefs.scripting.allow_scripts_write_files { + return Err(bad(CMD, "scripts can't export files unless Settings ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network is on")); + } + let options = Options::parse(p)?; + let Options { path, sensor, units, start, include_points } = options; + if s.camera_target().is_some_and(|(c, l, u)| (c, l, u) == (cid, lid, uid)) && s.is_camera_analyzing() || s.camera_pending.contains(&(cid, lid, uid)) { + return Err(bad(CMD, "wait for this tracker's analysis to finish before exporting")); + } + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let layer = comp.layer(lid).ok_or_else(|| bad(CMD, "tracked layer missing"))?; + let g = layer.props.find_group(uid).ok_or_else(|| bad(CMD, "tracker missing"))?; + for name in [ct::SOLVE, ct::TRACKS] { + if g.prop(name).is_some_and(|p| matches!(&p.value, effectcraft_keyframe::Value::Str(v) if v.len() > MAX_JSON)) { + return Err(bad(CMD, "tracking data exceeds the 64 MiB export limit")); + } + } + let params = crate::camera_track::static_params(g); + let solve = ct::solve(¶ms).ok_or_else(|| bad(CMD, "the camera is not solved yet; analyze the tracker first"))?; + let (lo, end) = validate::timing(comp, layer, &solve, start)?; + let Source { footage, path: source_path, fps: source_fps, count: source_count } = source(s, layer, &solve, path)?; + validate::geometry(&solve, sensor)?; + let camera = params + .get(ct::CAMERA) + .map(|v| v.as_f64()) + .filter(|v| v.is_finite() && *v > 0.0 && *v <= u64::MAX as f64) + .and_then(|id| comp.layer(LayerId(id as u64))) + .filter(|l| l.is_camera()); + let warnings = scene_warnings(camera, &solve, footage); + let ctx = EvalCtx { expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, lo) }; + let (frames, world) = bake_frames(&ctx, layer, &solve, camera, &options, footage, source_count)?; + let mut points = Vec::new(); + for point in &solve.points { + finite(point.pos.into_iter().chain([point.error as f64]))?; + if point.first > point.last || point.last as usize >= solve.frames.len() { + return Err(bad(CMD, "invalid solved point frame range; re-analyze the tracker")); + } + let location = position(world.apply(point.pos), units); + finite(location)?; + if include_points { + points.push(json!({"id": point.id, "location": location, "error": point.error, + "first": start.saturating_add(point.first as i64), "last": start.saturating_add(point.last as i64)})); + } + } + let tracks = ct::tracks(¶ms); + if tracks.as_ref().is_some_and(|t| t.tracks.len() > MAX_POINTS || t.tracks.iter().any(|t| t.pts.len() > MAX_FRAMES)) { + return Err(bad(CMD, "2D tracks exceed export limits")); + } + let markers: Vec = comp + .markers + .iter() + .filter_map(|m| { + let frame = start.saturating_add(comp.frame_rate.frame_at(m.time).saturating_sub(comp.frame_rate.frame_at(lo))); + if (start..=end).contains(&frame) { Some(json!({"name": m.comment, "frame": frame})) } else { None } + }) + .collect(); + let payload = json!({"version": 1, "name": format!("{} — {}", s.project.item(cid).map(|i| i.name.as_str()).unwrap_or("Shot"), layer.name), + "resolution": solve.size, "fpsNumerator": comp.frame_rate.num, "fpsDenominator": comp.frame_rate.den, + "startFrame": start, "endFrame": end, "sensorWidth": sensor, "unitScale": units, + "media": {"path": source_path, "kind": if footage.kind == FootageKind::Still {"FILE"} else {"MOVIE"}, + "fps": source_fps, "nativeRate": footage.native_rate, "pixelAspect": footage.pixel_aspect, "timecode": footage.start_timecode, + "frameCount": source_count, "loopCount": footage.loop_count}, + "frames": frames, "points": points, "markers": markers, "ground": solve.ground.is_some(), + "groundSize": world.s * units, "shutter": comp.shutter_angle / 360.0, "shutterPhase": comp.shutter_phase, + "solve": solve, "tracks": if include_points { tracks.as_deref() } else { None }, + "warnings": warnings}); + let data = serde_json::to_string(&payload).map_err(|e| bad(CMD, format!("cannot serialize scene: {e}")))?; + if data.len() > MAX_JSON { + return Err(bad(CMD, "scene exceeds the 64 MiB script limit; disable includePoints or split the shot")); + } + let quoted = serde_json::to_string(&data).map_err(|e| bad(CMD, format!("cannot encode scene: {e}")))?; + let script = include_str!("blender_scene.py").replace("__EFFECTCRAFT_DATA__", "ed); + s.services.write_file(path, script.as_bytes()).map_err(|e| bad(CMD, format!("cannot write {path}: {e}")))?; + Ok(json!({"path": path, "bytes": script.len(), "frames": frames.len(), "points": points.len(), "warnings": warnings})) +} + +struct Options<'a> { + path: &'a str, + sensor: f64, + units: f64, + start: i64, + include_points: bool, +} + +impl<'a> Options<'a> { + fn parse(p: &'a Value) -> Result { + let path = str_p(p, "path").filter(|p| !p.is_empty() && !p.contains('\0')).ok_or_else(|| bad(CMD, "missing `path` (.py)"))?; + if !Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("py")) { + return Err(bad(CMD, "the export path must end in .py")); + } + let sensor = number(p, "sensorWidth", 36.0, 1.0, 1000.0)?; + let units = number(p, "unitScale", 0.01, 0.000_001, 1000.0)?; + let start = match p.get("startFrame") { + None => 1, + Some(v) => v.as_i64().filter(|n| (1..=MAX_FRAME).contains(n)).ok_or_else(|| bad(CMD, "startFrame: expected a positive Blender frame number"))?, + }; + let include_points = match p.get("includePoints") { + Some(v) if !v.is_boolean() => return Err(bad(CMD, "includePoints: expected a boolean")), + _ => b_p(p, "includePoints").unwrap_or(true), + }; + Ok(Self { path, sensor, units, start, include_points }) + } +} + +struct Source<'a> { + footage: &'a Footage, + path: String, + fps: f64, + count: i64, +} + +fn source<'a>(s: &'a Session, layer: &Layer, solve: &CameraSolve, path: &str) -> Result> { + let footage = match layer.source { + LayerSource::Footage { item } => s.project.item(item).and_then(|i| match &i.kind { + ItemKind::Footage(f) => Some(f), + _ => None, + }), + _ => None, + } + .filter(|f| f.has_video && matches!(f.kind, FootageKind::Video | FootageKind::Still)); + let footage = footage + .ok_or_else(|| bad(CMD, "Blender background export needs a video or still-footage layer; render precomps/sequences to a video and track that video"))?; + let source_path = media_path(s, &footage.path)?; + if Path::new(path) == Path::new(&source_path) { + return Err(bad(CMD, "the export path must differ from the source footage")); + } + if footage.width as f64 != solve.size[0] || footage.height as f64 != solve.size[1] { + return Err(bad(CMD, "the analysis image differs from the original footage dimensions; render the tracker's input to a video and track that video")); + } + let source_fps = footage.frame_rate.as_f64(); + if footage.frame_rate.num <= 0 || footage.frame_rate.den <= 0 || !source_fps.is_finite() || source_fps <= 0.0 { + return Err(bad(CMD, "invalid source footage frame rate")); + } + // Same frame selection as the media pool: interpreted FPS, rounded per-loop frame count, + // clamp to the total loop range, then wrap. Native FPS does not change the frame index. + let divisor = (effectcraft_time::TICKS_PER_SECOND as i128).saturating_mul(footage.frame_rate.den as i128); + let source_count = (footage.duration.0 as i128).saturating_mul(footage.frame_rate.num as i128).saturating_add(divisor / 2) / divisor; + let source_count = if footage.kind == FootageKind::Still { 1 } else { source_count }; + if !(1..=MAX_FRAME as i128).contains(&source_count) { + return Err(bad(CMD, "invalid footage duration or footage exceeds Blender's frame range; trim/render the source video first")); + } + let source_count = source_count as i64; + Ok(Source { footage, path: source_path, fps: source_fps, count: source_count }) +} + +fn scene_warnings(camera: Option<&Layer>, solve: &CameraSolve, footage: &Footage) -> Vec { + let mut warnings = vec![ + "Sensor width is assumed (36 mm by default); monocular tracking cannot recover physical sensor size or absolute scene scale.".to_string(), + "The original footage is referenced, not embedded or rendered: masks and effects before the tracker are not baked. Relink the video if moving this script.".to_string(), + ]; + if camera.is_none() { + warnings + .push("No linked tracker camera: focus uses the default scene depth, DOF is off; optical focus cannot be recovered from feature tracks.".into()); + } else { + warnings.push("Focus/iris settings are sampled from the linked camera; pose and lens come from the tracker solve. Blender's physical DOF is an approximation of EffectCraft's blur model.".into()); + } + if solve.distortion.is_some_and(|d| !d.is_none()) { + warnings.push("Radial distortion is preserved as metadata (Brown k1/k2, half-diagonal normalization), not applied to Blender's pinhole camera. Use an undistorted plate for exact alignment.".into()); + } + if (footage.pixel_aspect - 1.0).abs() > 1e-6 { + warnings.push("The tracker solves square pixels: export uses square pixels; original footage pixel aspect is retained in metadata.".into()); + } + warnings +} + +/// Bake the solved camera and the linked camera's optical settings at exact composition ticks. +fn bake_frames( + ctx0: &EvalCtx<'_>, + layer: &Layer, + solve: &CameraSolve, + camera: Option<&Layer>, + options: &Options<'_>, + footage: &Footage, + source_count: i64, +) -> Result<(Vec, linalg::Similarity)> { + let comp = ctx0.comp; + let lo = ctx0.time; + let fd = comp.frame_duration(); + let sensor = options.sensor; + let units = options.units; + let start = options.start; + let first = solve.frames.first().ok_or_else(|| bad(CMD, "the camera solve is empty"))?; + let m = ctx0.world_matrix(layer).0; + let layer_scale = m[0][0].hypot(m[1][0]); + finite([layer_scale])?; + if layer_scale <= 1e-9 { + return Err(bad(CMD, "the tracked layer has zero scale")); + } + let world = solve.world([comp.width as f64, comp.height as f64], first.focal * layer_scale); + finite([world.s * units])?; + let mut frames = Vec::with_capacity(solve.frames.len()); + for (i, frame) in solve.frames.iter().enumerate() { + let time = Tick(lo.0.saturating_add(fd.0.saturating_mul(i as i64))); + let ctx = ctx0.at(time); + let pose = linalg::mm(&world.r, &linalg::mt(&frame.r())); + let right = position(linalg::mv(&pose, [1.0, 0.0, 0.0]), 1.0); + let up = position(linalg::mv(&pose, [0.0, -1.0, 0.0]), 1.0); + let back = position(linalg::mv(&pose, [0.0, 0.0, -1.0]), 1.0); + let location = position(world.apply(frame.center), units); + let lens = frame.focal * sensor / solve.size[0]; + let opts = camera.and_then(|l| l.props.sub("cameraOptions").map(|g| (l, g))); + let value = |key: &str, fallback: f64| opts.map(|(l, g)| ctx.f(l, g, key, fallback)).unwrap_or(fallback); + let focus = value("focusDistance", world.s) * units; + let aperture = value("aperture", 0.0); + let zoom = value("zoom", frame.focal * layer_scale); + // Match Camera Settings' f-stop conversion: focal_for_zoom uses a 36 mm film width, + // and the aperture UI is mm scaled by comp_width / 1920 (not a world-space diameter). + let aperture_mm = aperture * 1920.0 / comp.width as f64; + let fstop = if aperture_mm > 0.0 { (zoom * 36.0 / comp.width as f64) / aperture_mm } else { 2.8 }; + let rotation = value("irisRotation", 0.0).to_radians(); + let ratio = value("irisAspectRatio", 1.0); + let shape = opts.map(|(l, g)| ctx.e(l, g, "irisShape")).unwrap_or(0); + let blades = match shape { + 0 => 4, + 1..=8 => shape.saturating_add(2), + _ => return Err(bad(CMD, "invalid iris shape")), + }; + let dof = opts.is_some_and(|(l, g)| ctx.b(l, g, "dof")) && aperture > 0.0 && value("blurLevel", 100.0) > 0.0; + let source_time = ctx.source_time(layer); + let source_frame = if footage.kind == FootageKind::Still { + 0 + } else { + footage.frame_rate.frame_at(source_time).clamp(0, source_count.saturating_mul(footage.loop_count.max(1) as i64).saturating_sub(1)) % source_count + }; + finite(location.into_iter().chain([focus, aperture, zoom, fstop, rotation, ratio, value("blurLevel", 100.0), value("irisRoundness", 0.0)]))?; + if focus <= 0.0 || !(0.01..=100.0).contains(&ratio) || !(0.01..=1e6).contains(&fstop) { + return Err(bad(CMD, "focus, f-stop or iris aspect is outside Blender's supported range")); + } + frames.push(json!({"frame": start + i as i64, "timeTicks": time.0, "sourceFrame": source_frame, + "location": location, "rotation": [right, up, back], "lens": lens, "focus": focus, "dof": dof, + "fstop": fstop, "blades": blades, "irisRotation": rotation, "irisRatio": ratio, + "sourceAperture": aperture, "sourceZoom": zoom, + "blurLevel": value("blurLevel", 100.0), "irisRoundness": value("irisRoundness", 0.0)})); + } + Ok((frames, world)) +} diff --git a/crates/engine/src/cmd/blender/validate.rs b/crates/engine/src/cmd/blender/validate.rs new file mode 100644 index 000000000..a9454861f --- /dev/null +++ b/crates/engine/src/cmd/blender/validate.rs @@ -0,0 +1,71 @@ +//! Validate solved geometry and its correspondence to the current layer timing. + +use effectcraft_project::{Comp, Layer}; +use effectcraft_time::Tick; +use effectcraft_track::camtrack::{CameraSolve, linalg}; + +use super::{CMD, MAX_FRAME, MAX_FRAMES, MAX_POINTS, bad, finite}; +use crate::Result; + +pub(super) fn timing(comp: &Comp, layer: &Layer, solve: &CameraSolve, start: i64) -> Result<(Tick, i64)> { + if solve.frames.len() > MAX_FRAMES || solve.points.len() > MAX_POINTS { + return Err(bad(CMD, "export is limited to 100,000 frames and 100,000 points; split the shot")); + } + if solve.size.iter().any(|v| !v.is_finite() || *v < 4.0 || *v > 65_536.0 || v.fract() != 0.0) { + return Err(bad(CMD, "invalid tracking resolution (expected 4..65536 whole pixels)")); + } + let fps = comp.frame_rate.as_f64(); + if comp.frame_rate.num <= 0 || comp.frame_rate.den <= 0 || !fps.is_finite() || !(1.0..=1000.0).contains(&fps) { + return Err(bad(CMD, "invalid composition frame rate (expected 1..1000 fps)")); + } + let end = start + .checked_add(solve.frames.len() as i64) + .and_then(|n| n.checked_sub(1)) + .filter(|n| *n <= MAX_FRAME) + .ok_or_else(|| bad(CMD, "the baked animation exceeds Blender's frame range"))?; + finite([solve.start, solve.frame_duration, solve.average_error, layer.stretch, comp.shutter_angle, comp.shutter_phase])?; + if layer.stretch == 0.0 || comp.width == 0 || comp.height == 0 { + return Err(bad(CMD, "invalid composition dimensions or zero layer stretch")); + } + let lo = comp.frame_rate.snap_nearest(layer.in_point.max(Tick::ZERO)); + let hi = layer.out_point.min(comp.duration); + let fd = comp.frame_duration(); + if fd.0 <= 0 || lo >= hi { + return Err(bad(CMD, "the tracked layer has no active frames")); + } + let expected = ((hi.0 as i128 - lo.0 as i128 + fd.0 as i128 - 1) / fd.0 as i128) as u128; + if expected != solve.frames.len() as u128 { + return Err(bad(CMD, "the solve no longer matches the layer's frame range; re-analyze the tracker")); + } + let dt = (layer.layer_time(lo + fd) - layer.layer_time(lo)).seconds(); + if (solve.start - layer.layer_time(lo).seconds()).abs() > 1e-6 || solve.frames.len() > 1 && (solve.frame_duration - dt).abs() > 1e-6 { + return Err(bad(CMD, "the solve no longer matches the layer's timing; re-analyze the tracker")); + } + Ok((lo, end)) +} + +pub(super) fn geometry(solve: &CameraSolve, sensor: f64) -> Result<()> { + for frame in &solve.frames { + finite(frame.rot.into_iter().chain(frame.center).chain([frame.focal]))?; + if !frame.solved || frame.focal <= 0.0 { + return Err(bad(CMD, "the solve contains unsolved frames or invalid focal lengths; re-analyze the tracker")); + } + let mm = sensor * frame.focal / solve.size[0]; + if !(1.0..=5000.0).contains(&mm) { + return Err(bad(CMD, "lens outside Blender's 1..5000 mm range; choose another sensorWidth")); + } + } + if let Some(ground) = &solve.ground { + finite(ground.origin.into_iter().chain(ground.normal).chain(ground.x_axis))?; + if (linalg::norm(ground.normal) - 1.0).abs() > 1e-4 + || (linalg::norm(ground.x_axis) - 1.0).abs() > 1e-4 + || linalg::dot(ground.normal, ground.x_axis).abs() > 1e-4 + { + return Err(bad(CMD, "invalid ground-plane basis; set the ground plane again")); + } + } + if let Some(d) = &solve.distortion { + finite([d.k1, d.k2, d.radius])?; + } + Ok(()) +} diff --git a/crates/engine/src/cmd/blender_scene.py b/crates/engine/src/cmd/blender_scene.py new file mode 100644 index 000000000..0a9700a00 --- /dev/null +++ b/crates/engine/src/cmd/blender_scene.py @@ -0,0 +1,204 @@ +# EffectCraft camera tracker → Blender, original code under MIT OR Apache-2.0. +# Run in Blender's Scripting workspace (Open, Run Script), or: +# blender --background --python "Tracked Scene.py" +# Creates a NEW scene; existing scenes and objects are preserved. Save the .blend yourself. +# Public API types: bpy.types.Camera, bpy.types.ImageUser +import json +import math +import os + +import bpy +from mathutils import Matrix + +DATA = json.loads(__EFFECTCRAFT_DATA__) + + +def _curves(owner): + """Blender 4.0–4.3 legacy actions and 4.4+/5.x layered actions.""" + anim = owner.animation_data + if anim is None or anim.action is None: + return + action = anim.action + if getattr(action, "is_action_layered", False): + for layer in action.layers: + for strip in layer.strips: + if strip.type == 'KEYFRAME': + bag = strip.channelbag(anim.action_slot) + if bag is not None: + yield from bag.fcurves + else: + yield from action.fcurves + + +def _interpolation(owner): + for curve in _curves(owner): + # Footage indices and discrete switches must never interpolate between frames. + stepped = ("frame_offset" in curve.data_path or "use_dof" in curve.data_path + or "aperture_blades" in curve.data_path) + for key in curve.keyframe_points: + key.interpolation = 'CONSTANT' if stepped else 'LINEAR' + + +def _media_path(): + original = DATA["media"]["path"] + candidates = [original] + # When sharing, place the video alongside the script or the .blend and run again. + script_path = globals().get("__file__", "") + if script_path: + candidates.append(os.path.join(os.path.dirname(os.path.abspath(script_path)), + os.path.basename(original))) + if bpy.data.filepath: + candidates.append(os.path.join(os.path.dirname(bpy.data.filepath), + os.path.basename(original))) + return next((p for p in candidates if os.path.isfile(p)), None) + + +def import_scene(): + if bpy.app.version < (4, 0, 0): + raise RuntimeError("EffectCraft scene import requires Blender 4.0 or newer") + scene = bpy.data.scenes.new(DATA["name"]) + scene.frame_start = DATA["startFrame"] + scene.frame_end = DATA["endFrame"] + fps = DATA["fpsNumerator"] / DATA["fpsDenominator"] + scene.render.fps = max(1, round(fps)) + scene.render.fps_base = scene.render.fps / fps + scene.render.resolution_x, scene.render.resolution_y = map(int, DATA["resolution"]) + scene.render.resolution_percentage = 100 + # The tracker assumes square pixels, regardless of the source's interpretation metadata. + scene.render.pixel_aspect_x = scene.render.pixel_aspect_y = 1.0 + scene.render.film_transparent = True + scene.render.motion_blur_shutter = max(0.0, min(2.0, DATA["shutter"])) + scene.unit_settings.system = 'METRIC' + scene.unit_settings.scale_length = 1.0 + scene["effectcraft_unit_scale"] = DATA["unitScale"] + scene["effectcraft_scale_is_arbitrary"] = True + scene["effectcraft_shutter_phase_degrees"] = DATA["shutterPhase"] + + collection = bpy.data.collections.new("EffectCraft Tracking") + scene.collection.children.link(collection) + camera_data = bpy.data.cameras.new("3D Tracker Camera") + camera = bpy.data.objects.new("3D Tracker Camera", camera_data) + collection.objects.link(camera) + scene.camera = camera + camera.rotation_mode = 'QUATERNION' + camera_data.type = 'PERSP' + camera_data.sensor_fit = 'HORIZONTAL' + camera_data.sensor_width = DATA["sensorWidth"] + camera_data.sensor_height = (DATA["sensorWidth"] * DATA["resolution"][1] + / DATA["resolution"][0]) + camera_data.show_background_images = True + camera_data.show_limits = True + extent = max([DATA["groundSize"], 1.0] + + [math.dist(p["location"], f["location"]) + for p in DATA["points"][::max(1, len(DATA["points"]) // 100)] + for f in DATA["frames"][::max(1, len(DATA["frames"]) // 10)]]) + camera_data.clip_start = max(0.00001, min(0.1, extent / 10000.0)) + camera_data.clip_end = max(1000.0, extent * 10.0) + + previous = None + for sample in DATA["frames"]: + frame = sample["frame"] + camera.location = sample["location"] + # Exported right/up/back vectors are COLUMNS of Blender's local → world basis. + quaternion = Matrix(sample["rotation"]).transposed().to_quaternion() + if previous is not None and previous.dot(quaternion) < 0: + quaternion.negate() + camera.rotation_quaternion = quaternion + previous = quaternion.copy() + camera.keyframe_insert(data_path="location", frame=frame) + camera.keyframe_insert(data_path="rotation_quaternion", frame=frame) + camera_data.lens = sample["lens"] + camera_data.keyframe_insert(data_path="lens", frame=frame) + camera_data.dof.use_dof = sample["dof"] + camera_data.dof.focus_distance = sample["focus"] + camera_data.dof.aperture_fstop = sample["fstop"] + camera_data.dof.aperture_blades = sample["blades"] + camera_data.dof.aperture_rotation = (sample["irisRotation"] + math.pi) % math.tau - math.pi + camera_data.dof.aperture_ratio = sample["irisRatio"] + for prop in ("use_dof", "focus_distance", "aperture_fstop", "aperture_blades", + "aperture_rotation", "aperture_ratio"): + camera_data.keyframe_insert(data_path="dof." + prop, frame=frame) + + path = _media_path() + if path is not None: + try: + image = bpy.data.images.load(path, check_existing=False) + image.source = DATA["media"]["kind"] + background = camera_data.background_images.new() + background.source = 'IMAGE' + background.image = image + background.alpha = 1.0 + background.display_depth = 'BACK' + background.frame_method = 'STRETCH' + user = background.image_user + user.use_auto_refresh = True + user.use_cyclic = False + user.frame_start = DATA["startFrame"] + user.frame_duration = max(1, min(1048574, image.frame_duration)) + if image.source == 'MOVIE': + for sample in DATA["frames"]: + # Blender uses 1-based image frames; sourceFrame is 0-based. + user.frame_offset = sample["sourceFrame"] - (sample["frame"] - user.frame_start) + camera_data.keyframe_insert( + data_path="background_images[0].image_user.frame_offset", + frame=sample["frame"]) + except (RuntimeError, ValueError, OSError) as error: + DATA["warnings"].append("Blender could not load the background video: " + str(error)) + else: + DATA["warnings"].append("Video not found: " + DATA["media"]["path"] + + ". Place it beside this script or relink the camera background.") + + if DATA["points"]: + mesh = bpy.data.meshes.new("Solved Track Points") + mesh.from_pydata([p["location"] for p in DATA["points"]], [], []) + mesh.update() + cloud = bpy.data.objects.new("Solved Track Points", mesh) + collection.objects.link(cloud) + cloud.show_in_front = True + cloud.hide_render = True + for name, kind, key in (("track_id", 'INT', "id"), ("reprojection_error", 'FLOAT', "error"), + ("first_frame", 'INT', "first"), ("last_frame", 'INT', "last")): + attr = mesh.attributes.new(name=name, type=kind, domain='POINT') + for dest, point in zip(attr.data, DATA["points"]): + # Track IDs are u32 in EffectCraft; keep the full IDs in the JSON text too. + dest.value = point[key] if kind == 'FLOAT' else min(2147483647, point[key]) + + if DATA["ground"]: + size = DATA["groundSize"] + mesh = bpy.data.meshes.new("Solved Ground Plane") + mesh.from_pydata([(-size, -size, 0), (size, -size, 0), + (size, size, 0), (-size, size, 0)], [], [(0, 1, 2, 3)]) + mesh.update() + ground = bpy.data.objects.new("Solved Ground Plane", mesh) + collection.objects.link(ground) + ground.display_type = 'WIRE' + ground.hide_render = True + for marker in DATA["markers"]: + scene.timeline_markers.new(marker["name"], frame=marker["frame"]) + + _interpolation(camera) + _interpolation(camera_data) + metadata = bpy.data.texts.new("EffectCraft Tracking Metadata.json") + metadata.write(json.dumps(DATA, ensure_ascii=False, indent=2)) + scene["effectcraft_metadata_text"] = metadata.name + camera_data["effectcraft_average_error_pixels"] = DATA["solve"]["averageError"] + camera_data["effectcraft_solve_method"] = DATA["solve"]["methodUsed"] + camera_data["effectcraft_focus_source"] = "See warnings in " + metadata.name + distortion = DATA["solve"].get("distortion") + if distortion: + for key in ("k1", "k2", "radius"): + camera_data["effectcraft_distortion_" + key] = distortion[key] + if bpy.context.window is not None: + bpy.context.window.scene = scene + for area in bpy.context.window.screen.areas: + if area.type == 'VIEW_3D': + area.spaces.active.region_3d.view_perspective = 'CAMERA' + scene.frame_set(scene.frame_start) + for warning in DATA["warnings"]: + print("EffectCraft: " + warning) + print("EffectCraft: imported", len(DATA["frames"]), "baked camera frames into", scene.name) + return scene + + +if __name__ == "__main__": + EFFECTCRAFT_SCENE = import_scene() diff --git a/crates/engine/src/commands/camera_cmds.rs b/crates/engine/src/cmd/camera_cmds.rs similarity index 98% rename from crates/engine/src/commands/camera_cmds.rs rename to crates/engine/src/cmd/camera_cmds.rs index 68cd14fb9..e717d51cc 100644 --- a/crates/engine/src/commands/camera_cmds.rs +++ b/crates/engine/src/cmd/camera_cmds.rs @@ -42,11 +42,25 @@ pub fn specs() -> Vec { create_from_solve ), cmd!("camera.create", "Create Camera", [], None, "{layer?, effect?}", has_comp, |s, p| create_from_solve(s, &with_kind(p, "camera"))), + cmd!( + "camera.exportBlender", + "Export Scene to Blender...", + [], + None, + "{path: .py, comp?, layer?, effect?, sensorWidth?: mm (default 36), unitScale?: Blender units per comp pixel (default 0.01), startFrame?: positive integer (default 1), includePoints?: true}", + has_comp, + export_blender + ), cmd!("camera.setGroundPlane", "Set Ground Plane and Origin", [], None, "{points?: [id] (default: selected), layer?, effect?}", has_comp, set_ground), cmd!("camera.deletePoints", "Delete Selected Points", [], None, "{points?: [id] (default: selected), layer?, effect?, wait?}", has_comp, delete_points), ] } +fn export_blender(s: &mut Session, p: &Value) -> Result { + let (cid, lid, uid) = ct_p(s, p, "camera.exportBlender")?; + super::blender::export(s, p, cid, lid, uid) +} + fn with_kind(p: &Value, kind: &str) -> Value { let mut q = p.clone(); if !q.is_object() { diff --git a/crates/engine/src/commands/comp.rs b/crates/engine/src/cmd/comp.rs similarity index 95% rename from crates/engine/src/commands/comp.rs rename to crates/engine/src/cmd/comp.rs index 6648a7426..8f44ebd3d 100644 --- a/crates/engine/src/commands/comp.rs +++ b/crates/engine/src/cmd/comp.rs @@ -35,7 +35,7 @@ fn apply_settings(c: &mut Comp, p: &Value) { let dx = (c.width as f64 - ow as f64) * (a % 3) as f64 / 2.0; let dy = (c.height as f64 - oh as f64) * (a / 3) as f64 / 2.0; if dx != 0.0 || dy != 0.0 { - for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) { + for l in c.layers.iter_mut_where(|l| l.parent.is_none()) { if let Some(tr) = l.transform_mut() { super::layer::offset_position(tr, dx, dy); } @@ -272,6 +272,20 @@ fn poster(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +fn delete_all_markers(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + s.edit("Delete All Markers", None, |proj, _| { + proj.comp_mut(cid).ok_or(EngineError::NoComp)?.markers.clear(); + Ok(()) + })?; + Ok(Value::Null) +} + +fn has_comp_markers(s: &Session) -> std::result::Result<(), String> { + let c = s.active_comp().ok_or("no composition")?; + if !c.markers.is_empty() { Ok(()) } else { Err("the composition has no markers".into()) } +} + pub fn specs() -> Vec { vec![ cmd!( @@ -299,5 +313,6 @@ pub fn specs() -> Vec { cmd!("comp.workArea", "Set Work Area", [], None, "{start?, end?, set?: begin|end (at CTI)}", has_comp, work_area), cmd!("comp.setSwitch", "Composition Switch", [], None, "{switch: hideShy|motionBlur|frameBlending|draft3d, value?}", has_comp, comp_switch), cmd!("comp.addMarker", "Add Marker", [], Some("Num*"), "{time?, comment?}", has_comp, add_marker), + cmd!("comp.deleteAllMarkers", "Delete All Markers", [], None, "{comp?}", has_comp_markers, delete_all_markers), ] } diff --git a/crates/engine/src/commands/comp_more.rs b/crates/engine/src/cmd/comp_more.rs similarity index 57% rename from crates/engine/src/commands/comp_more.rs rename to crates/engine/src/cmd/comp_more.rs index d6de320ec..8b8340e78 100644 --- a/crates/engine/src/commands/comp_more.rs +++ b/crates/engine/src/cmd/comp_more.rs @@ -1,11 +1,11 @@ -//! Composition menu, part two: crop to region of interest / layer bounds, Save Frame As ▸ File, -//! Copy Frame to Clipboard, Responsive Design — Time. +//! Composition menu, part two: crop to region of interest / layer bounds, Scale Composition, Save +//! Frame As ▸ File, Copy Frame to Clipboard, Responsive Design — Time. use effectcraft_project::Marker; use effectcraft_time::Tick; use serde_json::{Value, json}; -use super::{CommandSpec, always, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p}; +use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p}; use crate::prefs::CompPreset; use crate::{EngineError, Result, Session, cmd, query}; @@ -25,7 +25,7 @@ fn crop(s: &mut Session, cid: effectcraft_project::ItemId, r: [f64; 4], label: & let (old_w, old_h) = (c.width, c.height); c.width = w; c.height = h; - for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) { + for l in c.layers.iter_mut_where(|l| l.parent.is_none()) { if let Some(tr) = l.transform_mut() { super::layer::offset_position(tr, -x, -y); } @@ -71,6 +71,164 @@ fn crop_to_layers(s: &mut Session, p: &Value) -> Result { crop(s, cid, [x0, y0, b[2].ceil() - x0, b[3].ceil() - y0], "Crop Comp to Selected Layer(s) Bounds") } +/// Multiply a property's value, every key, its spatial tangents and its speeds by `f`. +fn scale_prop(pr: &mut effectcraft_project::Property, f: f64) { + use effectcraft_keyframe::Value as KV; + let mul = |v: &KV| match v { + KV::Scalar(x) => KV::Scalar(x * f), + KV::Vec2(a) => KV::Vec2(a.map(|x| x * f)), + KV::Vec3(a) => KV::Vec3(a.map(|x| x * f)), + other => other.clone(), + }; + pr.value = mul(&pr.value); + for k in &mut pr.keys { + k.value = mul(&k.value); + k.spatial_in = k.spatial_in.map(|x| x * f); + k.spatial_out = k.spatial_out.map(|x| x * f); + for e in k.in_ease.iter_mut().chain(&mut k.out_ease) { + e.speed *= f; + } + } +} + +/// Multiply only the Z of a 3D value (and of its keys and spatial tangents) by `f`. Speeds follow: +/// a spatial side's speed by how much the path's direction there stretches, so the ease keeps +/// its shape; a per-dimension one (Scale) by `f` for Z only. +fn scale_prop_z(pr: &mut effectcraft_project::Property, f: f64) { + use effectcraft_keyframe::Value as KV; + let stretch = |v: [f64; 3]| { + let n = v[0].hypot(v[1]).hypot(v[2]); + if n > 1e-12 && n.is_finite() { v[0].hypot(v[1]).hypot(v[2] * f) / n } else { 1.0 } + }; + let pos = |k: &effectcraft_project::Keyframe| k.value.as_vec3(); + let sub = |a: [f64; 3], b: [f64; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; + let is_zero = |v: [f64; 3]| v.iter().all(|x| x.abs() <= 1e-12); + // Read every side's direction before anything changes (auto tangents come from the neighbours). + let sides: Vec<(f64, f64)> = (0..pr.keys.len()) + .map(|i| { + let (tin, tout) = effectcraft_keyframe::spatial_tangents(&pr.keys, i); + let here = pr.keys.get(i).map(pos).unwrap_or_default(); + let chord = |j: Option| j.and_then(|j| pr.keys.get(j)).map(|k| sub(pos(k), here)).unwrap_or_default(); + let tin = if is_zero(tin) { chord(i.checked_sub(1)) } else { tin }; + let tout = if is_zero(tout) { chord(Some(i + 1)) } else { tout }; + (stretch(tin), stretch(tout)) + }) + .collect(); + let spatial = pr.spatial; + let mul = |v: &mut KV| { + if let KV::Vec3(a) = v { + a[2] *= f; + } + }; + mul(&mut pr.value); + for (k, (s_in, s_out)) in pr.keys.iter_mut().zip(sides) { + mul(&mut k.value); + k.spatial_in[2] *= f; + k.spatial_out[2] *= f; + if spatial { + k.in_ease.iter_mut().for_each(|e| e.speed *= s_in); + k.out_ease.iter_mut().for_each(|e| e.speed *= s_out); + } else { + // A shorter list lends its first ease to Z (`effective_ease`): give Z its own. + for list in [&mut k.in_ease, &mut k.out_ease] { + if let Some(&first) = list.first() { + list.resize(list.len().max(3), first); + } + if let Some(e) = list.get_mut(2) { + e.speed *= f; + } + } + } + } +} + +/// Scale Composition (After Effects ships it as File ▸ Scripts ▸ Scale Composition.jsx): the comp +/// and its contents grow or shrink by one factor, keys included, in one undo step. Layers whose +/// parent has a Scale follow that parent; everything else scales its Position (and Point of +/// Interest) and Scale. Anchor Point, masks, shapes and effects live in layer space and follow the +/// layer's Scale. Cameras scale their Zoom so 3D layers keep their place in the frame; a 2D parent +/// carries no depth, so its 3D children scale their Z themselves. +fn scale_comp(s: &mut Session, p: &Value) -> Result { + const C: &str = "comp.scale"; + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let (ow, oh) = (f64::from(comp.width), f64::from(comp.height)); + // The three are alternatives: with two, one would be ignored without a word. + if ["factor", "width", "height"].iter().filter(|k| f_p(p, k).is_some()).count() > 1 { + return Err(bad(C, "pass only one of `factor`, `width` or `height`")); + } + let f = match (f_p(p, "factor"), f_p(p, "width"), f_p(p, "height")) { + (Some(f), ..) => f, + (None, Some(w), _) => w / ow, + (None, None, Some(h)) => h / oh, + _ => return Err(bad(C, "pass `factor`, `width` or `height`")), + }; + let (w, h) = ((ow * f).round(), (oh * f).round()); + let max = f64::from(effectcraft_project::limits::MAX_SIZE); + if !f.is_finite() || f <= 0.0 || !(4.0..=max).contains(&w) || !(4.0..=max).contains(&h) { + return Err(bad(C, format!("the composition would be {w}×{h}: width and height run from 4 to 30,000 px"))); + } + let (w, h) = (w as u32, h as u32); + let active = s.active_comp_id() == Some(cid); + s.edit("Scale Composition", None, |proj, st| { + let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + // An ancestor's Scale carries its descendants (X and Y only when that ancestor is 2D), + // through cameras and lights too: the renderer applies every ancestor's transform. + let chain: std::collections::HashMap<_, _> = + c.layers.iter().map(|l| (l.id, (l.transform().is_some_and(|tr| tr.get("scale").is_some()), l.is_3d(), l.parent))).collect(); + // (X and Y carried, Z carried); bounded, as parent chains in a file can loop. + let carried = |mut cur: Option| { + let (mut xy, mut z) = (false, false); + for _ in 0..64 { + let Some(&(has_scale, three_d, up)) = cur.and_then(|id| chain.get(&id)) else { break }; + xy |= has_scale; + z |= has_scale && three_d; + cur = up; + } + (xy, z) + }; + for l in &mut c.layers { + let (xy_carried, z_carried) = carried(l.parent); + let three_d = l.is_3d(); + let Some(tr) = l.transform_mut() else { continue }; + if !xy_carried { + for name in ["position", "positionX", "positionY", "positionZ", "poi", "scale"] { + if let Some(pr) = tr.get_mut(name) { + scale_prop(pr, f); + } + } + } else if three_d && !z_carried { + if let Some(pr) = tr.get_mut("positionZ") { + scale_prop(pr, f); + } + for name in ["position", "poi", "scale"] { + if let Some(pr) = tr.get_mut(name) { + scale_prop_z(pr, f); + } + } + } + if let Some(cam) = l.props.sub_mut("cameraOptions") { + for name in ["zoom", "focusDistance", "aperture"] { + if let Some(pr) = cam.get_mut(name) { + scale_prop(pr, f); + } + } + } + } + // Percentage guides keep their percent. + for g in c.guides.iter_mut().filter(|g| !g.percent) { + g.position *= f; + } + c.width = w; + c.height = h; + if active && let Some(r) = &mut st.region_of_interest { + *r = r.map(|v| v * f); + } + Ok(()) + })?; + Ok(json!({"width": w, "height": h, "factor": f})) +} + /// Encode straight 8-bit RGBA as PNG. pub(crate) fn encode_png(rgba: &[u8], w: u32, h: u32) -> std::result::Result, String> { use image::ImageEncoder; @@ -82,7 +240,7 @@ pub(crate) fn encode_png(rgba: &[u8], w: u32, h: u32) -> std::result::Result Result { - use effectcraft_project::render_queue::{RenderQueueItem, TimeSpan}; + use effectcraft_project::render_queue::{Channels, RenderQueueItem, TimeSpan}; use effectcraft_project::render_templates::TemplateSlot; let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; @@ -91,10 +249,16 @@ fn queue_frame(s: &mut Session, p: &Value) -> Result { it.settings = s.project.render_templates.default_render_settings(TemplateSlot::Still); it.settings.time_span = TimeSpan::Custom { start: t, end: t + comp.frame_duration() }; it.output = s.project.render_templates.default_output_module(TemplateSlot::Still); + if b_p(p, "transparent") == Some(false) { + it.output.channels = Channels::Rgb; + } if let Some(path) = str_p(p, "path") { it.output.output = path.to_string(); - } else if !it.output.format.is_sequence() { - it.output.output = "[compName]_frame.[fileExtension]".into(); + } else { + if !it.output.format.is_sequence() { + it.output.output = "[compName]_frame.[fileExtension]".into(); + } + s.avoid_sources(&mut it); } let id = s.edit("Save Frame As", None, |proj, _| { it.id = proj.render_queue.iter().map(|i| i.id).max().unwrap_or(0) + 1; @@ -107,19 +271,20 @@ fn queue_frame(s: &mut Session, p: &Value) -> Result { } fn save_frame(s: &mut Session, p: &Value) -> Result { - if p.get("queue").and_then(Value::as_bool) == Some(true) { + if b_p(p, "queue") == Some(true) { return queue_frame(s, p); } let path = str_p(p, "path").ok_or_else(|| bad("comp.saveFrameAs", "missing `path` (.png)"))?.to_string(); + s.refuse_export(&path)?; let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(s.time()); let scale = f_p(p, "scale").unwrap_or(1.0).clamp(0.01, 4.0); let bg = comp.background; let img = s.render(cid, t, effectcraft_render::RenderOpts { scale, backend: effectcraft_render::Backend::Auto, ..Default::default() }); - let rgba = img.to_rgba8_over(bg); + let rgba = if b_p(p, "transparent") == Some(false) { img.to_rgba8_over(bg) } else { img.to_rgba8() }; let png = encode_png(&rgba, img.width, img.height).map_err(|e| EngineError::Other(format!("PNG encoding failed: {e}")))?; - s.services.write_file(&path, &png).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; + s.write_output(&path, &png)?; s.toast(format!("Saved frame to {path}")); Ok(json!({"path": path, "width": img.width, "height": img.height, "time": t.seconds()})) } @@ -230,12 +395,21 @@ pub fn specs() -> Vec { vec![ cmd!("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", ["Composition"], None, "{comp?}", has_roi, crop_to_roi), cmd!("comp.cropToLayerBounds", "Crop Comp to Selected Layer(s) Bounds", ["Composition"], None, "{layers?}", has_layers, crop_to_layers), + cmd!( + "comp.scale", + "Scale Composition", + [], + None, + "{comp?, factor? (2 = twice the size) | width? | height? (the other side keeps the aspect ratio)}", + has_comp, + scale_comp + ), cmd!( "comp.saveFrameAs", "File...", ["Composition", "Save Frame As"], Some("Cmd+Alt+S"), - "{path (.png; with queue: an output path or template), time?, scale?, queue?: bool (add a Render Queue item from the Frame Default templates instead of writing now)}", + "{path (.png; with queue: an output path or template), time?, scale?, queue?: bool (add a Render Queue item from the Frame Default templates instead of writing now), transparent?: bool (default true)}", has_comp, save_frame ), diff --git a/crates/engine/src/commands/content_fill.rs b/crates/engine/src/cmd/content_fill.rs similarity index 91% rename from crates/engine/src/commands/content_fill.rs rename to crates/engine/src/cmd/content_fill.rs index 650df5e73..d96be073d 100644 --- a/crates/engine/src/commands/content_fill.rs +++ b/crates/engine/src/cmd/content_fill.rs @@ -170,6 +170,21 @@ pub struct FillPlan { pub dir: String, } +/// The fill holds every frame as float RGBA (16 bytes a pixel), its hole mask (1) and the filled +/// copy (16) until it finishes (#1789): when even that is more than `available`, the error to +/// report up front, instead of running out of memory partway through. +fn memory_shortfall(frames: usize, w: u32, h: u32, available: u64) -> Option { + let need = (frames as u64).saturating_mul(u64::from(w)).saturating_mul(u64::from(h)).saturating_mul(33); + (need > available).then(|| { + let gb = |b: u64| b as f64 / 1e9; + format!( + "Content-Aware Fill of {frames} frames at {w}×{h} needs at least {:.1} GB of memory, and {:.1} GB is free: fill a shorter range (set the work area and choose Work Area) or close other applications", + gb(need), + gb(available) + ) + }) +} + /// Run a fill: the layer's frames (rendered in passes with a job worker's GPU), the fill, the /// PNG sequence written into `plan.dir` (`Services::store_file`). Returns the files. #[allow(clippy::too_many_arguments)] @@ -275,6 +290,9 @@ fn generate(s: &mut Session, p: &Value) -> Result { } let (fa, fb) = (fr.frame_at(fr.snap_nearest(a)), fr.frame_at(b - Tick(1))); let times: Vec = (fa..=fb).map(|f| fr.tick_of(f)).collect(); + if let Some(e) = crate::sysinfo::memory().and_then(|m| memory_shortfall(times.len(), w, h, m.available)) { + return Err(bad(c, e)); + } let opts = FillOpts { method: FillMethod::from_name(&st.method).unwrap_or_default(), lighting: lighting(&st.lighting_correction).unwrap_or(0.0), @@ -424,3 +442,18 @@ pub fn specs() -> Vec { }, ] } + +#[cfg(test)] +mod tests { + use super::memory_shortfall; + + /// #1789: 10 s of 1080p30 (300 frames) needs at least 20 GB, which 4 GB free can't hold. + #[test] + fn a_fill_that_cannot_fit_in_memory_is_refused_up_front() { + let e = memory_shortfall(300, 1920, 1080, 4_000_000_000).expect("refused"); + assert!(e.contains("300 frames") && e.contains("Work Area"), "{e}"); + assert_eq!(memory_shortfall(30, 1920, 1080, 4_000_000_000), None, "a second of it fits"); + // Hostile sizes saturate instead of overflowing. + assert!(memory_shortfall(usize::MAX, u32::MAX, u32::MAX, u64::MAX - 1).is_some()); + } +} diff --git a/crates/engine/src/commands/create.rs b/crates/engine/src/cmd/create.rs similarity index 71% rename from crates/engine/src/commands/create.rs rename to crates/engine/src/cmd/create.rs index 850d38ada..6c4b02c87 100644 --- a/crates/engine/src/commands/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -1,14 +1,17 @@ //! Layer ▸ Create: Convert to Editable Text (Photoshop type layers), Create Shapes from Text, -//! Create Masks from Text and Create Shapes from Vector Layer (SVG footage). +//! Create Masks from Text, Create Shapes from Vector Layer (SVG footage) and Create 3D Layer +//! Instance (a 2D copy of a 3D model layer that 2D effects can be applied to). use effectcraft_color::Label; +use effectcraft_keyframe::Value as KV; +use effectcraft_project::build::{self, Ids}; use effectcraft_project::{ItemKind, LayerSource, Solid}; use serde_json::{Value, json}; use super::{CommandSpec, bad, has_layers, layers_p, str_p}; use crate::{EngineError, Result, Session, cmd}; -const OPS: &str = "editableText|shapesFromText|masksFromText|shapesFromVector"; +const OPS: &str = "editableText|shapesFromText|masksFromText|shapesFromVector|instance3d"; fn create(s: &mut Session, p: &Value) -> Result { let op = str_p(p, "op").ok_or_else(|| bad("layer.create", format!("missing `op` ({OPS})")))?; @@ -19,6 +22,69 @@ fn create(s: &mut Session, p: &Value) -> Result { let time = s.time(); let mut created = vec![]; let mut skipped = vec![]; + // All the selected models in one edit: one undo (or redo) removes (or restores) every + // instance at once, as one command in the History panel. + if op == "instance3d" { + // After Effects' Create 3D Layer Instance (Layer ▸ Create): 3D model layers take + // no masks or effects, so a comp-sized solid goes at the top of the layer stack + // with a 3D Layer Instance effect referencing the model — a 2D copy of its render, + // through the comp's camera, that 2D effects can be applied to. The model is switched + // off, as After Effects does (back on to keep its shadow). + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let mut eligible = vec![]; + for lid in &lids { + match comp.layer(*lid) { + Some(src) if src.source.is_model() => eligible.push((*lid, src.clone())), + Some(src) => skipped.push(format!("{}: not a 3D model layer", src.name)), + None => {} + } + } + if !eligible.is_empty() { + let ids = s.edit("Create 3D Layer Instance", None, |proj, st| { + let mut ids = vec![]; + for (lid, src) in &eligible { + let name = proj.comp(cid).ok_or(EngineError::NoComp)?.unique_layer_name(&format!("{} Instance", src.name)); + let folder = proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder)); + let sid = proj.add_item( + &name, + src.label, + Some(folder), + ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: comp.width, height: comp.height, pixel_aspect: 1.0 }), + ); + let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (comp.width, comp.height), None); + // A soloed source keeps its Solo switch with video off, and any_solo hides + // the non-soloed copy: the copy takes the Solo over to stay visible. + l.switches.solo = src.switches.solo; + // A 3D Layer Instance effect referencing the 3D layer: the solid shows its + // render (the 2D copy). + let fx = effectcraft_effects::find("ec.3d.instance3d").ok_or_else(|| bad("layer.create", "the 3D Layer Instance effect is missing"))?; + let mut g = effectcraft_effects::instantiate(fx, &mut Ids(&mut proj.next_id), fx.name, [comp.width as f64, comp.height as f64]); + if let Some(pr) = g.get_mut("layer") { + pr.value = KV::Layer(Some(lid.0)); + } + if let Some(fxg) = l.props.sub_mut("effects") { + fxg.children.push(g.into()); + } + // The model is switched off (After Effects turns the referenced layer off); + // the copies go at the top of the stack, selected. + let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + if let Some(m) = c.layers.iter_mut().find(|x| x.id == *lid) { + m.switches.video = false; + } + let id = l.id; + c.layers.insert(0, l); + ids.push(id); + } + st.selected_layers = ids.clone(); + Ok(ids) + })?; + created.extend(ids.iter().map(|i| i.0)); + } + if created.is_empty() && !skipped.is_empty() { + return Err(bad("layer.create", skipped.join("; "))); + } + return Ok(json!({"layers": created, "skipped": skipped})); + } for lid in lids { let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let Some(src) = comp.layer(lid).cloned() else { continue }; @@ -198,5 +264,5 @@ fn insert_above( } pub fn specs() -> Vec { - vec![cmd!("layer.create", "Create", [], None, "{op: editableText|shapesFromText|masksFromText|shapesFromVector, layers?}", has_layers, create)] + vec![cmd!("layer.create", "Create", [], None, "{op: editableText|shapesFromText|masksFromText|shapesFromVector|instance3d, layers?}", has_layers, create)] } diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/cmd/edit.rs similarity index 88% rename from crates/engine/src/commands/edit.rs rename to crates/engine/src/cmd/edit.rs index 6a8194395..8a5068e8f 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -2,11 +2,11 @@ use effectcraft_color::Label; use effectcraft_keyframe::Value as KV; -use effectcraft_project::{Comp, Expression, ItemKind, Layer, LayerId, LayerSource, TrackMatte}; +use effectcraft_project::{Comp, Expression, ItemId, ItemKind, Layer, LayerId, LayerSource, TrackMatte}; use effectcraft_time::Tick; use serde_json::{Value, json}; -use super::{CommandSpec, has_comp, has_layers, layers_p, match_path_of, selected_leaf_props, str_p}; +use super::{CommandSpec, has_comp, has_layers, layers_or, layers_p, match_path_of, selected_leaf_props, str_p}; use crate::{EngineError, KeyRef, LinkClip, Result, Session, cmd}; fn can_undo(s: &Session) -> std::result::Result<(), String> { @@ -25,6 +25,7 @@ fn has_clip(s: &Session) -> std::result::Result<(), String> { && s.state.key_clipboard.is_empty() && s.state.effect_clipboard.is_empty() && s.state.contents_clipboard.is_empty() + && s.state.mask_clipboard.is_empty() && s.state.link_clipboard.is_none() && s.state.value_clipboard.is_empty() { @@ -74,9 +75,9 @@ fn select_all(s: &mut Session, _: &Value) -> Result { Ok(json!(s.state.selected_layers.len())) } fn deselect_all(s: &mut Session, _: &Value) -> Result { - // A deselected layer never keeps an open text edit: commit the edit first, exactly as - // `text.endEdit` (an empty Type-tool layer included), so every route — menu, shortcut, - // script, MCP — leaves one final state. + // A deselected layer never keeps an open text edit: commit it first, exactly as `text.endEdit`, + // so every route — menu, shortcut, script, MCP — leaves one final state. An empty Type-tool + // layer survives, as it does in After Effects. super::text_edit::end_edit(s, &Value::Null)?; s.state.selected_layers.clear(); s.state.selected_props.clear(); @@ -90,7 +91,7 @@ fn deselect_all(s: &mut Session, _: &Value) -> Result { fn relink_copies(comp: &mut Comp, map: &[(LayerId, LayerId)]) { let present: Vec = comp.layers.iter().map(|l| l.id).collect(); let link = |id: LayerId| map.iter().find(|(o, _)| *o == id).map(|(_, c)| *c).or(present.contains(&id).then_some(id)); - for l in comp.layers.iter_mut().filter(|l| map.iter().any(|(_, c)| *c == l.id)) { + for l in comp.layers.iter_mut_where(|l| map.iter().any(|(_, c)| *c == l.id)) { l.parent = l.parent.and_then(link); l.track_matte = l.track_matte.and_then(|m| link(m.layer).map(|layer| TrackMatte { layer, ..m })); } @@ -104,9 +105,16 @@ pub(crate) fn reid(layer: &mut Layer, next: &mut u64) { *next += 1; } +/// `edit.duplicate`: copies the targets in place. Without `layers` the targets are, in order: +/// during a text edit the edited layer (the edit is committed first, as `text.endEdit` does), +/// else the selected effects, shape items or text animators, else the selected layers. Layer +/// copies are selected and their ids returned; the other paths return their delegated result. fn duplicate(s: &mut Session, p: &Value) -> Result { - // Effects selected → duplicate them (Edit ▸ Duplicate in Effect Controls). - if p.get("layers").is_none() { + let editing = s.state.text_edit.is_some(); + // Effects selected → duplicate them (Edit ▸ Duplicate in Effect Controls). An open text edit + // comes first, as it does for Clear: opening the editor keeps the property selection, but + // while typing, Duplicate means the edited layer, not an effect or item selected before. + if p.get("layers").is_none() && !editing { let fx = super::effect::selected_effects(s); if !fx.is_empty() { let mut out = vec![]; @@ -126,7 +134,17 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { return super::prop_groups::duplicate_contents(s, &items); } } - let (cid, ids) = layers_p(s, p)?; + // A deselected layer never keeps an open text edit (#2226): the copies take the selection, so + // commit an open edit first, exactly as `text.endEdit` and Deselect All do — one call on the + // command path so every route (menu, shortcut, palette, MCP, script) ends the edit and leaves + // the same final state. Unnamed, the target is the edited layer even when an engine route + // selected another one meanwhile. + let edited = s.state.text_edit.as_ref().map(|e| e.layer); + super::text_edit::end_edit(s, &Value::Null)?; + let (cid, ids) = match edited { + Some(layer) => layers_or(s, p, &[layer])?, + None => layers_p(s, p)?, + }; let new = s.edit("Duplicate", None, |proj, st| { let mut next = proj.next_id; let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?; @@ -143,52 +161,86 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { } relink_copies(comp, &map); proj.next_id = next; - st.selected_layers = created.clone(); + // The property, keyframe and mask-vertex selection the edit kept belongs to the originals; + // left in place, the next Duplicate or Clear would act on it, not on the copies. So during + // an edit the copies take the selection the way a click on them would. + if editing { + super::layer::select_only(st, created.clone()); + } else { + st.selected_layers = created.clone(); + } Ok(created) })?; Ok(json!(new.iter().map(|l| l.0).collect::>())) } +/// `edit.clear`: delegates to `text.delete` while editing text (deleting the selection or +/// preceding character/word), then handles selected Time Remap (turning remapping off), keyframes, loose mask +/// vertices, puppet pins, effects, masks/shape items/text animators/trackers, and finally the +/// selected (or given) unlocked layers. Layer and selected-property-group paths return the number +/// removed; text, Time Remap, keyframe, loose-vertex, puppet-pin and effect paths return their +/// delegated command's result. +/// +/// Layer removal also unparents surviving children in place, clears track mattes that pointed +/// at a removed layer and rewrites layer-reference values and keys to `None` in the same undo +/// step; the text-edit session is then sanitized so a removed layer's editor is not reused. fn delete(s: &mut Session, p: &Value) -> Result { - if s.state.text_edit.is_some() && p.get("layers").is_none() { - return s.execute("text.delete", json!({})); + if let Some(done) = delete_selection(s, p)? { + return Ok(done); + } + let (cid, ids) = layers_p(s, p)?; + let ids = super::unlocked(s, cid, ids, "edit.clear")?; + clear_layers(s, cid, &ids)?; + // A removed layer may have been the edited one: end that session before another command + // can reuse it. Undo restores a layer as a new editor, never by reviving the old IME session. + s.sanitize_state(); + Ok(json!(ids.len())) +} + +/// Deletes the selected things that are not layers. `None` when the layers are deleted +/// instead (nothing else is selected, or the command names its layers). +fn delete_selection(s: &mut Session, p: &Value) -> Result> { + let no_layers = p.get("layers").is_none(); + if s.state.text_edit.is_some() && no_layers { + return Ok(Some(s.execute("text.delete", json!({}))?)); } // Time Remap selected (its name, which selects all its keys) → time remapping off, not the // layer (After Effects). - if p.get("layers").is_none() { + if no_layers { let remapped = super::layer_time::selected_time_remap(s); if !remapped.is_empty() { - return super::layer_time::disable_time_remap(s, &remapped); + return Ok(Some(super::layer_time::disable_time_remap(s, &remapped)?)); } } // Keyframes selected → delete keys; mask vertices → delete them; else layers. - if !s.state.selected_keys.is_empty() && p.get("layers").is_none() { - return s.execute("keys.delete", json!({})); + if !s.state.selected_keys.is_empty() && no_layers { + return Ok(Some(s.execute("keys.delete", json!({}))?)); } // (Points selected with their mask, which selecting a mask does: the mask goes, below.) let comp = s.active_comp(); let loose = s.state.selected_vertices.iter().any(|v| { !s.state.selected_props.contains(&(v.layer, v.mask)) && comp.and_then(|c| c.layer(v.layer)).is_some_and(|l| l.props.find_group(v.mask).is_some()) }); - if loose && p.get("layers").is_none() { - return s.execute("mask.deleteVertices", json!({})); + if loose && no_layers { + return Ok(Some(s.execute("mask.deleteVertices", json!({}))?)); } // Puppet pins selected → delete the pins (not their layer). - if p.get("layers").is_none() && !super::puppet::selected_pins(s).is_empty() { - return s.execute("puppet.removePin", json!({})); + if no_layers && !super::puppet::selected_pins(s).is_empty() { + return Ok(Some(s.execute("puppet.removePin", json!({}))?)); } // Effects selected → remove them. - if p.get("layers").is_none() && !super::effect::selected_effects(s).is_empty() { - return s.execute("effect.remove", json!({})); + if no_layers && !super::effect::selected_effects(s).is_empty() { + return Ok(Some(s.execute("effect.remove", json!({}))?)); } // Masks, shape items, text animators or trackers selected → remove them. - if p.get("layers").is_none() - && let Some(n) = super::prop_groups::remove_selected(s)? - { - return Ok(n); + if no_layers && let Some(n) = super::prop_groups::remove_selected(s)? { + return Ok(Some(n)); } - let (cid, ids) = layers_p(s, p)?; - let ids = super::unlocked(s, cid, ids, "edit.clear")?; + Ok(None) +} + +/// Removes layers `ids` from comp `cid` in one undoable step, and clears what referred to them. +fn clear_layers(s: &mut Session, cid: ItemId, ids: &[LayerId]) -> Result<()> { // Children of deleted layers are unparented where they are (like After Effects). let comp = s.project.comp(cid).ok_or(crate::EngineError::NoComp)?; let orphans: Vec = comp.layers.iter().filter(|l| !ids.contains(&l.id) && l.parent.is_some_and(|p| ids.contains(&p))).map(|l| l.id).collect(); @@ -226,11 +278,7 @@ fn delete(s: &mut Session, p: &Value) -> Result { st.selected_props.clear(); st.selected_keys.clear(); Ok(()) - })?; - // A removed layer may have been the edited one: end that session before another command - // can reuse it. Undo restores a layer as a new editor, never by reviving the old IME session. - s.sanitize_state(); - Ok(json!(ids.len())) + }) } fn clear_clipboards(s: &mut Session) { @@ -238,8 +286,10 @@ fn clear_clipboards(s: &mut Session) { s.state.key_clipboard.clear(); s.state.effect_clipboard.clear(); s.state.contents_clipboard.clear(); + s.state.mask_clipboard.clear(); s.state.link_clipboard = None; s.state.value_clipboard.clear(); + s.state.keyframe_text = None; s.state.clip_is_keys = false; } @@ -263,6 +313,16 @@ fn copy(s: &mut Session, p: &Value) -> Result { if p.get("layers").is_none() && !super::effect::selected_effects(s).is_empty() { return s.execute("effect.copy", json!({})); } + // Masks selected → copy them (not their layer), for Paste onto other layers (#2091), unless + // a layer is named explicitly. + if p.get("layers").is_none() && p.get("layer").is_none_or(Value::is_null) { + let masks = super::prop_groups::copy_masks(s); + if !masks.is_empty() { + clear_clipboards(s); + s.state.mask_clipboard = masks; + return Ok(json!({"masks": s.state.mask_clipboard.len()})); + } + } // Shape items selected in a shape layer's Contents → copy them (not their layer). if p.get("layers").is_none() { let items = super::prop_groups::copy_contents(s); @@ -306,10 +366,12 @@ fn copy_properties(s: &mut Session) -> Result> { } let mut keys = s.state.selected_keys.clone(); let mut values = vec![]; + let mut statics = vec![]; for (lid, uid) in &leaves { let Some(l) = comp.layer(*lid) else { continue }; let Some(pr) = l.props.find(*uid) else { continue }; if pr.keys.is_empty() { + statics.push((*lid, *uid)); if let Some(path) = l.props.match_path_of(*uid) && !values.iter().any(|(p, _)| *p == path) { @@ -319,6 +381,7 @@ fn copy_properties(s: &mut Session) -> Result> { keys.extend(pr.keys.iter().map(|k| KeyRef { layer: *lid, prop: *uid, time: k.time })); } } + let text = crate::keyframe_text::text(&s.project, comp, &keys, &statics); clear_clipboards(s); let mut n = 0; if !keys.is_empty() { @@ -327,6 +390,7 @@ fn copy_properties(s: &mut Session) -> Result> { s.state.selected_keys = selected; n = r?.as_u64().unwrap_or(0); } + s.state.keyframe_text = text; let v = values.len(); s.state.value_clipboard = values; // Keys alone answer as `keys.copy` does (their count). @@ -545,6 +609,9 @@ fn paste(s: &mut Session, p: &Value) -> Result { if !s.state.contents_clipboard.is_empty() { return super::prop_groups::paste_contents(s, p); } + if !s.state.mask_clipboard.is_empty() { + return super::prop_groups::paste_masks(s, p); + } let cid = super::comp_id(s, p)?; let clip = s.state.clipboard.clone(); let new = s.edit("Paste", None, |proj, st| { @@ -652,7 +719,7 @@ fn label(s: &mut Session, p: &Value) -> Result { } } if let Some(comp) = proj.comp_mut(cid) { - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.label = lab; } } @@ -824,10 +891,12 @@ pub(crate) fn disk_stats(s: &mut Session, _: &Value) -> Result { "layers": dc.keys(effectcraft_render::disk_cache::Kind::Layer).len(), "bytes": st.bytes, "maxBytes": st.max_bytes, + "limitBytes": st.limit_bytes, "hits": st.hits, "misses": st.misses, "writes": st.writes, "evictions": st.evictions, + "failed": st.failed, })) } @@ -933,7 +1002,15 @@ pub fn specs() -> Vec { paste_reversed ), cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{layers?}", layers_or_keys, delete), - cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{layers?} (effects or shape items when they are selected)", has_layers, duplicate), + cmd!( + "edit.duplicate", + "Duplicate", + ["Edit"], + Some("Cmd+D"), + "{layers?} (without layers: during a text edit the edited layer, else the selected effects, shape items or text animators, else the selected layers)", + layers_or_text_edit, + duplicate + ), cmd!("edit.splitLayer", "Split Layer", ["Edit"], Some("Cmd+Shift+D"), "{layers?}", has_layers, split), cmd!("edit.liftWorkArea", "Lift Work Area", ["Edit"], None, "{layers?}", has_comp, lift), cmd!("edit.extractWorkArea", "Extract Work Area", ["Edit"], None, "{layers?}", has_comp, extract), @@ -1003,6 +1080,11 @@ fn layers_or_keys(s: &Session) -> std::result::Result<(), String> { if !s.state.selected_keys.is_empty() || !s.state.selected_vertices.is_empty() { super::has_comp(s) } else { has_layers(s) } } +/// Duplicate's unnamed target: the edited layer during a text edit, else the selection. +fn layers_or_text_edit(s: &Session) -> std::result::Result<(), String> { + if s.state.text_edit.is_some() { super::has_comp(s) } else { has_layers(s) } +} + fn always_ok(_: &Session) -> std::result::Result<(), String> { Ok(()) } diff --git a/crates/engine/src/commands/effect.rs b/crates/engine/src/cmd/effect.rs similarity index 69% rename from crates/engine/src/commands/effect.rs rename to crates/engine/src/cmd/effect.rs index 4f263b6fb..f41ba3f9c 100644 --- a/crates/engine/src/commands/effect.rs +++ b/crates/engine/src/cmd/effect.rs @@ -136,7 +136,16 @@ fn warning(s: &mut Session, p: &Value) -> Result { let layer = comp.layer(lid).ok_or(EngineError::NoComp)?; let g = layer.effects().and_then(|fx| fx.groups().find(|g| g.uid == uid)).ok_or_else(|| bad(cmd, "no such effect"))?; let t = super::time_p(s, p, Some(comp)); - let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: t, + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; Ok(json!({"effect": uid, "warning": effectcraft_effects::warning(g, &mut |pr| ctx.value(layer, pr))})) } @@ -160,7 +169,16 @@ fn pick_color(s: &mut Session, p: &Value) -> Result { .filter(|pr| pr.ui == effectcraft_project::ParamUi::Color) .ok_or_else(|| bad(cmd, "not a colour parameter of this effect"))?; let t = super::time_p(s, p, Some(comp)); - let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: t, + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let mut r = effectcraft_render::Renderer::new(&s.project, &*s.footage, effectcraft_render::RenderOpts::default()); r.expr = s.expr.as_deref(); r.cache = Some(&s.layer_cache); @@ -190,6 +208,7 @@ fn pick_color(s: &mut Session, p: &Value) -> Result { /// Cryptomatte layer, with the aux channels (for the manifest) and the effect's Selection. struct CryptoHit { at: (effectcraft_project::ItemId, LayerId, Tick), + probe: std::sync::Arc, aux: std::sync::Arc, layer: String, hash: u32, @@ -210,13 +229,144 @@ impl CryptoHit { } } -/// The ID under `{x, y}` (effect space, which is layer space on footage and precomp layers) for -/// the Cryptomatte effect `{layer?, effect?}`, from the aux channels its render reads. The point -/// snaps to the centre of the pixel the effect's input buffer samples in a render at `scale` -/// (the viewer's resolution). -fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result> { +/// What a pick at one frame needs besides the point: the aux channels the Cryptomatte effect +/// reads, its layer, and the effect's input buffer grid (scale, offset). Building it can render +/// (a precomp's ID pass, the effects before the Cryptomatte effect); a point lookup in it is a +/// few array reads. The session keeps the last one (see [`Session::crypto_probe_job`]). +pub struct CryptoProbe { + key: ProbeKey, + grid: std::result::Result, +} + +#[derive(Clone)] +struct ProbeGrid { + aux: std::sync::Arc, + layer: String, + scale: f64, + offset: [f64; 2], +} + +/// What a probe depends on: the project revision, the effect (and its index), the frame, the +/// scale and the render prefs ([`Session::render_opts`]: nested switches, draft shadows). +#[derive(Clone, Copy, PartialEq, Debug)] +struct ProbeKey { + revision: u64, + at: (effectcraft_project::ItemId, LayerId, Uid, usize), + frame: ProbeFrame, + scale: f64, + prefs: (bool, bool), +} + +/// The frame a probe reads. On footage whose masks and effects up to the Cryptomatte effect +/// don't change over time, its source frame: a still has one, held or remapped frames share +/// theirs. Otherwise the comp time. +#[derive(Clone, Copy, PartialEq, Debug)] +enum ProbeFrame { + Source(i64), + Comp(Tick), +} + +/// The session's kept probe, and the generation of the latest request: a build stores its +/// result only while no newer request or edit came since. +#[derive(Default)] +pub struct CryptoProbeSlot { + probe: Option>, + want: Option, + generation: u64, + /// Jobs run (each on its thread), and those that built (not cancelled before they began). + jobs: u64, + builds: u64, +} + +impl CryptoProbeSlot { + /// Drop the kept probe and cancel builds in flight. + pub(crate) fn clear(&mut self) { + (self.probe, self.want) = (None, None); + self.generation += 1; + } + pub(crate) fn kept(&self) -> bool { + self.probe.is_some() + } + pub(crate) fn builds(&self) -> (u64, u64) { + (self.jobs, self.builds) + } +} + +/// A probe built off the UI thread into the session's slot. +pub type CryptoProbeJob = Box; + +const NO_CRYPTO: &str = "this layer has no Cryptomatte channels (a multi-layer OpenEXR with Cryptomatte, or a 3D precomp)"; + +impl CryptoProbe { + /// `None` when `live` turns false between its stages (a newer request or an edit since): + /// a cancelled build stops early and lets go of what it read. + fn build(r: &effectcraft_render::Renderer, key: ProbeKey, time: Tick, live: &dyn Fn() -> bool) -> Option { + let (cid, lid, _, pos) = key.at; + let build = || -> std::result::Result, String> { + let comp = r.project.comp(cid).ok_or("no composition")?; + let layer = comp.layer(lid).ok_or("no such layer")?; + let g = layer.effects().and_then(|fx| fx.groups().nth(pos)).ok_or("no such effect")?; + let ctx = effectcraft_render::EvalCtx { + project: r.project, + comp_id: cid, + comp, + time, + expr: r.expr, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; + let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)); + let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset"); + let aux = r.layer_aux(&ctx, layer).ok_or(NO_CRYPTO)?; + if !live() { + return Ok(None); + } + let layer_name = aux.resolve_crypto_layer(want).ok_or(NO_CRYPTO)?; + // The grid of the effect's input buffer (footage keeps native pixels from 3/4 scale + // up, proxies have their own size, earlier effects can pad it), rendered and cached. + if !live() { + return Ok(None); + } + let b = r.layer_input(&ctx, layer, pos).ok_or("this layer has no pixels")?; + Ok(Some(ProbeGrid { aux, layer: layer_name, scale: b.scale, offset: b.offset })) + }; + // On another thread no command guard runs: a panic becomes the probe's error. + let grid = crate::guard::guarded("Reading the Cryptomatte IDs", || Ok(build())).unwrap_or_else(|e| Err(e.to_string())); + Some(CryptoProbe { key, grid: grid.transpose()? }) + } +} + +/// Whether a property in `g` other than `skip` changes over time (keyframes or an expression). +fn moves(g: &PropGroup, skip: Option) -> bool { + g.children.iter().any(|c| match c { + effectcraft_project::Node::Prop(p) => Some(p.uid) != skip && (p.is_animated() || p.has_expression()), + effectcraft_project::Node::Group(g) => moves(g, skip), + }) +} + +/// [`ProbeFrame`] of the effect at index `pos` of `layer`. +fn probe_frame(ctx: &effectcraft_render::EvalCtx, layer: &Layer, pos: usize) -> ProbeFrame { + let comp = ProbeFrame::Comp(ctx.time); + let effectcraft_project::LayerSource::Footage { item } = layer.source else { return comp }; + let Some(effectcraft_project::ItemKind::Footage(f)) = ctx.project.item(item).map(|i| &i.kind) else { return comp }; + let fx = layer.effects().into_iter().flat_map(|fx| fx.groups().take(pos + 1)); + let time_fx = |g: &PropGroup| matches!(&g.kind, GroupKind::Effect { effect } if effect.starts_with("ec.time.")); + let still = f.kind == effectcraft_project::FootageKind::Still; + let blend = !still && ctx.comp.enable_frame_blending && layer.switches.frame_blend != effectcraft_project::FrameBlend::Off; + // The Cryptomatte effect's own Selection isn't read by the probe: animated, it keeps it. + let own = |i: usize, g: &PropGroup| if i == pos { g.prop("selection").map(|p| p.uid) } else { None }; + if blend || layer.masks().is_some_and(|m| moves(m, None)) || fx.enumerate().any(|(i, g)| moves(g, own(i, g)) || time_fx(g)) { + return comp; + } + ProbeFrame::Source(if still { 0 } else { effectcraft_render::frame_position(ctx.source_time(layer), f.frame_rate).0 }) +} + +/// The Cryptomatte effect `{layer?, effect?, scale?, time?}` names: its probe key, the comp +/// time, its layer, group and Selection property. +#[allow(clippy::type_complexity)] +fn probe_key<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<(ProbeKey, Tick, &'a Layer, &'a PropGroup, &'a effectcraft_project::Property)> { let (cid, lid, fx_uid) = find_fx(s, p, cmd)?; - let (Some(x), Some(y)) = (f_p(p, "x"), f_p(p, "y")) else { return Err(bad(cmd, "missing `x` / `y` (layer pixels)")) }; let scale = f_p(p, "scale").unwrap_or(1.0); if !(scale.is_finite() && scale > 0.0 && scale <= 1.0) { return Err(bad(cmd, "`scale` is the preview's resolution factor, above 0 and at most 1")); @@ -227,30 +377,193 @@ fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result> { let pos = fx.groups().position(|g| g.uid == fx_uid).ok_or_else(|| bad(cmd, "no such effect"))?; let g = fx.groups().nth(pos).ok_or_else(|| bad(cmd, "no such effect"))?; let prop = g.prop("selection").filter(|_| g.match_id == "ec.3d.cryptomatte").ok_or_else(|| bad(cmd, "not a Cryptomatte effect"))?; - let t = super::time_p(s, p, Some(comp)); - let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; - let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)); - let r = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() }); - let aux = r.layer_aux(&ctx, layer); - let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset"); - let Some((aux, layer_name)) = aux.and_then(|a| a.resolve_crypto_layer(want).map(|l| (a, l))) else { - return Err(bad(cmd, "this layer has no Cryptomatte channels (a multi-layer OpenEXR with Cryptomatte, or a 3D precomp)")); + let time = super::time_p(s, p, Some(comp)); + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time, + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], }; - // The grid of the effect's input buffer (footage keeps native pixels from 3/4 scale up, - // proxies have their own size, earlier effects can pad it), rendered and cached. - let b = r.layer_input(&ctx, layer, pos).ok_or_else(|| bad(cmd, "this layer has no pixels"))?; + let frame = probe_frame(&ctx, layer, pos); + let o = s.render_opts(Default::default()); + let prefs = (o.nested_switches, o.draft_shadows); + Ok((ProbeKey { revision: s.revision, at: (cid, lid, fx_uid, pos), frame, scale, prefs }, time, layer, g, prop)) +} + +/// The kept probe if it answers for the current revision, for [`keep_crypto_probe`] (taken +/// before an undo, redo or history jump). +pub(crate) fn kept_crypto_probe(s: &Session) -> Option> { + s.crypto_probe.lock().ok()?.probe.clone().filter(|p| p.key.revision == s.revision) +} + +/// After an undo, redo or history jump from `before` (which dropped the probe): `kept`, the +/// probe from before, still answers when only Cryptomatte Selections changed, which the probe +/// doesn't read (undoing a pick, as the pick itself keeps it). +pub(crate) fn keep_crypto_probe(s: &Session, kept: Option>, before: &effectcraft_project::Project) { + let Some(p) = kept else { return }; + if !only_crypto_selections_differ(before, &s.project) { + return; + } + if let Ok(mut slot) = s.crypto_probe.lock() + && slot.probe.is_none() + { + slot.probe = Some(std::sync::Arc::new(CryptoProbe { key: ProbeKey { revision: s.revision, ..p.key }, grid: p.grid.clone() })); + } +} + +/// Whether projects `a` and `b` differ at most in the Selections of Cryptomatte effects. +fn only_crypto_selections_differ(a: &effectcraft_project::Project, b: &effectcraft_project::Project) -> bool { + let mut x = a.clone(); + for (cid, it) in x.items.iter_mut() { + let effectcraft_project::ItemKind::Comp(c) = &mut it.kind else { continue }; + let sels: Vec<(LayerId, Uid)> = c + .layers + .iter() + .flat_map(|l| { + l.effects() + .into_iter() + .flat_map(|fx| fx.groups()) + .filter(|g| g.match_id == "ec.3d.cryptomatte") + .filter_map(move |g| g.get("selection").map(|p| (l.id, p.uid))) + }) + .collect(); + if sels.is_empty() { + continue; + } + let Some(bc) = b.comp(*cid) else { return false }; + let c = std::sync::Arc::make_mut(c); + for (lid, uid) in sels { + let Some(src) = bc.layer(lid).and_then(|l| l.props.find(uid)) else { return false }; + if let Some(dst) = c.layer_mut(lid).and_then(|l| l.props.find_mut(uid)) { + *dst = src.clone(); + } + } + } + x.same_content(b) +} + +/// The session's probe when it has `key`'s. +fn cached_probe(s: &Session, key: ProbeKey) -> Option> { + s.crypto_probe.lock().ok()?.probe.clone().filter(|p| p.key == key) +} + +/// [`Session::crypto_probe_ready`]. +pub(crate) fn crypto_probe_ready(s: &Session, p: &Value) -> Result { + let (key, ..) = probe_key(s, p, "effect.cryptomatteAt")?; + Ok(cached_probe(s, key).is_some()) +} + +/// [`Session::crypto_probe_job`]. +pub(crate) fn crypto_probe_job(s: &Session, p: &Value) -> Result> { + let (key, time, ..) = probe_key(s, p, "effect.cryptomatteAt")?; + let generation = { + let mut slot = s.crypto_probe.lock().map_err(|_| EngineError::Other("the Cryptomatte probe is unavailable".into()))?; + if slot.probe.as_ref().is_some_and(|p| p.key == key) { + return Ok(None); + } + // A request for another probe cancels the one in flight. + if slot.want != Some(key) { + (slot.want, slot.generation) = (Some(key), slot.generation + 1); + } + slot.generation + }; + let (project, footage, expr, cache, accel, slot) = + (s.project.clone(), s.footage.clone(), s.expr.clone(), s.layer_cache.clone(), s.accel.clone(), s.crypto_probe.clone()); + let opts = s.render_opts(effectcraft_render::RenderOpts { scale: key.scale, ..Default::default() }); + // The slot while this is still the newest request. + fn current(slot: &std::sync::Mutex, generation: u64) -> Option> { + slot.lock().ok().filter(|s| s.generation == generation) + } + Ok(Some(Box::new(move || { + match slot.lock() { + Ok(mut s) if s.generation == generation => (s.jobs, s.builds) = (s.jobs + 1, s.builds + 1), + Ok(mut s) => return s.jobs += 1, + Err(_) => return, + } + let mut r = effectcraft_render::Renderer::new(&project, footage.as_ref(), opts); + r.expr = expr.as_deref(); + r.cache = Some(&cache); + r.accel = accel.as_deref(); + // Checked between its stages too: a superseded build stops at the next one. + let Some(probe) = CryptoProbe::build(&r, key, time, &|| current(&slot, generation).is_some()) else { return }; + let probe = std::sync::Arc::new(probe); + // Only the newest request's result, and never over a newer probe. + if let Some(mut s) = current(&slot, generation) + && s.probe.as_ref().is_none_or(|p| p.key.revision <= key.revision) + { + s.probe = Some(probe); + } + }))) +} + +/// What a pick finds at a point. +enum Hit { + /// `probeOnly` and the probe isn't built yet. + Pending, + Nothing, + Id(CryptoHit), +} + +/// The ID under `{x, y}` (effect space, which is layer space on footage and precomp layers) for +/// the Cryptomatte effect `{layer?, effect?}`, from the aux channels its render reads. The point +/// snaps to the centre of the pixel the effect's input buffer samples in a render at `scale` +/// (the viewer's resolution). Without a kept probe it builds one, unless `probeOnly`. +fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result { + let (key, time, layer, g, prop) = probe_key(s, p, cmd)?; + let (Some(x), Some(y)) = (f_p(p, "x"), f_p(p, "y")) else { return Err(bad(cmd, "missing `x` / `y` (layer pixels)")) }; + let probe = match cached_probe(s, key) { + Some(probe) => probe, + None if b_p(p, "probeOnly").unwrap_or(false) => return Ok(Hit::Pending), + None => { + let r = s.renderer(effectcraft_render::RenderOpts { scale: key.scale, ..Default::default() }); + if let Ok(mut slot) = s.crypto_probe.lock() { + slot.clear(); + slot.builds += 1; + } + // (Never cancelled here: nothing else runs on this thread.) + let Some(probe) = CryptoProbe::build(&r, key, time, &|| true).map(std::sync::Arc::new) else { return Ok(Hit::Pending) }; + if let Ok(mut slot) = s.crypto_probe.lock() { + slot.probe = Some(probe.clone()); + } + probe + } + }; + let b = probe.grid.as_ref().map_err(|e| bad(cmd, e.clone()))?; let (bs, o) = (b.scale, b.offset); // The centre of its pixel under the point, at the aux point the effect reads there. let [ax, ay] = effectcraft_effects::aux_point(bs, o, [(x * bs + o[0]).floor() + 0.5, (y * bs + o[1]).floor() + 0.5]); - let Some(hash) = aux.index_at(ax, ay).and_then(|i| aux.crypto_id_at(&layer_name, i)) else { return Ok(None) }; - let (selection, driven) = (params.s("selection").to_string(), prop.expr.as_ref().is_some_and(|e| e.enabled)); - Ok(Some(CryptoHit { at: (cid, lid, t), aux, layer: layer_name, hash, selection, prop: prop.uid, driven })) + let Some(hash) = b.aux.index_at(ax, ay).and_then(|i| b.aux.crypto_id_at(&b.layer, i)) else { return Ok(Hit::Nothing) }; + let (cid, lid, ..) = key.at; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time, + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; + let selection = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)).s("selection").to_string(); + let driven = prop.expr.as_ref().is_some_and(|e| e.enabled); + let (aux, layer_name) = (b.aux.clone(), b.layer.clone()); + Ok(Hit::Id(CryptoHit { at: (cid, lid, time), probe: probe.clone(), aux, layer: layer_name, hash, selection, prop: prop.uid, driven })) } /// `effect.cryptomatteAt`: the ID under a point, for the viewer's hover hint. The first call per -/// frame renders the effect's input to learn its pixel grid (cached afterwards). +/// frame renders the effect's input to learn its pixel grid (kept afterwards); with `probeOnly` +/// it answers `{pending: true}` instead. fn crypto_at(s: &mut Session, p: &Value) -> Result { - Ok(crypto_hit(s, p, "effect.cryptomatteAt")?.map_or(Value::Null, |h| h.reply(&h.selection))) + Ok(match crypto_hit(s, p, "effect.cryptomatteAt")? { + Hit::Pending => json!({"pending": true}), + Hit::Nothing => Value::Null, + Hit::Id(h) => h.reply(&h.selection), + }) } /// The Cryptomatte eyedropper: replace the effect's Selection with the ID under a point, add it @@ -264,7 +577,11 @@ fn pick_crypto(s: &mut Session, p: &Value) -> Result { "remove" => PickMode::Remove, m => return Err(bad(cmd, format!("unknown `mode` {m:?} (replace, add or remove)"))), }; - let h = crypto_hit(s, p, cmd)?.ok_or_else(|| bad(cmd, "no Cryptomatte ID there (empty or outside the layer)"))?; + let h = match crypto_hit(s, p, cmd)? { + Hit::Id(h) => h, + Hit::Pending => return Ok(json!({"pending": true, "changed": false})), + Hit::Nothing => return Err(bad(cmd, "no Cryptomatte ID there (empty or outside the layer)")), + }; if h.driven { return Err(bad(cmd, "the Selection is driven by an expression; disable it to pick")); } @@ -274,6 +591,19 @@ fn pick_crypto(s: &mut Session, p: &Value) -> Result { if changed { let (cid, lid, t) = h.at; s.execute("prop.set", json!({"comp": cid.0, "layer": lid.0, "prop": h.prop, "value": sel, "time": t.seconds(), "timeBase": "comp"}))?; + // The step says what made it (Edit ▸ Undo Pick Cryptomatte, the History panel), not + // "Change Property" like any other edit. (A changed Selection is always a new step.) + if let Some(step) = s.history.undo.last_mut() { + step.0 = "Pick Cryptomatte".into(); + } + // Only the Selection changed, which the probe doesn't read: the next hover keeps it. + // (The edit dropped it; a probe built since is newer.) + if let Ok(mut slot) = s.crypto_probe.lock() + && slot.probe.is_none() + { + let key = ProbeKey { revision: s.revision, ..h.probe.key }; + slot.probe = Some(std::sync::Arc::new(CryptoProbe { key, grid: h.probe.grid.clone() })); + } } let mut out = h.reply(&sel); out["selection"] = json!(sel); @@ -415,7 +745,7 @@ fn effects_p(s: &Session, p: &Value, cmd: &str) -> Result> { Ok(sel) } -fn effect_time_error(cmd: &str) -> EngineError { +pub(crate) fn effect_time_error(cmd: &str) -> EngineError { bad(cmd, "an effect keyframe converts outside the supported time range") } @@ -451,7 +781,7 @@ fn effect_time(layer: &Layer, time: Tick, to_comp: bool, cmd: &str) -> Result Result) -> Result<()> { +pub(crate) fn retime_effect(group: &mut PropGroup, scale: f64, cmd: &str, mut time: impl FnMut(Tick) -> Result) -> Result<()> { let mut result = Ok(()); group.walk_mut(&mut |pr| { if result.is_err() { @@ -519,7 +849,9 @@ fn copy(s: &mut Session, p: &Value) -> Result { } s.state.clipboard.clear(); s.state.key_clipboard.clear(); + s.state.keyframe_text = None; s.state.contents_clipboard.clear(); + s.state.mask_clipboard.clear(); s.state.link_clipboard = None; s.state.clip_is_keys = false; let n = groups.len(); @@ -669,8 +1001,7 @@ fn list(_: &mut Session, p: &Value) -> Result { let mut out = json!({"id": p.id, "name": p.name, "type": p.default.kind_name(), "default": p.default.to_json()}); match &p.ui { effectcraft_project::ParamUi::Popup { options } => { - // Engine/MCP popup values are zero-based; JSX exposes one-based values. - out["options"] = json!(options.iter().enumerate().map(|(value, label)| json!({"value": value, "label": label})).collect::>()); + out["options"] = super::popup_options(options); } effectcraft_project::ParamUi::Slider { min, max, slider_min, slider_max, decimals } => { out["min"] = json!(min); @@ -732,14 +1063,14 @@ pub fn specs() -> Vec { "Pick Cryptomatte", [], None, - "{layer?, effect?: index|uid|name, x, y (effect space), mode?: replace|add|remove (default replace), scale? (preview resolution, default 1), time?} → {name, hash, layer, selection, changed, selected} — edits the Cryptomatte effect's Selection with the ID under the point", + "{layer?, effect?: index|uid|name, x, y (effect space), mode?: replace|add|remove (default replace), scale? (preview resolution, default 1), time?, probeOnly?} → {name, hash, layer, selection, changed, selected} | {pending} — edits the Cryptomatte effect's Selection with the ID under the point", has_layers, pick_crypto ), crate::query!( "effect.cryptomatteAt", "Cryptomatte At", - "{layer?, effect?: index|uid|name, x, y (effect space), scale?, time?} → {name, hash, layer, selected} | null — the Cryptomatte ID under the point", + "{layer?, effect?: index|uid|name, x, y (effect space), scale?, time?, probeOnly?: bool (don't build the probe)} → {name, hash, layer, selected} | {pending} | null — the Cryptomatte ID under the point", crypto_at ), cmd!("effect.applyLast", "Last Effect", ["Effect"], Some("Cmd+Alt+Shift+E"), "{layers?}", has_layers, last), diff --git a/crates/engine/src/cmd/enabled.rs b/crates/engine/src/cmd/enabled.rs new file mode 100644 index 000000000..fb28b60c4 --- /dev/null +++ b/crates/engine/src/cmd/enabled.rs @@ -0,0 +1,38 @@ +use super::{EngineError, Result, Session, Value}; + +// ---------- enablement ---------- + +pub fn always(_: &Session) -> std::result::Result<(), String> { + Ok(()) +} + +pub(crate) fn has_comp(s: &Session) -> std::result::Result<(), String> { + s.active_comp().map(|_| ()).ok_or_else(|| "no composition is open".into()) +} + +pub(crate) fn has_layers(s: &Session) -> std::result::Result<(), String> { + has_comp(s)?; + if s.state.selected_layers.is_empty() { Err("select a layer first".into()) } else { Ok(()) } +} + +pub(crate) fn has_keys(s: &Session) -> std::result::Result<(), String> { + has_comp(s)?; + if s.state.selected_keys.is_empty() { Err("select keyframes first".into()) } else { Ok(()) } +} + +pub(crate) fn has_project_selection(s: &Session) -> std::result::Result<(), String> { + if s.state.project_selection.is_empty() { Err("select an item in the Project panel first".into()) } else { Ok(()) } +} + +/// Disabled-command enablement (menu entries whose implementation lands with another milestone; +/// none are left, kept for future stubs). +#[allow(dead_code)] +pub(crate) fn not_yet(_: &Session) -> std::result::Result<(), String> { + Err("not available yet in EffectCraft".into()) +} + +/// `run` of a not-yet-available command. +#[allow(dead_code)] +pub(crate) fn not_yet_run(_: &mut Session, _: &Value) -> Result { + Err(EngineError::Other("not available yet in EffectCraft".into())) +} diff --git a/crates/engine/src/commands/essential.rs b/crates/engine/src/cmd/essential.rs similarity index 99% rename from crates/engine/src/commands/essential.rs rename to crates/engine/src/cmd/essential.rs index cb6702660..419bd7316 100644 --- a/crates/engine/src/commands/essential.rs +++ b/crates/engine/src/cmd/essential.rs @@ -807,6 +807,7 @@ fn export_template(s: &mut Session, p: &Value) -> Result { const C: &str = "essential.exportTemplate"; let cid = eg_comp(s, p)?; let path = str_p(p, "path").ok_or_else(|| bad(C, "missing `path` (.ectemplate)"))?.to_string(); + s.refuse_export(&path)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let eg = comp.essential.clone().filter(|e| !e.controls.is_empty()).ok_or_else(|| bad(C, "the composition has no Essential Graphics controls"))?; let comp_name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default(); @@ -868,7 +869,7 @@ fn export_template(s: &mut Session, p: &Value) -> Result { entries.insert(0, ("project.ecproj".into(), sub.to_json().into_bytes())); entries.insert(0, ("manifest.json".into(), serde_json::to_vec_pretty(&manifest).unwrap_or_default())); let bytes = effectcraft_lottie::zip::store(&entries); - s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; + s.write_output(&path, &bytes)?; s.toast(format!("Exported template “{name}” to {path}")); Ok(json!({"path": path, "name": name, "bytes": bytes.len(), "controls": eg.flat().len(), "items": deps.len()})) } diff --git a/crates/engine/src/commands/expr_tools.rs b/crates/engine/src/cmd/expr_tools.rs similarity index 98% rename from crates/engine/src/commands/expr_tools.rs rename to crates/engine/src/cmd/expr_tools.rs index fae74a5b5..cb39077af 100644 --- a/crates/engine/src/commands/expr_tools.rs +++ b/crates/engine/src/cmd/expr_tools.rs @@ -163,6 +163,8 @@ pub fn language_menu() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> ("easeIn(t, tMin, tMax, value1, value2)", "easeIn(t, tMin, tMax, value1, value2)"), ("easeOut(t, value1, value2)", "easeOut(t, value1, value2)"), ("easeOut(t, tMin, tMax, value1, value2)", "easeOut(t, tMin, tMax, value1, value2)"), + ("bezier(t, value1, value2, [x1, y1, x2, y2])", "bezier(t, value1, value2, [x1, y1, x2, y2])"), + ("bezier(t, tMin, tMax, value1, value2, [x1, y1, x2, y2])", "bezier(t, tMin, tMax, value1, value2, [x1, y1, x2, y2])"), ], ), ( diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/cmd/file.rs similarity index 90% rename from crates/engine/src/commands/file.rs rename to crates/engine/src/cmd/file.rs index b3922c7d6..e1f58a1cc 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -87,7 +87,8 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result { /// project opened from an XML copy saves as XML again). Fails, writing nothing, when the project /// wouldn't read back. pub(crate) fn file_text(s: &Session, path: &str) -> Result { - let json = s.project.to_file_json_with_view(&s.saved_view())?; + let compact = s.prefs.project.compact_project_files && !crate::xml_project::is_xml_path(path); + let json = s.project.to_file_json_with_view(&s.saved_view(), compact)?; if crate::xml_project::is_xml_path(path) { crate::xml_project::to_xml(&json).map_err(EngineError::Other) } else { Ok(json) } } @@ -124,7 +125,8 @@ fn revert(s: &mut Session, _: &Value) -> Result { } /// File ▸ Import; with `addToComp` (files dropped on the Timeline or the Composition viewer) they -/// also become layers of the active comp ([`add_to_comp`]). +/// also become layers of the active comp ([`add_to_comp`]); with `newComp` (the empty viewer's +/// New Composition From Footage) they become one new composition instead ([`new_comp`]). /// /// With `background` (files dropped on the window or picked in the Import dialog) the files are /// probed on a background thread, so decoding a slow video doesn't freeze the app: the job shows @@ -174,10 +176,10 @@ fn import_cmd(s: &mut Session, p: &Value) -> Result { Ok(r) } -/// After an import (its result `r`): with `addToComp`, the new items become layers of `target`; -/// what couldn't be added joins the result's errors. +/// After an import (its result `r`): with `newComp`, the new items become one new composition; +/// otherwise with `addToComp`, layers of `target`. What couldn't be added joins the result's errors. fn finish_import(s: &mut Session, r: &mut Value, target: Option, p: &Value) { - let errors = add_to_comp(s, r, target, p); + let errors = if b_p(p, "newComp").unwrap_or(false) { new_comp(s, r) } else { add_to_comp(s, r, target, p) }; if !errors.is_empty() && let Some(e) = r.get_mut("errors").and_then(Value::as_array_mut) { @@ -191,18 +193,7 @@ fn finish_import(s: &mut Session, r: &mut Value, target: Option, p: &Val /// Project panel) and the other footage. `target` stays open. Returns what couldn't be added. pub(crate) fn add_to_comp(s: &mut Session, r: &Value, target: Option, p: &Value) -> Vec { let Some(cid) = target.filter(|_| p.get("addToComp").and_then(Value::as_bool).unwrap_or(false)) else { return vec![] }; - let ids = |k: &str| r.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).collect::>(); - let comps = ids("comps"); - let in_comps: std::collections::HashSet = comps - .iter() - .filter_map(|c| s.project.comp(ItemId(*c))) - .flat_map(|c| c.layers.iter()) - .filter_map(|l| match l.source { - LayerSource::Footage { item } | LayerSource::Comp { item } | LayerSource::Solid { item } => Some(item.0), - _ => None, - }) - .collect(); - let add: Vec = comps.iter().copied().chain(ids("items").into_iter().filter(|i| !in_comps.contains(i))).collect(); + let add = imported(s, r); let mut errors = vec![]; for (k, item) in add.into_iter().enumerate() { let mut params = json!({"comp": cid.0, "item": item, "time": p.get("time"), "position": p.get("position")}); @@ -219,6 +210,55 @@ pub(crate) fn add_to_comp(s: &mut Session, r: &Value, target: Option, p: errors } +/// What an import (its result `r`) made that belongs in a comp: the comps of layered files +/// (their layers' footage, in their precomps too, stays in the Project panel), then the other items, +/// without those comps again or the files' folders. +fn imported(s: &Session, r: &Value) -> Vec { + let ids = |k: &str| r.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).collect::>(); + let comps = ids("comps"); + let mut in_comps = std::collections::HashSet::new(); + let mut todo = comps.clone(); + while let Some(c) = todo.pop() { + for l in s.project.comp(ItemId(c)).into_iter().flat_map(|c| c.layers.iter()) { + match l.source { + LayerSource::Comp { item } if in_comps.insert(item.0) => todo.push(item.0), + LayerSource::Footage { item } | LayerSource::Solid { item } => { + in_comps.insert(item.0); + } + _ => {} + } + } + } + let rest = ids("items").into_iter().filter(|i| !in_comps.contains(i) && !comps.contains(i) && !s.project.item(ItemId(*i)).is_some_and(|it| it.is_folder())); + comps.iter().copied().chain(rest).collect() +} + +/// With `newComp`, make one composition of the footage and solids an import (its result `r`) +/// made, as New Comp from Selection does with `single` (first item on top, its settings). A comp +/// the import made (a layered file) already is the composition, so it stays out, as does data +/// footage; none left, no new comp. Returns what went wrong. +fn new_comp(s: &mut Session, r: &Value) -> Vec { + let items: Vec = imported(s, r) + .into_iter() + .map(ItemId) + .filter(|i| { + s.project.item(*i).is_some_and(|it| match &it.kind { + ItemKind::Footage(f) => f.kind != FootageKind::Data, + ItemKind::Solid(_) => true, + ItemKind::Comp(_) | ItemKind::Folder => false, + }) + }) + .collect(); + if items.is_empty() { + return vec![]; + } + s.state.project_selection = items; + match s.execute("file.newCompFromSelection", json!({"single": true})) { + Ok(_) => vec![], + Err(e) => vec![e.to_string()], + } +} + /// File ▸ Import, all in one go (scripts and commands built on it); see [`import_cmd`]. pub(crate) fn import(s: &mut Session, p: &Value) -> Result { let pending = prepare_import(s, p)?; @@ -745,7 +785,7 @@ pub fn specs() -> Vec { "File...", ["File", "Import"], Some("Cmd+I"), - "{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default: a numbered still brings its run, except dropped files (drag), which import as stills as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}", + "{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default false: picked or dropped files import as stills, as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), newComp?: bool (make one new composition of the footage and solids imported, as file.newCompFromSelection with single; a layered file imported as a composition is that composition and gets no other; takes precedence over addToComp), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}", always, import_cmd ), diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/cmd/file_more.rs similarity index 77% rename from crates/engine/src/commands/file_more.rs rename to crates/engine/src/cmd/file_more.rs index 74a86e1d8..168529c47 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/cmd/file_more.rs @@ -5,13 +5,13 @@ use std::collections::{BTreeMap, BTreeSet}; use effectcraft_color::Label; -use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits}; +use effectcraft_project::{AlphaMode, CineonSettings, CineonUnits, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits}; use effectcraft_time::{FrameRate, Tick}; use serde_json::{Value, json}; use super::{CommandSpec, always, b_p, bad, f_p, has_comp, has_project_selection, str_p}; use crate::sequence::SequenceOptions; -use crate::{EngineError, Result, Session, cmd}; +use crate::{EngineError, Result, Session, cmd, query}; fn has_footage_selection(s: &Session) -> std::result::Result<(), String> { if selected_footage(s).is_empty() { Err("select footage in the Project panel".into()) } else { Ok(()) } @@ -78,11 +78,19 @@ fn close_project(s: &mut Session, _: &Value) -> Result { fn save_copy(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad("file.saveCopy", "missing `path`"))?; + s.refuse_export(path)?; let json = super::file::file_text(s, path)?; - s.services.write_file(path, json.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; + s.write_output(path, json.as_bytes())?; Ok(json!({"path": path, "bytes": json.len()})) } +/// `file.checkOutput {path}`: the commands that write to a path never replace footage, a proxy +/// or the project file; this says beforehand, for callers that write the file themselves. +fn check_output(s: &mut Session, p: &Value) -> Result { + let path = str_p(p, "path").ok_or_else(|| bad("file.checkOutput", "missing `path`"))?; + Ok(s.output_status(path)) +} + fn rate_of(p: &Value) -> FrameRate { f_p(p, "frameRate").map(|f| limits::frame_rate(FrameRate::from_f64(f), FrameRate::FPS_29_97)).unwrap_or(FrameRate::FPS_29_97) } @@ -376,41 +384,6 @@ fn find_missing(s: &mut Session, p: &Value) -> Result { fn collect_files(s: &mut Session, p: &Value) -> Result { let folder = str_p(p, "folder").ok_or_else(|| bad("file.collectFiles", "missing `folder`"))?.trim_end_matches('/').to_string(); - // Collect Files makes its folder (and the footage folder inside it). - #[cfg(not(target_arch = "wasm32"))] - std::fs::create_dir_all(format!("{folder}/(Footage)")).map_err(|e| EngineError::Other(format!("cannot create {folder}: {e}")))?; - let mut proj = (*s.project).clone(); - let mut copied = 0; - let mut errors = vec![]; - let mut used_names = BTreeSet::new(); - for it in proj.items.values_mut() { - let ItemKind::Footage(f) = &mut it.kind else { continue }; - if f.path.is_empty() { - continue; - } - let files: Vec = if f.sequence.is_empty() { vec![f.path.clone()] } else { f.sequence.clone() }; - let mut new_paths = vec![]; - for src in &files { - let mut name = std::path::Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "footage".into()); - let mut k = 2; - while !used_names.insert(name.clone()) { - name = format!("{k}_{name}"); - k += 1; - } - let dst = format!("{folder}/(Footage)/{name}"); - match s.services.read_file(src).and_then(|bytes| s.services.write_file(&dst, &bytes)) { - Ok(()) => copied += 1, - Err(e) => errors.push(format!("{src}: {e}")), - } - new_paths.push(dst); - } - if f.sequence.is_empty() { - f.path = new_paths.remove(0); - } else { - f.path = new_paths.first().cloned().unwrap_or_default(); - f.sequence = new_paths; - } - } let stem = s .path .as_deref() @@ -418,7 +391,67 @@ fn collect_files(s: &mut Session, p: &Value) -> Result { .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| "Untitled Project".into()); let out = format!("{folder}/{stem}.ecproj"); - s.services.write_file(&out, proj.to_json().as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {out}: {e}")))?; + // Where every copy goes, before anything is made: a copy never lands on a file the project + // reads (other footage, a proxy, the project file itself), and then nothing is written. + let protected = s.protected_files(); + let mut used_names = BTreeSet::new(); + let mut plan: Vec<(ItemId, Vec<(String, String)>)> = vec![]; + for it in s.project.items.values() { + let ItemKind::Footage(f) = &it.kind else { continue }; + if f.path.is_empty() { + continue; + } + let files: Vec<&String> = if f.sequence.is_empty() { vec![&f.path] } else { f.sequence.iter().collect() }; + let mut pairs = vec![]; + for src in files { + let mut name = std::path::Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "footage".into()); + let mut k = 2; + while !used_names.insert(name.clone()) { + name = format!("{k}_{name}"); + k += 1; + } + let dst = format!("{folder}/(Footage)/{name}"); + // A file that is already where its copy goes stays (it is not copied onto itself). + if !crate::output_guard::same_file(std::path::Path::new(src), std::path::Path::new(&dst)) + && let Some(source) = protected.find(std::path::Path::new(&dst)) + { + return Err(EngineError::Other(format!("Nothing was collected. {}", source.refusal(&dst, crate::output_guard::Use::Export)))); + } + pairs.push((src.clone(), dst)); + } + plan.push((it.id, pairs)); + } + if let Some(source) = protected.find(std::path::Path::new(&out)) { + return Err(EngineError::Other(format!("Nothing was collected. {}", source.refusal(&out, crate::output_guard::Use::Export)))); + } + // Collect Files makes its folder (and the footage folder inside it). + #[cfg(not(target_arch = "wasm32"))] + std::fs::create_dir_all(format!("{folder}/(Footage)")).map_err(|e| EngineError::Other(format!("cannot create {folder}: {e}")))?; + let mut proj = (*s.project).clone(); + let mut copied = 0; + let mut errors = vec![]; + for (id, pairs) in plan { + let mut new_paths = vec![]; + for (src, dst) in &pairs { + if crate::output_guard::same_file(std::path::Path::new(src), std::path::Path::new(dst)) { + copied += 1; + } else { + match s.services.read_file(src).and_then(|bytes| s.write_output_with(&protected, dst, &bytes).map_err(std::io::Error::other)) { + Ok(()) => copied += 1, + Err(e) => errors.push(format!("{src}: {e}")), + } + } + new_paths.push(dst.clone()); + } + let Some(ItemKind::Footage(f)) = proj.items.get_mut(&id).map(|it| &mut it.kind) else { continue }; + if f.sequence.is_empty() { + f.path = new_paths.pop().unwrap_or_default(); + } else { + f.path = new_paths.first().cloned().unwrap_or_default(); + f.sequence = new_paths; + } + } + s.write_output_with(&protected, &out, proj.to_json().as_bytes())?; s.toast(format!("Collected {copied} file(s) into {folder}")); Ok(json!({"project": out, "copied": copied, "errors": errors})) } @@ -430,6 +463,20 @@ fn collect_files(s: &mut Session, p: &Value) -> Result { /// array or JSON lines of `{"command": id, "params": {…}}` (also accepts the control channel's /// `{"method": "engine.execute", "params": {"id", "params"}}`). fn run_script(s: &mut Session, p: &Value) -> Result { + // File ▸ Scripts ▸ Recent Script Files ▸