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/windows-arm64.yml b/.github/workflows/windows-arm64.yml index a074e6dbe..4499e3fdc 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -28,8 +28,10 @@ permissions: jobs: windows-arm64: - # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main + # Immutable supporting workflow revision until the new install contract lands upstream. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f with: app-name: effectcraft app-dir: EffectCraft + install-relative-path: Programs/ArtCraft/EffectCraft + install-scope: perUser diff --git a/CLAUDE.md b/CLAUDE.md index e52386942..21f2df36b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,7 @@ EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual e - **Everything is agent-drivable:** every interactive widget registers an automation id; UI state is serde. - **Community links** (Help menu, About, header Discord button): https://discord.gg/artcraft, https://getartcraft.com, https://getartcraft.com/apps/effectcraft, https://github.com/storytold/effectcraft. - **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm). +- **New workspace crates** must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them. - **Commits:** one task id per commit (`M6.3: trim paths`). Only green states. ## Running and looking at the app diff --git a/Cargo.lock b/Cargo.lock index a4bcdf006..a6611ffba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -860,6 +860,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" @@ -1038,34 +1047,81 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=512b54355cd1418f4e8c56a3fbd7483c3c274bf3#512b54355cd1418f4e8c56a3fbd7483c3c274bf3" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" + +[[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=512b54355cd1418f4e8c56a3fbd7483c3c274bf3#512b54355cd1418f4e8c56a3fbd7483c3c274bf3" +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=512b54355cd1418f4e8c56a3fbd7483c3c274bf3#512b54355cd1418f4e8c56a3fbd7483c3c274bf3" +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=512b54355cd1418f4e8c56a3fbd7483c3c274bf3#512b54355cd1418f4e8c56a3fbd7483c3c274bf3" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "chrono", "dunce", @@ -1284,7 +1340,7 @@ dependencies = [ [[package]] name = "effectcraft" -version = "0.17.0" +version = "0.18.0" dependencies = [ "cpal", "craft-update", @@ -1310,7 +1366,7 @@ dependencies = [ [[package]] name = "effectcraft-automation" -version = "0.17.0" +version = "0.18.0" dependencies = [ "effectcraft-engine", "effectcraft-host", @@ -1322,14 +1378,14 @@ dependencies = [ [[package]] name = "effectcraft-av1enc" -version = "0.17.0" +version = "0.18.0" dependencies = [ "filmcraft-av1", ] [[package]] name = "effectcraft-cli" -version = "0.17.0" +version = "0.18.0" dependencies = [ "cpu-time", "effectcraft-automation", @@ -1345,7 +1401,7 @@ dependencies = [ [[package]] name = "effectcraft-color" -version = "0.17.0" +version = "0.18.0" dependencies = [ "proptest", "serde", @@ -1353,7 +1409,7 @@ dependencies = [ [[package]] name = "effectcraft-effects" -version = "0.17.0" +version = "0.18.0" dependencies = [ "chrono", "effectcraft-color", @@ -1371,7 +1427,7 @@ dependencies = [ [[package]] name = "effectcraft-engine" -version = "0.17.0" +version = "0.18.0" dependencies = [ "chrono", "craft-docpath", @@ -1406,7 +1462,7 @@ dependencies = [ [[package]] name = "effectcraft-export" -version = "0.17.0" +version = "0.18.0" dependencies = [ "effectcraft-av1enc", "effectcraft-color", @@ -1436,7 +1492,7 @@ dependencies = [ [[package]] name = "effectcraft-expr" -version = "0.17.0" +version = "0.18.0" dependencies = [ "boa_engine", "effectcraft-color", @@ -1453,7 +1509,7 @@ dependencies = [ [[package]] name = "effectcraft-geom" -version = "0.17.0" +version = "0.18.0" dependencies = [ "proptest", "serde", @@ -1461,7 +1517,7 @@ dependencies = [ [[package]] name = "effectcraft-gpu" -version = "0.17.0" +version = "0.18.0" dependencies = [ "bytemuck", "effectcraft-color", @@ -1483,14 +1539,14 @@ dependencies = [ [[package]] name = "effectcraft-hevcenc" -version = "0.17.0" +version = "0.18.0" dependencies = [ "filmcraft-hevc", ] [[package]] name = "effectcraft-host" -version = "0.17.0" +version = "0.18.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -1515,7 +1571,7 @@ dependencies = [ [[package]] name = "effectcraft-interchange" -version = "0.17.0" +version = "0.18.0" dependencies = [ "effectcraft-color", "effectcraft-keyframe", @@ -1534,7 +1590,7 @@ dependencies = [ [[package]] name = "effectcraft-keyframe" -version = "0.17.0" +version = "0.18.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1546,7 +1602,7 @@ dependencies = [ [[package]] name = "effectcraft-lottie" -version = "0.17.0" +version = "0.18.0" dependencies = [ "craft-docpath", "effectcraft-color", @@ -1564,7 +1620,7 @@ dependencies = [ [[package]] name = "effectcraft-media" -version = "0.17.0" +version = "0.18.0" dependencies = [ "craft-docpath", "effectcraft-model", @@ -1593,7 +1649,7 @@ dependencies = [ [[package]] name = "effectcraft-model" -version = "0.17.0" +version = "0.18.0" dependencies = [ "effectcraft-geom", "image", @@ -1606,14 +1662,14 @@ dependencies = [ [[package]] name = "effectcraft-opusenc" -version = "0.17.0" +version = "0.18.0" dependencies = [ "filmcraft-opus", ] [[package]] name = "effectcraft-path" -version = "0.17.0" +version = "0.18.0" dependencies = [ "effectcraft-geom", "effectcraft-keyframe", @@ -1625,7 +1681,7 @@ dependencies = [ [[package]] name = "effectcraft-pdf" -version = "0.17.0" +version = "0.18.0" dependencies = [ "craft-pdf", "effectcraft-raster", @@ -1641,7 +1697,7 @@ dependencies = [ [[package]] name = "effectcraft-plugin" -version = "0.17.0" +version = "0.18.0" dependencies = [ "effectcraft-effects", "effectcraft-keyframe", @@ -1653,7 +1709,7 @@ dependencies = [ [[package]] name = "effectcraft-project" -version = "0.17.0" +version = "0.18.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1666,14 +1722,14 @@ dependencies = [ [[package]] name = "effectcraft-psd" -version = "0.17.0" +version = "0.18.0" dependencies = [ "miniz_oxide 0.9.1", ] [[package]] name = "effectcraft-raster" -version = "0.17.0" +version = "0.18.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1682,7 +1738,7 @@ dependencies = [ [[package]] name = "effectcraft-render" -version = "0.17.0" +version = "0.18.0" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1704,7 +1760,7 @@ dependencies = [ [[package]] name = "effectcraft-script" -version = "0.17.0" +version = "0.18.0" dependencies = [ "boa_engine", "effectcraft-engine", @@ -1722,7 +1778,7 @@ dependencies = [ [[package]] name = "effectcraft-segment" -version = "0.17.0" +version = "0.18.0" dependencies = [ "miniz_oxide 0.9.1", "ndarray", @@ -1731,7 +1787,7 @@ dependencies = [ [[package]] name = "effectcraft-svg" -version = "0.17.0" +version = "0.18.0" dependencies = [ "effectcraft-geom", "effectcraft-path", @@ -1743,7 +1799,7 @@ dependencies = [ [[package]] name = "effectcraft-text" -version = "0.17.0" +version = "0.18.0" dependencies = [ "effectcraft-keyframe", "harfrust", @@ -1755,7 +1811,7 @@ dependencies = [ [[package]] name = "effectcraft-time" -version = "0.17.0" +version = "0.18.0" dependencies = [ "proptest", "serde", @@ -1763,7 +1819,7 @@ dependencies = [ [[package]] name = "effectcraft-track" -version = "0.17.0" +version = "0.18.0" dependencies = [ "effectcraft-raster", "effectcraft-segment", @@ -1775,9 +1831,10 @@ dependencies = [ [[package]] name = "effectcraft-ui-egui" -version = "0.17.0" +version = "0.18.0" dependencies = [ "chrono", + "craft-quit", "craft-ui", "craft-update", "effectcraft-color", @@ -1809,11 +1866,11 @@ dependencies = [ [[package]] name = "effectcraft-vp9enc" -version = "0.17.0" +version = "0.18.0" [[package]] name = "effectcraft-web" -version = "0.17.0" +version = "0.18.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -2447,6 +2504,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" @@ -2871,7 +2939,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "git+https://github.com/storytold/craft-libs?rev=512b54355cd1418f4e8c56a3fbd7483c3c274bf3#512b54355cd1418f4e8c56a3fbd7483c3c274bf3" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "hayro-ccitt", ] @@ -2885,7 +2953,7 @@ checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "git+https://github.com/storytold/craft-libs?rev=512b54355cd1418f4e8c56a3fbd7483c3c274bf3#512b54355cd1418f4e8c56a3fbd7483c3c274bf3" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "hayro-jbig2", "hayro-jpeg2000", @@ -2943,6 +3011,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" @@ -2951,11 +3036,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" @@ -2968,6 +3074,7 @@ dependencies = [ "icu_provider", "smallvec", "utf16_iter", + "utf8_iter", "write16", "zerovec", ] @@ -2978,6 +3085,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" @@ -3007,6 +3140,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "postcard", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -3014,6 +3150,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" @@ -3329,6 +3489,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" @@ -4321,12 +4484,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", ] @@ -5407,7 +5582,10 @@ 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]] @@ -5579,6 +5757,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -6624,6 +6803,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" @@ -6937,6 +7127,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" @@ -7003,7 +7196,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.17.0" +version = "0.18.0" dependencies = [ "chrono", "serde_json", @@ -7193,8 +7386,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", + "litemap", + "serde_core", "yoke", "zerofrom", + "zerovec", ] [[package]] @@ -7203,6 +7399,7 @@ version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/Cargo.toml b/Cargo.toml index fe6d2ddcc..aa917fc63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,16 +4,17 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*"] [workspace.package] -version = "0.17.0" +version = "0.18.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" repository = "https://github.com/storytold/effectcraft" [workspace.dependencies] -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "512b54355cd1418f4e8c56a3fbd7483c3c274bf3", version = "=0.1.0", default-features = false, features = ["read"] } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "512b54355cd1418f4e8c56a3fbd7483c3c274bf3" } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "512b54355cd1418f4e8c56a3fbd7483c3c274bf3", features = ["egui"] } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["read"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-quit = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } # internal effectcraft-time = { path = "crates/time" } effectcraft-geom = { path = "crates/geom" } @@ -108,7 +109,7 @@ 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 = "512b54355cd1418f4e8c56a3fbd7483c3c274bf3", 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"] } @@ -135,9 +136,11 @@ sys-locale = "0.3" unsafe_code = "forbid" [workspace.lints.clippy] +cognitive_complexity = "deny" +too_many_arguments = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" -too_many_arguments = "allow" needless_range_loop = "allow" type_complexity = "allow" @@ -220,8 +223,8 @@ lto = "thin" codegen-units = 1 [patch.crates-io] -hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "512b54355cd1418f4e8c56a3fbd7483c3c274bf3", version = "=0.7.2" } -hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "512b54355cd1418f4e8c56a3fbd7483c3c274bf3", 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..81d42360c 100644 --- a/README.md +++ b/README.md @@ -275,9 +275,13 @@ send us right now. | arm64 (Snapdragon and other ARM PCs) | `effectcraft--windows-arm64.msi` | `effectcraft--windows-arm64-portable.zip` | | x86 (32-bit) | `effectcraft--windows-x86.msi` | `effectcraft--windows-x86-portable.zip` | -Installers and executables are code-signed. The installer asks where to install EffectCraft -(Program Files by default), and later versions install into the same folder. To install silently +Installers and executables are code-signed. The installer asks who to install EffectCraft for and where. By default it installs for your +account only, into `%LOCALAPPDATA%\Programs\ArtCraft\EffectCraft`, without an administrator prompt; +"Everyone on this computer" installs into `C:\Program Files\ArtCraft\EffectCraft` (`Program Files (x86)` for the 32-bit build). Later versions +install into the same folder. To install silently into a folder of your choice: `msiexec /i effectcraft--windows-x64.msi /qn INSTALLFOLDER="D:\Apps\EffectCraft"`. +A silent install is per-user by default. `INSTALLFOLDER` chooses the folder, not who it is installed for; +add `ALLUSERS=1`, from an elevated terminal or deployment tool, for an all-users install. ### macOS diff --git a/apps/effectcraft-cli/tests/it/qa_template.rs b/apps/effectcraft-cli/tests/it/qa_template.rs index 8cb533fda..ffbdb94e9 100644 --- a/apps/effectcraft-cli/tests/it/qa_template.rs +++ b/apps/effectcraft-cli/tests/it/qa_template.rs @@ -152,6 +152,13 @@ fn essential_graphics_template_via_script_and_commands() { assert_eq!(image::open(&frames[0]).unwrap().to_rgba8(), edit); assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), original_template); + invalid_template_values_fail_cleanly(&dir); + template_usage_errors(&dir); + rational_template_keeps_exact_rate(&dir, &original_template); + let _ = std::fs::remove_dir_all(&dir); +} + +fn invalid_template_values_fail_cleanly(dir: &PathBuf) { // Invalid values fail before the queue creates an output directory, or frame rendering // overwrites an existing output. A JSON object and existing control names are required. for (name, values, expected) in [ @@ -164,25 +171,27 @@ fn essential_graphics_template_via_script_and_commands() { std::fs::write(dir.join(&values_file), values).unwrap(); let output_dir = dir.join(format!("failed-{name}")); let out = output_dir.join("frame_[#####].png"); - let (code, error) = cli(&dir, &["render", "--template", "lt.ectemplate", "--values", &values_file, "--out", out.to_str().unwrap()]); + let (code, error) = cli(dir, &["render", "--template", "lt.ectemplate", "--values", &values_file, "--out", out.to_str().unwrap()]); assert_eq!(code, 1, "{name}: {error}"); assert!(error["error"].as_str().unwrap().contains(expected), "{name}: {error}"); assert!(!output_dir.exists(), "{name}: invalid input created render output"); std::fs::write(dir.join("untouched.png"), b"existing output").unwrap(); - let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", &values_file, "--out", "untouched.png"]); + let (code, error) = cli(dir, &["render-frame", "--template", "lt.ectemplate", "--values", &values_file, "--out", "untouched.png"]); assert_eq!(code, 1, "{name}: {error}"); assert_eq!(std::fs::read(dir.join("untouched.png")).unwrap(), b"existing output"); } - let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "missing-values.json", "--out", "missing.png"]); + let (code, error) = cli(dir, &["render-frame", "--template", "lt.ectemplate", "--values", "missing-values.json", "--out", "missing.png"]); assert_eq!(code, 1, "{error}"); assert!(error["error"].as_str().unwrap().contains("cannot read values"), "{error}"); assert!(!dir.join("missing.png").exists()); std::fs::write(dir.join("large-values.json"), vec![b' '; 1024 * 1024 + 1]).unwrap(); - let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "large-values.json", "--out", "large.png"]); + let (code, error) = cli(dir, &["render-frame", "--template", "lt.ectemplate", "--values", "large-values.json", "--out", "large.png"]); assert_eq!(code, 1, "{error}"); assert!(error["error"].as_str().unwrap().contains("exceeds 1 MiB"), "{error}"); assert!(!dir.join("large.png").exists()); +} +fn template_usage_errors(dir: &PathBuf) { for args in [ vec!["render-frame", "--values", "values.json"], vec!["info", "--template", "lt.ectemplate"], @@ -191,10 +200,12 @@ fn essential_graphics_template_via_script_and_commands() { vec!["render", "--template", "lt.ectemplate", "--queue"], vec!["render-frame", "--template", "lt.ectemplate", "--bridge", "1"], ] { - let output = Command::new(crate::binary()).current_dir(&dir).args(&args).output().unwrap(); + let output = Command::new(crate::binary()).current_dir(dir).args(&args).output().unwrap(); assert_eq!(output.status.code(), Some(2), "{args:?}: {}", String::from_utf8_lossy(&output.stderr)); } +} +fn rational_template_keeps_exact_rate(dir: &PathBuf, original_template: &[u8]) { // File-loaded templates can use exact rational rates that comp.new's float parser rounds. // Preserve that rate, Tick duration, work area, background and locked source layers. let mut project = effectcraft_engine::project::Project::from_json(&std::fs::read_to_string(dir.join("t.ecproj")).unwrap()).unwrap(); @@ -207,13 +218,13 @@ fn essential_graphics_template_via_script_and_commands() { comp.background = [0.08, 0.16, 0.24]; comp.layers.first_mut().unwrap().switches.locked = true; std::fs::write(dir.join("rational.ecproj"), project.to_json()).unwrap(); - ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"rational.ectemplate"}"#, "rational.ecproj"]); + ok(dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"rational.ectemplate"}"#, "rational.ecproj"]); let rational_template = std::fs::read(dir.join("rational.ectemplate")).unwrap(); - let rational = ok(&dir, &["render-frame", "--template", "rational.ectemplate", "--frame", "5", "--out", "rational.png"]); + let rational = ok(dir, &["render-frame", "--template", "rational.ectemplate", "--frame", "5", "--out", "rational.png"]); assert!((rational["time"].as_f64().unwrap() - 5.0 / rate.as_f64()).abs() < 1e-9, "{rational}"); assert_eq!((rational["width"].as_u64().unwrap(), rational["height"].as_u64().unwrap()), (320, 90)); let output = dir.join("rational-work-area/frame_[#####].png"); - ok(&dir, &["render", "--template", "rational.ectemplate", "--work-area", "--out", output.to_str().unwrap(), "--format", "png", "--resolution", "0.05"]); + ok(dir, &["render", "--template", "rational.ectemplate", "--work-area", "--out", output.to_str().unwrap(), "--format", "png", "--resolution", "0.05"]); let count = std::fs::read_dir(dir.join("rational-work-area")) .unwrap() .filter_map(Result::ok) @@ -222,8 +233,8 @@ fn essential_graphics_template_via_script_and_commands() { assert_eq!(count, 4, "the template's exact work area contains frames 3 through 6"); assert_eq!(std::fs::read(dir.join("rational.ectemplate")).unwrap(), rational_template); assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), original_template); - let _ = std::fs::remove_dir_all(&dir); } + fn template_output_alias_fixture(case: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("ec-qa-template-output-{case}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); diff --git a/apps/effectcraft-web/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/src/main.rs b/apps/effectcraft/src/main.rs index 6df015290..64ab5443d 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; @@ -365,14 +365,11 @@ fn main() -> eframe::Result { })) }), ); - match &result { - Ok(()) => log::info!("the window closed"), - Err(e) => { - if on_gl { - launch_guard::opengl_failed(effectcraft_host::config_dir().as_deref()); - } - startup_failed(e, !on_gl && cfg!(not(target_os = "macos"))); + if let Err(e) = &result { + if on_gl { + launch_guard::opengl_failed(effectcraft_host::config_dir().as_deref()); } + startup_failed(e, !on_gl && cfg!(not(target_os = "macos"))); } result } @@ -455,7 +452,8 @@ struct Desktop { } impl eframe::App for Desktop { - /// The desktop host's logic tick: update the macOS menu bar, run the shared app tick and close the IME pass. + /// The desktop host's logic tick: update the macOS menu bar, run the shared app tick, close the IME pass, show the + /// file dialogs asked for and, last, hand a close to the platform quit. fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { #[cfg(target_os = "macos")] self.menu.update(&mut self.app, ctx); @@ -464,6 +462,7 @@ impl eframe::App for Desktop { if let Some(asked) = &self.file_dialogs { sheet_dialogs::show_asked(asked, frame, ctx); } + effectcraft_ui_egui::panels::unsaved::quit_on_close(ctx); } fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { @@ -484,6 +483,9 @@ impl eframe::App for Desktop { } fn on_exit(&mut self) { + // On macOS the app quits through `-[NSApplication terminate:]`, and the process ends + // before `eframe::run_native` returns (#341). + log::info!("the window is closing"); self.launch.exited(); self.window.save(self.app.session.config.as_deref()); self.app.on_exit(); diff --git a/apps/effectcraft/tests/args.rs b/apps/effectcraft/tests/args.rs index 41281eb23..62c591b45 100644 --- a/apps/effectcraft/tests/args.rs +++ b/apps/effectcraft/tests/args.rs @@ -1,6 +1,4 @@ //! The desktop binary rejects a bad command line before it starts anything (#997). -// Integration tests: unwrapping and panicking on failure is fine here. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::path::PathBuf; use std::process::{Command, Stdio}; diff --git a/clippy.toml b/clippy.toml index 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/crates/automation/tests/qa/compositing.rs b/crates/automation/tests/qa/compositing.rs index 52d5e4fb2..da78ae116 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,13 +29,12 @@ fn write_sequence(qa: &Qa) -> String { qa.path("seq/ball_000.png") } -#[test] -fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { - let mut qa = Qa::new("compositing"); - +/// Import the sequence (reinterpreted at 12 fps) and a PSD written by the app; returns the +/// sequence item and the PSD's composition. +fn import_footage(qa: &mut Qa) -> (Value, Value) { // Footage: the sequence, and a layered PSD written by the app itself (Save Frame As ▸ // Photoshop Layers) from a small comp. - let seq = write_sequence(&qa); + let seq = write_sequence(qa); let imp = qa.exec("file.import", json!({"paths": [seq]})); let seq_item = imp["items"][0].clone(); let project = qa.tool("get_project", json!({})); @@ -55,10 +58,14 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { let pc = qa.tool("get_comp", json!({"comp": psd_comp.clone()})); let names: Vec<&str> = pc["layers"].as_array().unwrap().iter().map(|l| l["name"].as_str().unwrap()).collect(); assert_eq!(names, ["Bar", "Gray"], "the PSD's layers, top first: {pc}"); + (seq_item, psd_comp) +} +/// The main comp: the sequence plays, then a Screen-blended PSD; returns the Normal-mode frame. +fn main_comp_and_blend(qa: &mut Qa, seq_item: &Value, psd_comp: &Value) -> Img { // The main comp. - let main = qa.exec("comp.new", json!({"name": "Main", "width": 160, "height": 90, "frameRate": 12, "duration": 1}))["comp"].clone(); - qa.exec("layer.addItem", json!({"item": seq_item})); + qa.exec("comp.new", json!({"name": "Main", "width": 160, "height": 90, "frameRate": 12, "duration": 1})); + qa.exec("layer.addItem", json!({"item": seq_item.clone()})); let a = qa.frame(None, 0.0); let b = qa.frame(None, 0.5); assert!(mean_diff(&a, &b) > 1.0, "the sequence plays"); @@ -75,7 +82,10 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { for c in 0..3 { assert!(s[c] > n[c] && s[c] > [32, 64, 96][c], "Screen brightens channel {c}: {s:?}"); } + normal +} +fn track_matte(qa: &mut Qa) { // Track matte: a magenta solid seen only through text. qa.exec("layer.newText", json!({"text": "MATTE", "name": "Matte", "size": 40, "fill": "#ffffff"})); qa.exec("layer.newSolid", json!({"name": "Fill", "color": "#ff00ff", "width": 160, "height": 90})); @@ -91,7 +101,9 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { let fill = info["layers"].as_array().unwrap().iter().find(|l| l["name"] == "Fill").unwrap().clone(); assert_eq!(fill["trackMatte"]["kind"], "Alpha Matte"); qa.exec("layer.setSwitch", json!({"layers": ["Fill", "Matte"], "switch": "video", "value": false})); +} +fn adjustment_layer(qa: &mut Qa, normal: &Img) { // Adjustment layer: Lumetri exposure, inside a feathered, expanded elliptical mask; then Glow. qa.exec("layer.newAdjustment", json!({"name": "Grade"})); let fx = qa.exec("effect.apply", json!({"effect": "Lumetri Color", "layers": ["Grade"]})); @@ -103,7 +115,6 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { let mk = qa.exec("layer.addMask", json!({"layer": "Grade", "shape": "ellipse", "rect": [40, 20, 80, 50]})); assert!(mk["mask"].is_u64(), "{mk}"); let hard = qa.frame(None, 0.5); - let lum = |i: &Img, x, y| luma(i.get_pixel(x, y)); assert!(lum(&hard, 80, 45) > lum(&before, 80, 45) - 1.0 && lum(&hard, 5, 5) < lum(&before, 5, 5) - 10.0, "the mask limits the grade to the ellipse"); qa.exec("layer.mask.set", json!({"layer": "Grade", "field": "expansion", "value": 8})); let grown = qa.frame(None, 0.5); @@ -116,7 +127,7 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { } n }; - assert!(bright(&grown, &normal) > bright(&hard, &normal) + 100, "expansion grows the graded area"); + assert!(bright(&grown, normal) > bright(&hard, normal) + 100, "expansion grows the graded area"); qa.exec("layer.mask.set", json!({"layer": "Grade", "field": "feather", "value": 20})); let soft = qa.frame(None, 0.5); // At the ellipse's edge (x = 40 − 8): feathered is in between none and full. @@ -125,7 +136,10 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { qa.exec("effect.apply", json!({"effect": "Glow", "layers": ["Grade"]})); let glow = qa.frame(None, 0.5); assert!(mean_diff(&soft, &glow) > 0.5, "Glow changes the frame"); +} +/// The 3D stage with depth of field and a light; returns the stage comp. +fn stage_3d(qa: &mut Qa) -> Value { // 3D stage: a near and a far card, a camera with depth of field, a light. let stage = qa.exec("comp.new", json!({"name": "Stage", "width": 160, "height": 90, "frameRate": 12, "duration": 1}))["comp"].clone(); qa.exec("layer.newSolid", json!({"name": "Near", "color": "#ff4040", "width": 40, "height": 40})); @@ -155,7 +169,10 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { let advanced = qa.frame(None, 0.0); assert!(mean_diff(&classic, &advanced) > 0.2, "Advanced 3D renders the stage its own way"); assert!(lum(&advanced, 50, 45) > 60.0, "Advanced 3D still lights the card"); + stage +} +fn alpha_renders(qa: &mut Qa, stage: &Value) { // Renders with alpha: the stage has no background layer, so most of it is transparent. let mov = qa.path("stage.mov"); let webm = qa.path("stage.webm"); @@ -165,7 +182,7 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { assert!(done["items"].as_array().unwrap().iter().all(|i| i["status"] == "Done"), "{done}"); let reference = qa.tool("render_frame", json!({"comp": stage.clone(), "time": 0, "transparent": true, "inline": false})); assert_eq!(reference["width"], 160); - let refa = alpha_frame(&mut qa, stage.clone()); + let refa = alpha_frame(qa, stage.clone()); assert!(px(&refa, 2, 2)[3] < 40 && px(&refa, 50, 45)[3] == 255, "render_frame transparent keeps alpha: {:?} {:?}", px(&refa, 2, 2), px(&refa, 50, 45)); // ProRes 4444 carries alpha in the frame; WebM VP9 as a second VP9 stream in BlockAdditions // (AlphaMode 1), which FilmCraft's Matroska reader decodes into the frame's alpha plane. @@ -174,14 +191,24 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { assert_eq!(r["errors"], json!([])); assert_eq!(r["unlabeledAlpha"], r["items"], "{path}: the importer sees an alpha channel"); let c = qa.exec("file.newCompFromSelection", json!({}))["comps"][0].clone(); - let f = alpha_frame(&mut qa, c.clone()); + let f = alpha_frame(qa, c.clone()); // The opaque card decodes to the stage's colours (lossy codecs, so loosely). let (got, want) = (px(&f, 50, 45), px(&refa, 50, 45)); assert!((0..3).all(|c| got[c].abs_diff(want[c]) < 12), "{path} decodes to the stage: {got:?} vs {want:?}"); assert!(mean_diff(&qa.frame(Some(c.clone()), 0.0), &qa.frame(Some(stage.clone()), 0.0)) < 8.0, "{path} composites like the stage"); assert!(px(&f, 2, 2)[3] < 40 && px(&f, 50, 45)[3] > 250, "{path}: transparent corners, opaque card: {:?} {:?}", px(&f, 2, 2), px(&f, 50, 45)); } - let _ = main; +} + +#[test] +fn compositing_matte_blend_adjustment_3d_and_alpha_renders() { + let mut qa = Qa::new("compositing"); + let (seq_item, psd_comp) = import_footage(&mut qa); + let normal = main_comp_and_blend(&mut qa, &seq_item, &psd_comp); + track_matte(&mut qa); + adjustment_layer(&mut qa, &normal); + let stage = stage_3d(&mut qa); + alpha_renders(&mut qa, &stage); } fn alpha_frame(qa: &mut Qa, comp: Value) -> Img { 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..9bbd56823 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, predict_inter, predict_intra, subpel_plane}; +use crate::tables::{ + AC_QLOOKUP, BLOCK_8X8, BLOCK_64X64, DC_PRED, DC_QLOOKUP, DCT_DCT, DEFAULT_SCAN_4X4, DEFAULT_SCAN_8X8, DEFAULT_SCAN_16X16, DEFAULT_SCAN_32X32, MODE_TO_TXFM, + NEWMV, NUM_4X4_BLOCKS_WIDE, PARTITION_SPLIT, PARTITION_SUBSIZE, TX_4X4, TX_8X8, TX_16X16, TX_32X32, TX_64X64, TX_SET_DCTONLY, TX_SET_INTER_3, + TX_SET_INTRA_2, TX_SIZE_SQR_UP, TX_TYPE_IN_SET_INTER, TX_TYPE_IN_SET_INTRA, +}; use crate::transform::{forward_2d, inverse_2d}; /// Decisions for one coded block. @@ -100,6 +104,17 @@ pub(crate) fn intra_uv_tx_type(uv_mode: usize, uv_tx: usize) -> usize { if tx_in_set(tx_set(uv_tx, false), false, t) { t } else { DCT_DCT } } +/// Geometry and coding mode of one square transform block. +#[derive(Clone, Copy)] +struct TransformBlock { + plane: usize, + x: usize, + y: usize, + log2: u32, + tx_type: usize, + intra: bool, +} + struct TxResult { levels: Vec, 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], @@ -131,6 +178,8 @@ pub(crate) struct Decider<'a> { lambda_sad: f64, /// Half-sample interpolations of the reference luma (visible area): (8, 0), (0, 8), (8, 8). half: [Vec; 3], + /// Transform buffers, taken out while a block is being tried (see `code_tx`). + scratch: Option>, } const MODE_BITS_INTRA: f64 = 3.0; @@ -166,6 +215,7 @@ impl<'a> Decider<'a> { Some(rf) => [(8, 0), (0, 8), (8, 8)].map(|(fx, fy)| subpel_plane(&rf.planes[0], g.width, g.height, fx, fy, bd)), None => Default::default(), }, + scratch: None, } } @@ -324,8 +374,10 @@ impl<'a> Decider<'a> { let log2 = (n4 * 4).trailing_zeros(); let (sbr, sbc) = ((r & 15) as isize, (c & 15) as isize); let bd = self.bd; - let mut pred = vec![0u16; 32 * 32]; - let mut best_pred = vec![0u16; 32 * 32]; + let mut pred = [0u16; 32 * 32]; + let mut best_pred = [0u16; 32 * 32]; + let mut best_levels = [0i32; 32 * 32]; + let mut best_recon = [0u16; 32 * 32]; let n = 1usize << log2; // luma let e = IntraEdge { @@ -343,26 +395,26 @@ impl<'a> Decider<'a> { let ed = edges(&self.rec[0], &e); // modes ranked by SATD, then a rate-distortion choice of mode and transform type among // the best three - let mut ranked: Vec<(f64, usize)> = INTRA_MODES_USED - .iter() - .map(|&mode| { - predict_intra(&ed, &e, mode, &mut pred); - let sad = self.satd(0, c * 4, r * 4, n, &pred[..n * n]) as f64; - (sad + self.lambda_sad * if mode == DC_PRED { 1.0 } else { MODE_BITS_INTRA }, mode) - }) - .collect(); + let mut ranked: [(f64, usize); INTRA_MODES_USED.len()] = INTRA_MODES_USED.map(|mode| { + predict_intra(&ed, &e, mode, &mut pred); + let sad = self.satd(0, c * 4, r * 4, n, &pred[..n * n]) as f64; + (sad + self.lambda_sad * if mode == DC_PRED { 1.0 } else { MODE_BITS_INTRA }, mode) + }); ranked.sort_by(|a, b| a.0.total_cmp(&b.0)); let tx_y = tx_size_for(log2); - let mut best: Option<(f64, usize, usize, TxResult, Option>)> = None; + let mut best: Option<(f64, usize, usize, TxEval, bool)> = None; + let mut sc = self.scratch.take().unwrap_or_default(); for &(_, mode) in ranked.iter().take(3) { predict_intra(&ed, &e, mode, &mut pred); - let mut types = vec![DCT_DCT]; + let mut types = [DCT_DCT; 2]; + let mut n_types = 1; let alt = MODE_TO_TXFM[mode] as usize; if alt != DCT_DCT && tx_in_set(tx_set(tx_y, false), false, alt) { - types.push(alt); + types[1] = alt; + n_types = 2; } - for &t in &types { - let (res, recon) = self.try_tx(0, c * 4, r * 4, log2, t, &pred[..n * n], true); + for &t in &types[..n_types] { + let (res, has_recon) = self.try_tx(&mut sc, TransformBlock { plane: 0, x: c * 4, y: r * 4, log2, tx_type: t, intra: true }, &pred[..n * n]); let type_bits = if !res.nonzero || tx_set(tx_y, false) == TX_SET_DCTONLY { 0.0 } else if t == DCT_DCT { @@ -374,18 +426,24 @@ impl<'a> Decider<'a> { let cost = res.dist + self.lambda * (res.bits + type_bits + mode_bits); if best.as_ref().is_none_or(|b| cost < b.0) { best_pred[..n * n].copy_from_slice(&pred[..n * n]); - best = Some((cost, mode, t, res, recon)); + best_levels[..n * n].copy_from_slice(&sc.levels[..n * n]); + if has_recon { + best_recon[..n * n].copy_from_slice(&sc.recon[..n * n]); + } + best = Some((cost, mode, t, res, has_recon)); } } } + self.scratch = Some(sc); // Every ranked mode tries DCT_DCT, so `best` is always set; without a candidate the block // can't be coded intra (an infinite cost keeps it from being chosen). - let Some((_, y_mode, tx_type_y, ty, recon)) = best else { + let Some((_, y_mode, tx_type_y, ty, has_recon)) = best else { let leaf = Leaf { bsize, is_inter: false, y_mode: DC_PRED, uv_mode: DC_PRED, tx_type_y: DCT_DCT, mv: [0, 0], skip: true, coefs: Default::default() }; return (leaf, f64::INFINITY); }; - self.put(0, c * 4, r * 4, n, recon.as_deref().unwrap_or(&best_pred[..n * n])); + self.put(0, c * 4, r * 4, n, if has_recon { &best_recon[..n * n] } else { &best_pred[..n * n] }); + let ty = TxResult { levels: best_levels[..n * n].to_vec(), nonzero: ty.nonzero, dist: ty.dist, bits: ty.bits }; let tx_type_y = if ty.nonzero { tx_type_y } else { DCT_DCT }; self.mark_decoded(0, r, c, n4); // chroma (one mode for both planes) @@ -422,7 +480,7 @@ impl<'a> Decider<'a> { let uv_type = intra_uv_tx_type(uv_mode, uv_tx); let mut code_uv = |i: usize| { predict_intra(&ced[i], &ce[i], uv_mode, &mut pred); - let t = self.code_tx(1 + i, c * 2, r * 2, clog2, uv_type, &pred[..cn * cn], true); + let t = self.code_tx(TransformBlock { plane: 1 + i, x: c * 2, y: r * 2, log2: clog2, tx_type: uv_type, intra: true }, &pred[..cn * cn]); self.mark_decoded(1 + i, r, c, n4); t }; @@ -450,13 +508,17 @@ impl<'a> Decider<'a> { let (vw, vh) = self.g.visible(p); let pn = n >> s; let (x, y) = ((c * 4) >> s, (r * 4) >> s); - predict_inter(&refr.planes[p], vw as i32 - 1, vh as i32 - 1, x, y, pn, pn, mv, s, self.bd, &mut pred[..pn * pn]); + predict_inter( + &refr.planes[p], + &InterBlock { last_x: vw as i32 - 1, last_y: vh as i32 - 1, x, y, width: pn, height: pn, mv, subsampling: s, bit_depth: self.bd }, + &mut pred[..pn * pn], + ); if bsize == BLOCK_64X64 { // coded as a skip block: the prediction is the reconstruction dist += self.sse(p, x, y, pn, &pred[..pn * pn]); self.put(p, x, y, pn, &pred[..pn * pn]); } else { - let t = self.code_tx(p, x, y, log2 - s as u32, DCT_DCT, &pred[..pn * pn], false); + let t = self.code_tx(TransformBlock { plane: p, x, y, log2: log2 - s as u32, tx_type: DCT_DCT, intra: false }, &pred[..pn * pn]); dist += t.dist; tx.push(t); } @@ -771,32 +833,38 @@ impl<'a> Decider<'a> { /// Transform, quantise and reconstruct one square transform block of `plane` at (x, y) /// over the prediction `pred`; the reconstruction is written to `rec`. - fn code_tx(&mut self, p: usize, x: usize, y: usize, log2: u32, tx_type: usize, pred: &[u16], intra: bool) -> TxResult { - let (res, recon) = self.try_tx(p, x, y, log2, tx_type, pred, intra); + fn code_tx(&mut self, block: TransformBlock, pred: &[u16]) -> TxResult { + let TransformBlock { plane: p, x, y, log2, .. } = block; + let mut sc = self.scratch.take().unwrap_or_default(); + let (res, has_recon) = self.try_tx(&mut sc, block, pred); let n = 1usize << log2; - self.put(p, x, y, n, recon.as_deref().unwrap_or(pred)); - res + self.put(p, x, y, n, if has_recon { &sc.recon[..n * n] } else { pred }); + let levels = sc.levels[..n * n].to_vec(); + self.scratch = Some(sc); + TxResult { levels, nonzero: res.nonzero, dist: res.dist, bits: res.bits } } - /// Transform and quantise one block; returns the result and its reconstruction (None: the - /// prediction) without writing it. - fn try_tx(&self, p: usize, x: usize, y: usize, log2: u32, tx_type: usize, pred: &[u16], intra: bool) -> (TxResult, Option>) { + /// Transform and quantise one block without writing it. The levels are left in `sc.levels`; + /// the flag tells whether `sc.recon` holds the reconstruction (false: it is the prediction). + fn try_tx(&self, sc: &mut TxScratch, block: TransformBlock, pred: &[u16]) -> (TxEval, bool) { + let TransformBlock { plane: p, x, y, log2, tx_type, intra } = block; let n = 1usize << log2; + let nn = n * n; let tx_sz = tx_size_for(log2); let bd = self.bd; let src = &self.src[p]; - let mut res = vec![0i32; n * n]; + let TxScratch { res, coef, levels, deq, resid, recon } = sc; + let (res, coef, levels) = (&mut res[..nn], &mut coef[..nn], &mut levels[..nn]); for i in 0..n { let row = &src.row(y + i)[x..x + n]; for j in 0..n { res[i * n + j] = row[j] as i32 - pred[i * n + j] as i32; } } - let mut coef = vec![0f32; n * n]; - forward_2d(&res, &mut coef, n, tx_type); + forward_2d(res, coef, n, tx_type); // dead-zone quantiser: rounding offset below one half let rnd = if intra { 0.38f32 } else { 0.30f32 }; - let mut levels = vec![0i32; n * n]; + levels.fill(0); let mut nonzero = false; for (k, (&cf, l)) in coef.iter().zip(levels.iter_mut()).enumerate() { let q = if k == 0 { self.dc_q } else { self.ac_q }; @@ -808,23 +876,26 @@ impl<'a> Decider<'a> { } let zero_dist = self.sse(p, x, y, n, pred); if !nonzero { - return (TxResult { levels, nonzero, dist: zero_dist, bits: 1.0 }, None); + return (TxEval { nonzero, dist: zero_dist, bits: 1.0 }, false); } - let recon = self.reconstruct(&levels, tx_sz, tx_type, pred, n, bd); - let dist = self.sse(p, x, y, n, &recon); - let bits = estimate_bits(&levels, tx_sz); + let recon = &mut recon[..nn]; + self.reconstruct(levels, ReconBuffers { deq: &mut deq[..nn], resid: &mut resid[..nn], recon }, tx_sz, tx_type, pred, bd); + let dist = self.sse(p, x, y, n, recon); + let bits = estimate_bits(levels, tx_sz); if dist + self.lambda * bits >= zero_dist + self.lambda { - levels.iter_mut().for_each(|l| *l = 0); - return (TxResult { levels, nonzero: false, dist: zero_dist, bits: 1.0 }, None); + levels.fill(0); + return (TxEval { nonzero: false, dist: zero_dist, bits: 1.0 }, false); } - (TxResult { levels, nonzero: true, dist, bits }, Some(recon)) + (TxEval { nonzero: true, dist, bits }, true) } - /// Dequantisation (7.12.3) and the inverse transform, added to the prediction. - fn reconstruct(&self, levels: &[i32], tx_sz: usize, tx_type: usize, pred: &[u16], n: usize, bd: u32) -> Vec { + /// Dequantisation (7.12.3) and the inverse transform, added to the prediction; the result is + /// written to `recon`. `deq` and `resid` are scratch buffers of `n * n` entries. + fn reconstruct(&self, levels: &[i32], bufs: ReconBuffers, tx_sz: usize, tx_type: usize, pred: &[u16], bd: u32) { + let ReconBuffers { deq, resid, recon } = bufs; let denom: i64 = if tx_sz == TX_32X32 { 2 } else { 1 }; let lim = 1i64 << (7 + bd); - let mut deq = vec![0i32; n * n]; + deq.fill(0); for (k, (&l, d)) in levels.iter().zip(deq.iter_mut()).enumerate() { if l == 0 { continue; @@ -834,10 +905,11 @@ impl<'a> Decider<'a> { let mag = (dq.abs() & 0xFF_FFFF) / denom; *d = (if dq < 0 { -mag } else { mag }).clamp(-lim, lim - 1) as i32; } - let mut resid = vec![0i32; n * n]; - inverse_2d(&deq, &mut resid, tx_sz, tx_type, bd); + inverse_2d(deq, resid, tx_sz, tx_type, bd); let max = (1i32 << bd) - 1; - pred.iter().zip(&resid).map(|(&p, &r)| (p as i32 + r).clamp(0, max) as u16).collect() + for ((o, &p), &r) in recon.iter_mut().zip(pred).zip(resid.iter()) { + *o = (p as i32 + r).clamp(0, max) as u16; + } } } diff --git a/crates/av1enc/src/lib.rs b/crates/av1enc/src/lib.rs index 6b1edfbe8..3fcf446bb 100644 --- a/crates/av1enc/src/lib.rs +++ b/crates/av1enc/src/lib.rs @@ -409,11 +409,13 @@ impl Encoder { }; let bd = self.cfg.bit_depth as u32; let mut cache: Vec<(u32, u64)> = vec![(0, sse(&rec[0]))]; + // one working copy of the luma plane for all candidate levels + let mut planes = [rec[0].clone(), Plane::new(0, 0), Plane::new(0, 0)]; let mut eval = |lvl: u32| -> u64 { if let Some(&(_, e)) = cache.iter().find(|(l, _)| *l == lvl) { return e; } - let mut planes = [rec[0].clone(), Plane::new(0, 0), Plane::new(0, 0)]; + planes[0].data.copy_from_slice(&rec[0].data); loop_filter(&mut planes, info, [lvl; 4], bd, true, sb_row_step); let e = sse(&planes[0]); cache.push((lvl, e)); 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..7b354af1f 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); @@ -340,7 +344,11 @@ mod tests { for (bx, by) in [(0i32, 0i32), (8, 8), (24, 16), (-5, 20), (30, -3)] { let mut pred = vec![0u16; 64]; let mv = [by * 8 + fy as i32 / 2, bx * 8 + fx as i32 / 2]; - predict_inter(&p, w as i32 - 1, h as i32 - 1, 0, 0, 8, 8, mv, 0, 8, &mut pred); + predict_inter( + &p, + &InterBlock { last_x: w as i32 - 1, last_y: h as i32 - 1, x: 0, y: 0, width: 8, height: 8, mv, subsampling: 0, bit_depth: 8 }, + &mut pred, + ); for r in 0..8 { for c in 0..8 { let (x, y) = ((bx + c).clamp(0, w as i32 - 1), (by + r).clamp(0, h as i32 - 1)); diff --git a/crates/av1enc/src/transform.rs b/crates/av1enc/src/transform.rs index 90042e26a..048bfc0db 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 { @@ -71,6 +71,12 @@ fn inverse_dct(t: &mut [i32], n: u32, r: u32) { for i in 0..len { t[i] = copy[brev(n, i)]; } + inverse_dct_initial_stages(t, n, r); + inverse_dct_final_stages(t, n, r); +} + +/// Rotate the high-frequency branches before the four-point core. +fn inverse_dct_initial_stages(t: &mut [i32], n: u32, r: u32) { if n >= 5 { for i in 0..8 { bf(t, 16 + i, 31 - i, 6 + ((brev(3, 7 - i) as i32) << 3), false); @@ -103,6 +109,10 @@ fn inverse_dct(t: &mut [i32], n: u32, r: u32) { } } } +} + +/// Evaluate the four-point core and merge progressively larger branches. +fn inverse_dct_final_stages(t: &mut [i32], n: u32, r: u32) { for i in 0..2 { bf(t, 2 * i, 2 * i + 1, 32 + 16 * i as i32, i == 0); } @@ -454,6 +464,7 @@ pub(crate) fn forward_2d(residual: &[i32], out: &mut [f32], n: usize, tx_type: u #[cfg(test)] mod tests { use super::*; + use crate::tables::{TX_4X4, TX_8X8, TX_16X16, TX_32X32}; /// forward (float) then the normative inverse reproduces the residual: the forward basis /// and scale match the decoder's inverse transforms for every supported size and type. 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/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs index ea194f6da..08241107c 100644 --- a/crates/effects/src/channel3d.rs +++ b/crates/effects/src/channel3d.rs @@ -686,384 +686,4 @@ pub fn specs() -> Vec { } #[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..4fc84440e --- /dev/null +++ b/crates/effects/src/channel3d/tests.rs @@ -0,0 +1,375 @@ +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)); +} 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 06884017c..3a52e7a54 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -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/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/generate2.rs b/crates/effects/src/generate2.rs index fcb05d6e2..b4781b793 100644 --- a/crates/effects/src/generate2.rs +++ b/crates/effects/src/generate2.rs @@ -692,18 +692,33 @@ impl BoltCfg<'_> { } } -/// Grow a bolt from `a` to `z` by midpoint displacement (`depth` levels), forking at random. -/// Segments go to `out` with their intensity; `mains` marks the main core's segments. -#[allow(clippy::too_many_arguments)] -fn bolt(cfg: &BoltCfg, a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, main: bool, rng: &mut Rng, out: &mut Vec, mains: &mut Vec) { - if out.len() > 20_000 { +/// One branch's geometry and recursive growth state. +struct BoltBranch { + start: (f64, f64), + end: (f64, f64), + depth: u32, + intensity: f32, + main: bool, +} + +/// Segments and their matching main-core flags, kept in the same traversal order. +#[derive(Default)] +struct BoltOutput { + segments: Vec, + mains: Vec, +} + +/// Grow a branch by midpoint displacement, recursively appending its segments and forks. +fn bolt(cfg: &BoltCfg, branch: BoltBranch, rng: &mut Rng, output: &mut BoltOutput) { + let BoltBranch { start: a, end: z, depth, intensity: inten, main } = branch; + if output.segments.len() > 20_000 { return; } let (dx, dy) = (z.0 - a.0, z.1 - a.1); let len = (dx * dx + dy * dy).sqrt(); if depth == 0 || len < 1.0 { - out.push((a, z, inten)); - mains.push(main); + output.segments.push((a, z, inten)); + output.mains.push(main); return; } let (nx, ny) = (-dy / len.max(1e-9), dx / len.max(1e-9)); @@ -725,8 +740,8 @@ fn bolt(cfg: &BoltCfg, a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, mai if cfg.obstacle > 0.0 && !main && cfg.alpha_at(m) > 0.5 && rng.f() < cfg.obstacle / 10.0 { return; } - bolt(cfg, a, m, depth - 1, inten, main, rng, out, mains); - bolt(cfg, m, z, depth - 1, inten, main, rng, out, mains); + bolt(cfg, BoltBranch { start: a, end: m, depth: depth - 1, intensity: inten, main }, rng, output); + bolt(cfg, BoltBranch { start: m, end: z, depth: depth - 1, intensity: inten, main }, rng, output); if rng.f() < cfg.fork && inten > cfg.term && len >= cfg.min_fork { let ang = rng.s() * 0.7; let (s, c) = ang.sin_cos(); @@ -734,7 +749,7 @@ fn bolt(cfg: &BoltCfg, a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, mai let k = 1.2; // Fork Strength 50 % keeps the decayed intensity; Fork Variation randomises it. let fi = inten * (1.0 - cfg.decay) * cfg.fork_strength * 2.0 * if cfg.fork_var > 0.0 { 1.0 - cfg.fork_var * rng.f() as f32 } else { 1.0 }; - bolt(cfg, m, (m.0 + fx * k, m.1 + fy * k), depth.saturating_sub(1), fi.min(1.0), false, rng, out, mains); + bolt(cfg, BoltBranch { start: m, end: (m.0 + fx * k, m.1 + fy * k), depth: depth.saturating_sub(1), intensity: fi.min(1.0), main: false }, rng, output); } } @@ -772,10 +787,9 @@ pub fn lightning_segments(ctx: &EffectCtx, b: &Buf) -> Vec { fork_strength: (g("expertSettings/forkStrength", 50.0) / 100.0).clamp(0.0, 1.0) as f32, fork_var: (g("expertSettings/forkVariation", 0.0) / 100.0).clamp(0.0, 1.0) as f32, }; - let mut segs = Vec::new(); - let mut mains = Vec::new(); + let mut output = BoltOutput::default(); let (h, w) = (b.img.height as f64, b.img.width as f64); - let mut go = |a, z, depth, cfg: &BoltCfg, rng: &mut Rng| bolt(cfg, a, z, depth, 1.0, true, rng, &mut segs, &mut mains); + let mut go = |a, z, depth, cfg: &BoltCfg, rng: &mut Rng| bolt(cfg, BoltBranch { start: a, end: z, depth, intensity: 1.0, main: true }, rng, &mut output); // LIGHTNING_TYPES order. match kind { // Direction: travels toward the Direction point and beyond. @@ -811,6 +825,7 @@ pub fn lightning_segments(ctx: &EffectCtx, b: &Buf) -> Vec { // Strike. _ => go(o, d, depth, &cfg, &mut rng), } + let BoltOutput { segments: mut segs, mains } = output; let dist = |p: (f64, f64)| ((p.0 - o.0).powi(2) + (p.1 - o.1).powi(2)).sqrt(); let far = segs.iter().map(|(_, z, _)| dist(*z)).fold(1e-9, f64::max); // Decay Main Core: the whole bolt fades with distance from the origin. @@ -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 9e22ac8ae..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..319368a82 --- /dev/null +++ b/crates/effects/src/generate3/paths.rs @@ -0,0 +1,556 @@ +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 +} + +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. +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..0edad2f2d --- /dev/null +++ b/crates/effects/src/generate3/tests.rs @@ -0,0 +1,397 @@ +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); +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index cc0aad515..204e651ec 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -78,7 +78,7 @@ pub use color_fx::{ pub use color_fx::{COLORAMA_PRESETS, ColoramaPalette}; pub use color2::{Curve, auto_correct_settings, color_link_mode, color_link_sample, equalize_tables, luma_clip_points, shadow_highlight_amounts}; pub use color3::OffsetCurve; -pub use distort::transform_shutter; +pub use distort::{transform_grow, transform_matrix_px, transform_shutter}; pub use distort2::{MAGNIFY_MODES, bezier_grid, parse_mesh}; pub use distort3::{RESHAPE_POINTS, ReshapeSetup, SmearSetup, cc_smear_pad, reshape_setup, smear_setup, warp_inverse_map}; pub use distort4::liquify_mesh; @@ -86,7 +86,10 @@ use effectcraft_keyframe::Value; use effectcraft_project::build::Ids; use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property}; pub use effectcraft_raster::{AuxChannels, Image}; -pub use misc::{INVERT_ALPHA, INVERT_CHANNELS, glow_ab_t, glow_operation}; +pub use misc::{ + GLOW_GAIN, GLOW_KNEE, INVERT_ALPHA, INVERT_CHANNELS, glow_ab_t, glow_operation, glow_sigma, radial_wipe_sigma, radial_wipe_wedge, venetian_cover, + venetian_origin, +}; pub use sim::particle_state; pub use sim3::{PgBlit, PgPlan, playground_plan, playground_state}; pub use stylize2::strobe_on; @@ -104,7 +107,7 @@ pub use transition::place_layer; // effectcraft-gpu fx_noise. pub use keying2::{InnerOuterPlan, inner_outer_plan}; pub use noise::remove_grain_level; -pub use noise3::{FractalGpu, fractal_gpu}; +pub use noise3::{FractalGpu, NOISE_CELL, fractal_gpu}; pub use stylize3::{BumpLight, bump_layer_id}; pub use textfx::{TextLayer, text_layer}; pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time, time_frames, timewarp_crop, timewarp_plan}; @@ -287,6 +290,27 @@ impl Buf { self.offset[0] += origin[0] * self.scale; self.offset[1] += origin[1] * self.scale; } + /// Grow by transparent pixels: `sides` = left, top, right, bottom. Content keeps its place. + pub fn pad_sides(&mut self, sides: [u32; 4]) { + let [l, t, r, b] = sides; + if l == 0 && t == 0 && r == 0 && b == 0 { + return; + } + let (Some(w), Some(h)) = (self.img.width.checked_add(l).and_then(|v| v.checked_add(r)), self.img.height.checked_add(t).and_then(|v| v.checked_add(b))) + else { + return; + }; + let mut out = Image::new(w, h); + let (sw, ow) = (self.img.width as usize, w as usize); + for y in 0..self.img.height as usize { + let at = (y + t as usize) * ow + l as usize; + let (Some(src), Some(dst)) = (self.img.data.get(y * sw..(y + 1) * sw), out.data.get_mut(at..at + sw)) else { continue }; + dst.copy_from_slice(src); + } + self.img = out; + self.offset[0] += l as f64; + self.offset[1] += t as f64; + } /// Grow by `pad` transparent pixels on every side. pub fn pad(&mut self, pad: u32) { if pad == 0 { diff --git a/crates/effects/src/misc.rs b/crates/effects/src/misc.rs index 7468c6b84..e1f41954c 100644 --- a/crates/effects/src/misc.rs +++ b/crates/effects/src/misc.rs @@ -142,6 +142,28 @@ pub fn glow_ab_t(l: f32, looping: u32, loops: f32, phase: f32, mid: f32) -> f32 t.max(0.0).powf(0.5f32.ln() / m.ln()) } +/// Glow's blur: a Gaussian of σ = 0.384 × Glow Radius. Fitted to After Effects 2026 (cases +/// 807 and 808, Glow Radius 10 and 30 on a grey solid): the edge profile of the glow's +/// coverage is an erfc step of that σ within 1.7/255 (it was σ = radius / 2). +pub fn glow_sigma(radius: f64) -> f64 { + (radius * 0.384).max(0.5) +} + +/// Glow's bright pass for the original colours, per channel of the straight colour `v`: +/// `8 × (v − threshold + 0.0611)`, nothing below. Fitted to After Effects 2026 renders of grey +/// 0.65 / 0.8 / 0.9 / 1, orange and light-blue solids at Threshold 40 / 60 / 80 % and Radius 10 +/// / 30 (36 channel values, exact to the 8-bit capture): the glow is linear in each channel, +/// its gain is the same at every threshold and radius, and channels below the threshold add +/// nothing (an orange solid glows red only). +pub fn glow_bright_channel(v: f32, thr: f32) -> f32 { + (GLOW_GAIN * (v - thr + GLOW_KNEE)).max(0.0) +} + +/// [`glow_bright_channel`]'s gain and threshold offset. `kernels.wgsl` repeats both as literals; +/// the GPU test `glow_constants_match_the_cpu` checks that they are these. +pub const GLOW_GAIN: f32 = 8.0; +pub const GLOW_KNEE: f32 = 0.0611; + /// Glow Operation options (blend modes the glow is applied with). pub const GLOW_OPERATIONS: [&str; 17] = [ "Normal", @@ -205,23 +227,31 @@ fn glow(ctx: &EffectCtx, mut b: Buf) -> Buf { // Glow Based On: brightness from the colours, or from the alpha channel. let l = if alpha_based { a } else { luminance(px[0] / a, px[1] / a, px[2] / a) }; let k = ((l - thr) / (1.0 - thr).max(1e-3)).clamp(0.0, 1.0); + // Based on the colour channels, the glow's coverage is the layer's whole alpha, below + // the threshold too: After Effects darkens what lies under a dim layer's edge (glow_alpha). + let ga = if alpha_based { k * a } else { a }; if let Some(map) = &arbitrary { let t = glow_ab_t(l, looping, loops, phase, mid); let c = map.iter().map(|cv| cv.as_ref().map_or(t, |cv| cv.eval(t))).collect::>(); - *px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, k * a]; + *px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, ga]; } else if use_colors { let t = glow_ab_t(l, looping, loops, phase, mid); let c = [ca[0] + (cb[0] - ca[0]) * t, ca[1] + (cb[1] - ca[1]) * t, ca[2] + (cb[2] - ca[2]) * t]; - *px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, k * a]; + *px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, ga]; + } else if alpha_based { + *px = [px[0] * k, px[1] * k, px[2] * k, ga]; } else { - *px = px.map(|v| v * k); + // Original colours: each channel of the straight colour against the threshold. + let c = [px[0] / a, px[1] / a, px[2] / a].map(|v| glow_bright_channel(v, thr) * a); + *px = [c[0], c[1], c[2], ga]; } }); - let s = (radius / 2.0).max(0.5); + let s = glow_sigma(radius); let blurred = gaussian_blur(&bright, s * kx, s * ky, false); let mode = ctx.params.e("operation"); b.img.data.par_iter_mut().zip(blurred.data.par_iter()).for_each(|(o, g)| { - let g = g.map(|v| v * intensity); + // Intensity scales the glow's colour; with the colour channels its coverage stays. + let g = [g[0] * intensity, g[1] * intensity, g[2] * intensity, if alpha_based { g[3] * intensity } else { g[3] }]; match mode { // Behind: the glow shows only where the original is transparent. 1 => { @@ -525,22 +555,67 @@ fn linear_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { b } +/// Standard normal CDF Φ (Abramowitz & Stegun 7.1.26 erf, |error| < 1.5e-7). +fn norm_cdf(z: f64) -> f64 { + let x = z.abs() / std::f64::consts::SQRT_2; + let t = 1.0 / (1.0 + 0.327_591_1 * x); + let poly = t * (0.254_829_592 + t * (-0.284_496_736 + t * (1.421_413_741 + t * (-1.453_152_027 + t * 1.061_405_429)))); + let erf = 1.0 - poly * (-x * x).exp(); + if z >= 0.0 { 0.5 * (1.0 + erf) } else { 0.5 * (1.0 - erf) } +} + +/// Radial Wipe's wiped wedge relative to the Start Angle: (first ray, span), degrees clockwise +/// from up. Clockwise (0) wipes from the start angle on, Counterclockwise (1) back from it, +/// Both (2) half each way. +pub fn radial_wipe_wedge(wipe: u32, done: f64) -> (f64, f64) { + let span = done.clamp(0.0, 1.0) * 360.0; + match wipe { + 1 => (-span, span), + 2 => (-span / 2.0, span), + _ => (0.0, span), + } +} + +/// Radial Wipe's feather: a Gaussian of σ = 0.4 × Feather pixels across both edges of the wedge +/// (After Effects 2026, Feather 20: the edge profile is the same width in pixels at every +/// radius, σ = 8.0 px, mean error 0.0002 over the frame). +pub fn radial_wipe_sigma(feather: f64) -> f64 { + feather.max(0.0) * 0.4 +} + +/// Visible fraction of a pixel at `(ux, uy)` from the centre (y down) for a wiped wedge from +/// `ray0` (absolute degrees clockwise from up) over `span` degrees: the signed distance to the +/// wedge's nearer edge, through the feather's Gaussian. +pub fn radial_wipe_alpha(ux: f64, uy: f64, ray0: f64, span: f64, sigma: f64) -> f64 { + let a = (ux.atan2(-uy).to_degrees() - ray0).rem_euclid(360.0); + let wiped = a < span; + let ray_dist = |deg: f64| { + let (s, c) = deg.to_radians().sin_cos(); + // Ray direction (sin, −cos) in y-down pixels. + let along = ux * s - uy * c; + if along > 0.0 { (ux * c + uy * s).abs() } else { ux.hypot(uy) } + }; + let d = ray_dist(ray0).min(ray_dist(ray0 + span)); + let sd = if wiped { -d } else { d }; + if sigma < 1e-3 { + return if wiped { 0.0 } else { 1.0 }; + } + norm_cdf(sd / sigma) +} + fn radial_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { - let done = ctx.params.f("completion") / 100.0; + let done = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0); + if done <= 0.0 { + return b; + } let start = ctx.params.f("startAngle"); let c = b.to_px(ctx.params.v2("center")); - let dir = ctx.params.e("wipe"); - let feather = (ctx.params.f("feather")).max(0.01); + let (w0, span) = radial_wipe_wedge(ctx.params.e("wipe"), done); + let sigma = radial_wipe_sigma(ctx.params.f("feather")) * b.scale; + let full = done >= 1.0; b.img.rows_mut().for_each(|(y, row)| { for (x, px) in row.iter_mut().enumerate() { - let a = ((x as f64 + 0.5 - c.0).atan2(-(y as f64 + 0.5 - c.1)).to_degrees() - start).rem_euclid(360.0); - // Both: the wipe opens in both directions from the start angle at once. - let (a, edge) = match dir { - 1 => (360.0 - a, done * 360.0), - 2 => (a.min(360.0 - a), done * 180.0), - _ => (a, done * 360.0), - }; - let k = ((a - edge) / feather + 0.5).clamp(0.0, 1.0) as f32; + let k = if full { 0.0 } else { radial_wipe_alpha(x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1, start + w0, span, sigma) as f32 }; for ch in px.iter_mut() { *ch *= k; } @@ -549,16 +624,49 @@ fn radial_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { b } +/// Venetian Blinds: how much of a pixel at phase `d` (0..`width`) a band of `done × width` +/// covers, its two edges softened by a Gaussian of σ = Feather / 2 (σ in the units of `width`). +/// Fitted to After Effects 2026 (Completion 40, Direction 30, Width 50, Feather 5 on 960 × 540 +/// and 480 × 270 layers): band from phase 0 to done × width, both edges Gaussian, within 6/255. +pub fn venetian_cover(d: f64, width: f64, done: f64, sigma: f64) -> f64 { + let band = done * width; + if sigma < 1e-3 { + return if d < band { 1.0 } else { 0.0 }; + } + // Wider than a few blinds the stripes blur into an even veil. + if sigma > 8.0 * width { + return done.clamp(0.0, 1.0); + } + let n = ((4.0 * sigma / width).ceil() as i64 + 1).clamp(1, 40); + let mut t = 0.0; + for k in -n..=n { + let u = d + k as f64 * width; + t += norm_cdf(u / sigma) - norm_cdf((u - band) / sigma); + } + t.clamp(0.0, 1.0) +} + +/// Venetian Blinds' phase origin in layer pixels: the layer centre plus 0.6 px (fitted to the +/// AE renders above; the stripes are measured from the layer's centre, not its corner). +pub fn venetian_origin(layer_size: [f64; 2]) -> [f64; 2] { + [layer_size[0] / 2.0 + 0.6, layer_size[1] / 2.0 + 0.6] +} + fn venetian(ctx: &EffectCtx, mut b: Buf) -> Buf { - let done = ctx.params.f("completion") / 100.0; + let done = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0); + if done <= 0.0 { + return b; + } let ang = ctx.params.f("direction").to_radians(); let width = (ctx.params.f("width") * b.scale).max(1.0); - let feather = (ctx.params.f("feather") * b.scale).max(0.001); + let sigma = ctx.params.f("feather").max(0.0) * b.scale / 2.0; let (dx, dy) = (ang.cos(), ang.sin()); + let (ox, oy) = b.to_px(venetian_origin(ctx.layer_size)); b.img.rows_mut().for_each(|(y, row)| { + let ry = (y as f64 + 0.5 - oy) * dy; for (x, px) in row.iter_mut().enumerate() { - let d = ((x as f64 + 0.5) * dx + (y as f64 + 0.5) * dy).rem_euclid(width); - let k = ((d - done * width) / feather + 0.5).clamp(0.0, 1.0) as f32; + let d = ((x as f64 + 0.5 - ox) * dx + ry).rem_euclid(width); + let k = (1.0 - venetian_cover(d, width, done, sigma)) as f32; for c in px.iter_mut() { *c *= k; } @@ -807,6 +915,189 @@ mod tests { img } + /// After Effects 2026 (cases 524, 807, 808, 809): Glow on a 50 % grey solid, which is below + /// the 60 % threshold, still covers a blurred copy of the layer's alpha with black, so the + /// red layer underneath darkens outside the edge. The halo is the same at Intensity 1 and 3 + /// and with Composite Original On Top or Behind; its width follows Glow Radius. + #[test] + fn glow_halo_matches_after_effects() { + let grey = effectcraft_raster::Image::filled(200, 120, [0.5, 0.5, 0.5, 1.0]); + // AE's red under the halo at 0.5, 1.5, 3.5, 5.5 and 7.5 px outside the left edge + // (radius 10) and at 0.5, 5.5, 10.5, 20.5 px (radius 30); halo alpha = 1 − red. + let r10 = [(0.5, 140.0), (1.5, 165.0), (3.5, 208.0), (5.5, 234.0), (7.5, 248.0)]; + let r30 = [(0.5, 132.0), (5.5, 173.0), (10.5, 208.0), (20.5, 245.0)]; + for (radius, intensity, operation, profile) in [(10.0, 1.0, 1, &r10[..]), (10.0, 3.0, 1, &r10[..]), (10.0, 1.0, 0, &r10[..]), (30.0, 1.0, 1, &r30[..])] + { + let set = [ + ("based", Value::Enum(1)), + ("threshold", num(60.0)), + ("radius", num(radius)), + ("intensity", num(intensity)), + ("operation", Value::Enum(operation)), + ]; + let o = run_fx("ec.stylize.glow", &set, grey.clone(), 0.0, EffectEnv::default()); + for &(d, red) in profile { + // Layer x = −d (pixel centre), mid-height; over AE's red: R = 1 − α, colour 0. + let (bx, by) = o.to_px([-d, 60.0]); + let p = o.img.get(bx.floor() as i64, by.floor() as i64); + let ae = 1.0 - red / 255.0; + assert!((p[3] - ae).abs() < 3.0 / 255.0, "radius {radius} intensity {intensity} op {operation} at {d} px: alpha {} vs AE {ae}", p[3]); + assert!(p[0].abs() < 1e-6, "the halo is black: {p:?}"); + } + } + } + + /// After Effects 2026, Glow at Threshold 60 %, Radius 10, Intensity 0.25 on 200 × 200 + /// solids over red: the glow 1.5 px outside the left edge, premultiplied (the green / blue + /// channels of the render, where the red background adds nothing), and its alpha 0.353. + #[test] + fn glow_brightness_matches_after_effects() { + // (solid colour, channel, AE glow value 0..255) + let cases = [ + ([0.65, 0.65, 0.65], 1, 20.0), + ([0.8, 0.8, 0.8], 1, 47.0), + ([0.9, 0.9, 0.9], 1, 65.0), + ([1.0, 1.0, 1.0], 1, 83.0), + ([1.0, 0.5, 0.0], 1, 0.0), + ([0.2, 0.6, 1.0], 1, 11.0), + ([0.2, 0.6, 1.0], 2, 83.0), + ]; + for (c, ch, ae) in cases { + let img = effectcraft_raster::Image::filled(200, 200, [c[0], c[1], c[2], 1.0]); + let set = [("threshold", num(60.0)), ("radius", num(10.0)), ("intensity", num(0.25))]; + let o = run_fx("ec.stylize.glow", &set, img, 0.0, EffectEnv::default()); + let (bx, by) = o.to_px([-1.5, 100.0]); + let p = o.img.get(bx.floor() as i64, by.floor() as i64); + assert!((p[ch] * 255.0 - ae).abs() <= 2.5, "{c:?} channel {ch}: {} vs AE {ae}", p[ch] * 255.0); + assert!((p[3] - 0.353).abs() < 3.0 / 255.0, "{c:?}: alpha {}", p[3]); + } + // Intensity scales the colour linearly: white at Intensity 1 is 3.7 × its alpha. + let white = effectcraft_raster::Image::filled(200, 200, [1.0, 1.0, 1.0, 1.0]); + let o = run_fx("ec.stylize.glow", &[("threshold", num(60.0)), ("radius", num(10.0))], white, 0.0, EffectEnv::default()); + let (bx, by) = o.to_px([-1.5, 100.0]); + let p = o.img.get(bx.floor() as i64, by.floor() as i64); + assert!((p[1] / p[3] - 3.69).abs() < 0.05, "{p:?}"); + } + + /// After Effects 2026: Radial Blur keeps the layer's edges. Zoom 20 on a flat grey 960 × 540 + /// solid (case 713) leaves it flat and opaque to the edge (EffectCraft faded the edges toward + /// transparency, letting the red layer below show through); on a full-frame horizontal ramp + /// (case 829, centre [960, 540]) row 540 reads 2/255 at x = 2 and 253/255 at x = 1917. + #[test] + fn radial_blur_keeps_the_layer_edges_as_after_effects() { + let zoom = |c: [f64; 2]| [("amount", num(20.0)), ("type", Value::Enum(1)), ("center", Value::Vec2(c))]; + let grey = effectcraft_raster::Image::filled(960, 540, [0.5, 0.5, 0.5, 1.0]); + let o = run_fx("ec.blur.radial", &zoom([600.0, 300.0]), grey, 0.0, EffectEnv::default()); + for (x, y) in [(0, 270), (1, 270), (959, 270), (480, 0), (480, 539), (0, 0), (959, 539)] { + let p = o.img.get(x, y); + assert!((p[3] - 1.0).abs() < 1e-4 && (p[0] - 0.5).abs() < 1e-4, "({x}, {y}): {p:?}"); + } + let mut ramp = effectcraft_raster::Image::new(1920, 1080); + ramp.rows_mut().for_each(|(_, row)| { + for (x, px) in row.iter_mut().enumerate() { + let v = (x as f32 + 0.5) / 1920.0; + *px = [v, v, v, 1.0]; + } + }); + let o = run_fx("ec.blur.radial", &zoom([960.0, 540.0]), ramp, 0.0, EffectEnv::default()); + for (x, ae) in [(2, 2.0), (1917, 253.0)] { + let p = o.img.get(x, 540); + assert!((p[0] * 255.0 - ae).abs() <= 2.0 && (p[3] - 1.0).abs() < 1e-4, "x {x}: {p:?} vs AE {ae}"); + } + } + + /// After Effects 2026, Venetian Blinds Completion 40, Direction 30, Width 50, Feather 5 on + /// an opaque 480 × 270 layer: alpha (0..255) along row y = 135. The transparent band starts + /// at the layer centre's phase, both edges are Gaussian (σ = Feather / 2). + #[test] + fn venetian_blinds_match_after_effects() { + let img = effectcraft_raster::Image::filled(480, 270, [0.5, 0.5, 0.5, 1.0]); + let vals = [("completion", Value::Scalar(40.0)), ("direction", Value::Scalar(30.0)), ("width", Value::Scalar(50.0)), ("feather", Value::Scalar(5.0))]; + let o = run_fx("ec.transition.venetian", &vals, img, 0.0, EffectEnv::default()); + let ae = [ + (200, 5.0), + (203, 47.0), + (206, 145.0), + (209, 228.0), + (212, 253.0), + (224, 255.0), + (236, 238.0), + (239, 168.0), + (242, 66.0), + (245, 10.0), + (251, 0.0), + (257, 2.0), + (260, 31.0), + ]; + for (x, a) in ae { + let got = o.img.get(x, 135)[3] * 255.0; + assert!((got - a).abs() <= 6.0, "x {x}: alpha {got} vs AE {a}"); + } + // No completion leaves the layer; full completion clears it; no feather is a hard edge. + let full = [("completion", Value::Scalar(100.0)), ("feather", Value::Scalar(5.0))]; + let img = effectcraft_raster::Image::filled(40, 20, [0.5, 0.5, 0.5, 1.0]); + assert!(run_fx("ec.transition.venetian", &full, img.clone(), 0.0, EffectEnv::default()).img.data.iter().all(|p| p[3] < 1e-4)); + assert_eq!(run_fx("ec.transition.venetian", &[("completion", Value::Scalar(0.0))], img.clone(), 0.0, EffectEnv::default()).img, img); + let hard = run_fx( + "ec.transition.venetian", + &[("completion", Value::Scalar(50.0)), ("width", Value::Scalar(10.0)), ("feather", Value::Scalar(0.0))], + img, + 0.0, + EffectEnv::default(), + ); + assert!(hard.img.data.iter().all(|p| p[3] == 0.0 || p[3] == 1.0)); + // A feather far wider than the blinds is an even veil. + assert_eq!(venetian_cover(3.0, 10.0, 0.5, 1e9), 0.5); + } + + /// After Effects 2026, Radial Wipe Completion 40, Start Angle 30, Wipe Counterclockwise, + /// Feather 20, centre (800, 500): visible fraction at pixel offsets from the centre on both + /// edges (8-bit captures of dark steps, so ±0.05). + #[test] + fn radial_wipe_feather_matches_after_effects() { + let img = effectcraft_raster::Image::filled(400, 300, [1.0, 1.0, 1.0, 1.0]); + let vals = [ + ("completion", Value::Scalar(40.0)), + ("startAngle", Value::Scalar(30.0)), + ("center", Value::Vec2([200.0, 150.0])), + ("wipe", Value::Enum(1)), + ("feather", Value::Scalar(20.0)), + ]; + let o = run_fx("ec.transition.radialwipe", &vals, img.clone(), 0.0, EffectEnv::default()); + let ae = [ + ((-50, 34), 0.922), + ((-52, 30), 0.797), + ((-54, 26), 0.609), + ((-55, 24), 0.5), + ((-56, 22), 0.406), + ((-57, 19), 0.266), + ((-58, 15), 0.125), + ((-104, 60), 0.951), + ((-108, 53), 0.732), + ((-111, 45), 0.341), + ((-114, 37), 0.062), + ((22, -56), 0.156), + ((26, -54), 0.312), + ((30, -52), 0.531), + ((34, -50), 0.729), + ((37, -47), 0.875), + ((53, -108), 0.177), + ((67, -99), 0.875), + ]; + for ((dx, dy), a) in ae { + let got = o.img.get(200 + dx, 150 + dy)[3]; + assert!((got - a).abs() < 0.05, "offset ({dx}, {dy}): {got} vs AE {a}"); + } + // 0 % leaves the layer, 100 % clears it, Feather 0 is a hard edge. + let at = |c: f64, f: f64| { + let v = [("completion", Value::Scalar(c)), ("feather", Value::Scalar(f))]; + run_fx("ec.transition.radialwipe", &v, img.clone(), 0.0, EffectEnv::default()).img + }; + assert_eq!(at(0.0, 20.0), img); + assert!(at(100.0, 20.0).data.iter().all(|p| p[3] == 0.0)); + assert!(at(50.0, 0.0).data.iter().all(|p| p[3] == 0.0 || p[3] == 1.0)); + } + #[test] fn glow_operation_and_arbitrary_map() { // Pixels addressed in layer space (Glow pads its buffer). diff --git a/crates/effects/src/noise3.rs b/crates/effects/src/noise3.rs index bf1f05ed6..49f200553 100644 --- a/crates/effects/src/noise3.rs +++ b/crates/effects/src/noise3.rs @@ -60,6 +60,11 @@ pub const BLEND_NORMAL: u32 = 1; /// Index of "Soft Linear" in [`NOISE_TYPES`] (the default, and the GPU path's interpolation). pub const NOISE_SOFT_LINEAR: u32 = 2; +/// Lattice cell of Fractal / Turbulent Noise in pixels per percent of Scale: After Effects 2026 +/// draws Block noise at Scale 100 in 66.67 px cells (and Soft Linear noise has the matching +/// correlation length), not 100 px. +pub const NOISE_CELL: f64 = 2.0 / 3.0; + fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { EffectSpec { id, name, category: "Noise & Grain", params, render, gpu: false, float: true } } @@ -236,7 +241,7 @@ impl Fractal { overflow: pr.e("overflow"), invert: pr.b("invert"), rot: rot.sin_cos(), - size: [(sw * b.scale).max(1.0) as f32, (sh * b.scale).max(1.0) as f32], + size: [(sw * NOISE_CELL * b.scale).max(1.0) as f32, (sh * NOISE_CELL * b.scale).max(1.0) as f32], origin: [ox as f32, oy as f32], center: [cx as f32, cy as f32], perspective: pr.b("transform/perspectiveOffset"), @@ -535,14 +540,34 @@ mod tests { #[test] fn noise_types_differ_and_block_is_piecewise_constant() { - let a = run("ec.noise.fractal", &[("noiseType", Value::Enum(0)), ("complexity", num(1.0)), ("transform/scale", num(10.0))]); - // Block noise: neighbours inside one 10 px cell share a value. + let a = run("ec.noise.fractal", &[("noiseType", Value::Enum(0)), ("complexity", num(1.0)), ("transform/scale", num(15.0))]); + // Block noise: neighbours inside one 10 px cell (Scale 15) share a value. assert_eq!(a.get(4, 4), a.get(5, 5)); let others: Vec = (1..4).map(|t| run("ec.noise.fractal", &[("noiseType", Value::Enum(t)), ("transform/scale", num(10.0))])).collect(); assert_ne!(others[0], others[1]); assert_ne!(others[1], others[2]); } + /// After Effects 2026 (R9): Block noise at Scale 100, Complexity 1 changes value every + /// 66.67 px along a row. + #[test] + fn block_noise_cells_are_two_thirds_of_scale_as_in_after_effects() { + let img = run_fx( + "ec.noise.fractal", + &[("noiseType", Value::Enum(0)), ("complexity", num(1.0)), ("blendingMode", Value::Enum(BLEND_NONE))], + Image::filled(400, 8, [0.0, 0.0, 0.0, 1.0]), + 0.0, + EffectEnv::default(), + ) + .img; + let changes: Vec = (1..400).filter(|&x| img.get(x, 4) != img.get(x - 1, 4)).collect(); + assert!(changes.len() >= 4, "{changes:?}"); + for w in changes.windows(2) { + let d = (w[1] - w[0]) as f64; + assert!((d - 200.0 / 3.0).abs() <= 1.0, "cell width {d}: {changes:?}"); + } + } + #[test] fn scale_width_height_apply_without_uniform_scaling() { let base = &[("complexity", num(1.0)), ("transform/scale", num(8.0))]; diff --git a/crates/effects/src/object_matte.rs b/crates/effects/src/object_matte.rs index d98ec73ad..e61357c9f 100644 --- a/crates/effects/src/object_matte.rs +++ b/crates/effects/src/object_matte.rs @@ -358,20 +358,24 @@ pub enum FrameMatte { Outside, } -/// The matte of layer frame `f` on the layer grid of `img` (the effect's input on that frame at -/// `scale`), computing what is missing. `frame(g)`: the input on frame g on the same grid. -#[allow(clippy::too_many_arguments)] +/// The layer grid and timing used to compute one object matte. +pub struct MatteFrame<'a> { + pub layer_size: [f64; 2], + pub fps: f64, + pub frame: i64, + pub scale: f64, + pub img: &'a Image, +} + +/// Compute the object's alpha for one layer frame, loading other frames on the same grid as needed. pub fn frame_matte( params: &Params, - layer_size: [f64; 2], - fps: f64, - f: i64, - scale: f64, - img: &Image, + input: &MatteFrame<'_>, model: Option>, 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 +412,7 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf { if let Some(a) = frozen_alpha(ctx.params, layer_size, fps, f, w, h, model.as_deref()) { return compose(&buf, Some(&a), w, h, b(ctx.params, "invert"), param(ctx.params, "view").map(Value::as_enum).unwrap_or(0) == 1); } - let alpha = match frame_matte(ctx.params, layer_size, fps, f, sc, &img, model, &mut frame, &|| false) { + let alpha = match frame_matte(ctx.params, &MatteFrame { layer_size, fps, frame: f, scale: sc, img: &img }, model, &mut frame, &|| false) { FrameMatte::Alpha(a) if a.len() == w * h => Some(a), FrameMatte::NoPrompt => return buf, _ => None, @@ -608,7 +612,11 @@ mod tests { assert_ne!(seed(&p, [100.0, 80.0], 25.0, Some(stub.as_ref())), seed(&p, [100.0, 80.0], 25.0, None)); let img = frame_at(0); let mut frame = |g: i64| Some(Arc::new(frame_at(g))); - let FrameMatte::Alpha(a) = frame_matte(&p, [100.0, 80.0], 25.0, 0, 1.0, &img, Some(stub.clone()), &mut frame, &|| false) else { panic!("no matte") }; + let FrameMatte::Alpha(a) = + frame_matte(&p, &MatteFrame { layer_size: [100.0, 80.0], fps: 25.0, frame: 0, scale: 1.0, img: &img }, Some(stub.clone()), &mut frame, &|| false) + else { + panic!("no matte") + }; assert!(a[40 * 100 + 40] > 0.5 && a[5 * 100 + 5] < 0.5); assert_eq!(stub.calls.load(std::sync::atomic::Ordering::Relaxed), 1); assert_eq!(*stub.bbox.lock().unwrap(), None); @@ -634,7 +642,11 @@ mod tests { let stub = Arc::new(Stub::default()); let img = frame_at(0); let mut frame = |g: i64| Some(Arc::new(frame_at(g))); - let FrameMatte::Alpha(a) = frame_matte(&p, [100.0, 80.0], 25.0, 0, 1.0, &img, Some(stub.clone()), &mut frame, &|| false) else { panic!("no matte") }; + let FrameMatte::Alpha(a) = + frame_matte(&p, &MatteFrame { layer_size: [100.0, 80.0], fps: 25.0, frame: 0, scale: 1.0, img: &img }, Some(stub.clone()), &mut frame, &|| false) + else { + panic!("no matte") + }; assert_eq!(*stub.bbox.lock().unwrap(), Some([74.0, 54.0, 100.0, 80.0])); assert!(a[70 * 100 + 90] > 0.5 && a[40 * 100 + 40] < 0.5); // At half resolution the box is scaled with the frame (another Input Key, so the @@ -643,7 +655,8 @@ mod tests { p.values.insert(INPUT_KEY.into(), Value::Str("half".into())); let half = effectcraft_raster::resample(&img, 50, 40); let mut frame = |g: i64| Some(Arc::new(effectcraft_raster::resample(&frame_at(g), 50, 40))); - let _ = frame_matte(&p, [100.0, 80.0], 25.0, 0, 0.5, &half, Some(stub.clone()), &mut frame, &|| false); + let _ = + frame_matte(&p, &MatteFrame { layer_size: [100.0, 80.0], fps: 25.0, frame: 0, scale: 0.5, img: &half }, Some(stub.clone()), &mut frame, &|| false); assert_eq!(*stub.bbox.lock().unwrap(), Some([37.0, 27.0, 50.0, 40.0])); } diff --git a/crates/effects/src/ocio.rs b/crates/effects/src/ocio.rs index df579964f..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 41c74b4e4..af860c2fc 100644 --- a/crates/effects/src/ocio_config.rs +++ b/crates/effects/src/ocio_config.rs @@ -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/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..2467a719d 100644 --- a/crates/effects/src/roto.rs +++ b/crates/effects/src/roto.rs @@ -634,7 +634,7 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf { let (w, h) = (img.width as usize, img.height as usize); let mp = matte_params(ctx.params); let mut rgb = rb::rgb_of(&img); - let map = refine::decontaminate(&mut rgb, &alpha, &band, w, h, &mp, rr * sc, sc); + let map = refine::decontaminate(&mut rgb, &alpha, &band, (w, h), &mp, rr * sc, sc); let view_map = b(ctx.params, "refineEdgeMatte/decontamination/viewDecontaminationMap"); let (ox, oy) = (buf.offset[0].round() as i64, buf.offset[1].round() as i64); for y in 0..h { diff --git a/crates/effects/src/sim.rs b/crates/effects/src/sim.rs index 1bb82f46e..3a0743af2 100644 --- a/crates/effects/src/sim.rs +++ b/crates/effects/src/sim.rs @@ -18,2016 +18,25 @@ use crate::generate::value_noise; use crate::util::{SimCache, hash1, params_key, 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)] +pub(crate) use engine::{PState, PW_ANIMS, Phys, simulate}; +#[cfg(test)] +use engine::{PW_EFFECT_CAMERA, sim_key}; +pub(crate) use engine::{SPS, steps_at}; +pub use particle_fx::{Blob, mercury_blobs}; +pub use registry::{specs, sprite_plan}; +pub use sprites::{Acc, Post, Shape, Sprite, SpritePlan, Tint}; +pub(crate) use sprites::{combine, raster, splat}; +pub use weather::{Bubble, bubble_list, drizzle_drops}; #[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..b379636ce --- /dev/null +++ b/crates/effects/src/sim/engine.rs @@ -0,0 +1,288 @@ +use super::common::{h, hs}; +use super::particle_fx::{PS2_CACHE, PW_CACHE, 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, +} + +/// 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 } + } + + 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). +pub(super) 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). +pub(super) fn sim_key(ctx: &EffectCtx, salt: u64) -> u64 { + params_key_except(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, salt, &[PW_EFFECT_CAMERA]) +} + +/// Run a particle system to layer time `t` through `cache`. +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..6b6c27b01 --- /dev/null +++ b/crates/effects/src/sim/particle_fx.rs @@ -0,0 +1,367 @@ +use super::engine::{PState, PW_EFFECT_CAMERA, Phys, accelerated, norm3, particle_look, sim_key, simulate, type_sprite}; +use super::sprites::{Acc, Post, Sprite, SpritePlan, raster, splat}; +use super::{Buf, EffectCtx, Image, IntoParallelRefIterator, ParallelIterator, SimCache, Value, params_key, unpremul}; + +// ------------------------------------------------------------------ CC Particle World + +pub(super) static PW_CACHE: SimCache = SimCache::new(6); + +/// CC Particle World's physics. +pub(super) fn pw_phys(ctx: &EffectCtx) -> Phys { + let pr = ctx.params; + Phys { + rate: pr.f("birthRate").max(0.0) * 120.0, + 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, + } +} + +pub(super) 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, + } +} + +/// 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 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 + +pub(super) static PS2_CACHE: SimCache = SimCache::new(6); + +/// 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) * 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, + } +} + +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 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() +} + +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..f29eb2653 --- /dev/null +++ b/crates/effects/src/sim/registry.rs @@ -0,0 +1,229 @@ +use super::common::{pct, pt, spec}; +use super::engine::{PARTICLE_TYPES, PW_ANIMS}; +use super::particle_fx::{PW_FOV, mr_mercury, particle_systems2, particle_systems2_plan, particle_world, particle_world_plan}; +use super::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("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..78c405abd --- /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 + } +} + +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..c6f83ab4e --- /dev/null +++ b/crates/effects/src/sim/tests.rs @@ -0,0 +1,407 @@ +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)); +} diff --git a/crates/effects/src/sim/weather.rs b/crates/effects/src/sim/weather.rs new file mode 100644 index 000000000..b93c0e48b --- /dev/null +++ b/crates/effects/src/sim/weather.rs @@ -0,0 +1,352 @@ +use super::common::{h, hs}; +use super::sprites::{Acc, Post, Shape, Sprite, SpritePlan, Tint, aa, raster}; +use super::{Buf, EffectCtx, Image, IntoParallelIterator, ParallelIterator, unpremul}; + +// ------------------------------------------------------------------ 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() +} + +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 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) +} + +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 939c36757..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/warp_stab.rs b/crates/effects/src/warp_stab.rs index f850bdcf3..226654a04 100644 --- a/crates/effects/src/warp_stab.rs +++ b/crates/effects/src/warp_stab.rs @@ -453,7 +453,7 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf { }); let need = need_m.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner); if synth && need.iter().any(|w| *w > 0.0) { - synthesize(ctx, &a, k, &src_of, &crop, sc, &mut out, &need); + synthesize(ctx, &a, k, &SynthesisGrid { src_of: &src_of, crop: &crop, scale: sc }, &mut out, &need); } if b(ctx.params, "advanced/showTrackPoints") { let r = (2.5 * f(ctx.params, "advanced/trackPointSize") / 100.0 * sc).max(0.5); @@ -465,19 +465,16 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf { Buf { img: out, offset: [0.0; 2], scale: sc } } -/// Fill the pixels flagged in `need` (weight = how much synthesized content they take) from -/// neighbouring frames, nearest first. -#[allow(clippy::too_many_arguments)] -fn synthesize( - ctx: &EffectCtx, - a: &WarpAnalysis, - k: usize, - src_of: &(dyn Fn([f64; 2]) -> [f64; 2] + Sync), - crop: &[f64; 4], - sc: f64, - out: &mut Image, - need: &[f32], -) { +/// Maps an output sample into the uncropped source grid shared by neighbouring frames. +struct SynthesisGrid<'a> { + src_of: &'a (dyn Fn([f64; 2]) -> [f64; 2] + Sync), + crop: &'a [f64; 4], + scale: f64, +} + +/// Fill the pixels flagged in `need` from neighbouring frames, nearest first. +fn synthesize(ctx: &EffectCtx, a: &WarpAnalysis, k: usize, grid: &SynthesisGrid<'_>, out: &mut Image, need: &[f32]) { + let SynthesisGrid { src_of, crop, scale: sc } = *grid; let Some(host) = ctx.env.host else { return }; let n = a.frames.len(); let range = ((f(ctx.params, "advanced/synthesizeInputRange").max(0.0) / a.frame_duration.max(1e-6)).round() as usize).min(60); 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 100% rename from crates/engine/src/camera_track.rs rename to crates/engine/src/analysis/camera_track.rs 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/models.rs b/crates/engine/src/analysis/models.rs similarity index 100% rename from crates/engine/src/models.rs rename to crates/engine/src/analysis/models.rs diff --git a/crates/engine/src/roto.rs b/crates/engine/src/analysis/roto.rs similarity index 98% rename from crates/engine/src/roto.rs rename to crates/engine/src/analysis/roto.rs index d9ce6e5af..813561969 100644 --- a/crates/engine/src/roto.rs +++ b/crates/engine/src/analysis/roto.rs @@ -450,7 +450,18 @@ async fn run_work(w: Work, shared: &RotoShared) { (RotoTask::Propagate, true) => om::ensure(&w.params, w.size, w.fps, *f, model.clone(), &mut get, &cancel).is_some(), (RotoTask::Freeze, true) => { let img = get(*f); - match img.map(|img| (om::frame_matte(&w.params, w.size, w.fps, *f, 1.0, &img, model.clone(), &mut get, &cancel), img)) { + match img.map(|img| { + ( + om::frame_matte( + &w.params, + &om::MatteFrame { layer_size: w.size, fps: w.fps, frame: *f, scale: 1.0, img: &img }, + model.clone(), + &mut get, + &cancel, + ), + img, + ) + }) { Some((om::FrameMatte::Alpha(a), img)) => { frozen.size = [img.width as usize, img.height as usize]; frozen.frames.insert(*f, fx::freeze_frame(&a, &[])); diff --git a/crates/engine/src/tracking.rs b/crates/engine/src/analysis/tracking.rs similarity index 100% rename from crates/engine/src/tracking.rs rename to crates/engine/src/analysis/tracking.rs diff --git a/crates/engine/src/warp.rs b/crates/engine/src/analysis/warp.rs similarity index 100% rename from crates/engine/src/warp.rs rename to crates/engine/src/analysis/warp.rs 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 55% rename from crates/engine/src/remote.rs rename to crates/engine/src/background/remote.rs index 66417ccee..6539ac767 100644 --- a/crates/engine/src/remote.rs +++ b/crates/engine/src/background/remote.rs @@ -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..cba6df6ef --- /dev/null +++ b/crates/engine/src/cmd.rs @@ -0,0 +1,296 @@ +//! 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 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_p, merge_p, 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 100% rename from crates/engine/src/commands/anim.rs rename to crates/engine/src/cmd/anim.rs diff --git a/crates/engine/src/commands/anim_presets.rs b/crates/engine/src/cmd/anim_presets.rs similarity index 100% rename from crates/engine/src/commands/anim_presets.rs rename to crates/engine/src/cmd/anim_presets.rs diff --git a/crates/engine/src/commands/anim_tools.rs b/crates/engine/src/cmd/anim_tools.rs similarity index 100% rename from crates/engine/src/commands/anim_tools.rs rename to crates/engine/src/cmd/anim_tools.rs diff --git a/crates/engine/src/commands/animation.rs b/crates/engine/src/cmd/animation.rs similarity index 100% rename from crates/engine/src/commands/animation.rs rename to crates/engine/src/cmd/animation.rs diff --git a/crates/engine/src/commands/app_more.rs b/crates/engine/src/cmd/app_more.rs similarity index 100% rename from crates/engine/src/commands/app_more.rs rename to crates/engine/src/cmd/app_more.rs diff --git a/crates/engine/src/commands/autotrace.rs b/crates/engine/src/cmd/autotrace.rs similarity index 100% rename from crates/engine/src/commands/autotrace.rs rename to crates/engine/src/cmd/autotrace.rs diff --git a/crates/engine/src/commands/batch.rs b/crates/engine/src/cmd/batch.rs similarity index 100% rename from crates/engine/src/commands/batch.rs rename to crates/engine/src/cmd/batch.rs diff --git a/crates/engine/src/commands/camera_cmds.rs b/crates/engine/src/cmd/camera_cmds.rs similarity index 100% rename from crates/engine/src/commands/camera_cmds.rs rename to crates/engine/src/cmd/camera_cmds.rs diff --git a/crates/engine/src/commands/comp.rs b/crates/engine/src/cmd/comp.rs similarity index 100% rename from crates/engine/src/commands/comp.rs rename to crates/engine/src/cmd/comp.rs diff --git a/crates/engine/src/commands/comp_more.rs b/crates/engine/src/cmd/comp_more.rs similarity index 100% rename from crates/engine/src/commands/comp_more.rs rename to crates/engine/src/cmd/comp_more.rs diff --git a/crates/engine/src/commands/content_fill.rs b/crates/engine/src/cmd/content_fill.rs similarity index 100% rename from crates/engine/src/commands/content_fill.rs rename to crates/engine/src/cmd/content_fill.rs diff --git a/crates/engine/src/commands/create.rs b/crates/engine/src/cmd/create.rs similarity index 100% rename from crates/engine/src/commands/create.rs rename to crates/engine/src/cmd/create.rs diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/cmd/edit.rs similarity index 99% rename from crates/engine/src/commands/edit.rs rename to crates/engine/src/cmd/edit.rs index d0dbabb5b..0130aff70 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -847,10 +847,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, })) } diff --git a/crates/engine/src/commands/effect.rs b/crates/engine/src/cmd/effect.rs similarity index 71% rename from crates/engine/src/commands/effect.rs rename to crates/engine/src/cmd/effect.rs index 4f263b6fb..0a1354fd6 100644 --- a/crates/engine/src/commands/effect.rs +++ b/crates/engine/src/cmd/effect.rs @@ -190,6 +190,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 +211,135 @@ impl CryptoHit { } } -/// The ID under `{x, y}` (effect space, which is layer space on footage and precomp layers) for -/// the Cryptomatte effect `{layer?, effect?}`, from the aux channels its render reads. The point -/// snaps to the centre of the pixel the effect's input buffer samples in a render at `scale` -/// (the viewer's resolution). -fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result> { +/// 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 }; + 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 +350,175 @@ 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 }; + 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 }; - // The grid of the effect's input buffer (footage keeps native pixels from 3/4 scale up, - // proxies have their own size, earlier effects can pad it), rendered and cached. - let b = r.layer_input(&ctx, layer, pos).ok_or_else(|| bad(cmd, "this layer has no pixels"))?; + let (project, footage, expr, cache, accel, slot) = + (s.project.clone(), s.footage.clone(), s.expr.clone(), s.layer_cache.clone(), s.accel.clone(), s.crypto_probe.clone()); + let opts = s.render_opts(effectcraft_render::RenderOpts { scale: key.scale, ..Default::default() }); + // The slot while this is still the newest request. + fn current(slot: &std::sync::Mutex, 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 }; + 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 +532,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 +546,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); @@ -732,14 +1017,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 100% rename from crates/engine/src/commands/essential.rs rename to crates/engine/src/cmd/essential.rs diff --git a/crates/engine/src/commands/expr_tools.rs b/crates/engine/src/cmd/expr_tools.rs similarity index 100% rename from crates/engine/src/commands/expr_tools.rs rename to crates/engine/src/cmd/expr_tools.rs diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/cmd/file.rs similarity index 100% rename from crates/engine/src/commands/file.rs rename to crates/engine/src/cmd/file.rs diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/cmd/file_more.rs similarity index 96% rename from crates/engine/src/commands/file_more.rs rename to crates/engine/src/cmd/file_more.rs index 74a86e1d8..c9d581581 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/cmd/file_more.rs @@ -430,6 +430,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 ▸